{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "bb7d7319",
   "metadata": {},
   "source": [
    "# Lab 3 — homeostasis by diffusion: the shared light/RF field ¶\n",
    "\n",
    "A homeostasis-by-diffusion lab manual. The goal: demonstrate collective regulation with zero coordination messages on a cluster of ESP32s sharing one physical medium.\n",
    "\n",
    "The one rule that makes this real: the regulating scalar NEVER becomes a packet. No node broadcasts its reading. Each node senses the common bath locally and responds locally. The moment you put the scalar on the network, you've rebuilt the digital layer and thrown away the point.\n",
    "\n",
    "Run the cells to model it first, then build the bench. Everything is editable."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3653d244",
   "metadata": {},
   "source": [
    "## 1. Bill of materials ¶\n",
    "\n",
    "- 8x ESP32-S3 N16R8 dev boards (ESP32-S3-DevKitC-1 layout)\n",
    "\n",
    "- 8x light sensors (photodiode/LDR + ADC) OR just use WiFi RSSI as the field\n",
    "\n",
    "- 8x emitters (an LED per node) if using light; the radios themselves if using RF\n",
    "\n",
    "- one shared enclosure or space so the field is common\n",
    "\n",
    "- (light version is the most visible — you can watch the diffusion in space)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a8bc3d70",
   "metadata": {},
   "source": [
    "## 2. Wiring — the shared medium ¶\n",
    "\n",
    "Each node adds to a shared ambient field — light from its LED, or RF energy from its radio — and senses the total field locally with a photodiode (or RSSI). The enclosure/space is the medium. Nodes near a bright spot read a higher field and back off more, so a spatial gradient emerges — literal diffusion you can map. No addressed messages: each node just senses the common field and dims itself."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7eab8b19",
   "metadata": {},
   "source": [
    "## 3. Model it before you build it ¶\n",
    "\n",
    "This simulates N nodes, each following a droop curve on their own local reading of the shared medium. No node messages any other. Watch the ambient scalar settle to the setpoint and the load self-balance — homeostasis with no coordinator."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1ec7eb0a",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "# ---- the swarm ----\n",
    "N          = 8            # ESP32 nodes sharing ONE medium\n",
    "SETPOINT   = 500.0   # target ambient ambient field\n",
    "AMBIENT0   = 200.0   # starting ambient ambient field (lux)\n",
    "DIFFUSION  = 0.15         # how fast the shared medium mixes (the \"wetness\")\n",
    "LEAK       = 0.04         # medium bleeds back toward rest when load drops\n",
    "NOISE      = 0.03         # medium is physical -> a little noise\n",
    "STEPS      = 600\n",
    "\n",
    "# each node's droop curve: the more the LOCAL medium exceeds setpoint,\n",
    "# the more this node throttles its own load. NO node messages any other node.\n",
    "gain   = np.random.uniform(0.6, 1.4, N)   # nodes differ (real hardware does)\n",
    "demand = np.random.uniform(0.7, 1.0, N)   # each wants to run this hard\n",
    "\n",
    "# each node senses the shared medium with a little local offset (sensor spread)\n",
    "local_bias = np.random.uniform(-0.4, 0.4, N)\n",
    "\n",
    "ambient = AMBIENT0\n",
    "amb_hist, load_hist = [], []\n",
    "\n",
    "for t in range(STEPS):\n",
    "    # each node reads ONLY its own local view of the shared medium\n",
    "    local = ambient + local_bias + np.random.normal(0, NOISE, N)\n",
    "    # droop: throttle in proportion to how far local sits above setpoint\n",
    "    over  = np.clip(local - SETPOINT, 0, None)\n",
    "    load  = np.clip(demand - gain*over, 0.0, 1.0)   # analog, local, no address\n",
    "    # the shared medium integrates every node's load (diffusion) + leaks to rest\n",
    "    ambient += DIFFUSION*(load.mean()) - LEAK*(ambient - AMBIENT0*0.4)\n",
    "    ambient += np.random.normal(0, NOISE)\n",
    "    amb_hist.append(ambient)\n",
    "    load_hist.append(load.mean())\n",
    "\n",
    "amb_hist = np.array(amb_hist); load_hist = np.array(load_hist)\n",
    "\n",
    "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8,6), sharex=True)\n",
    "ax1.plot(amb_hist, color=\"#1D9E75\", lw=1.5, label=\"ambient ambient field (the shared bath)\")\n",
    "ax1.axhline(SETPOINT, color=\"#BA7517\", ls=\"--\", lw=1, label=\"setpoint\")\n",
    "ax1.set_ylabel(\"ambient field (lux)\"); ax1.legend(loc=\"upper right\"); ax1.grid(alpha=0.3)\n",
    "ax1.set_title(\"homeostasis by diffusion — field medium, no coordinator, no messages\")\n",
    "ax2.plot(load_hist, color=\"#185FA5\", lw=1.5)\n",
    "ax2.set_ylabel(\"mean node load\"); ax2.set_xlabel(\"time step\"); ax2.grid(alpha=0.3)\n",
    "ax2.set_ylim(0,1.05)\n",
    "plt.tight_layout(); plt.show()\n",
    "\n",
    "print(f\"settled ambient: {amb_hist[-50:].mean():.3f} (setpoint {SETPOINT})\")\n",
    "print(f\"settled load   : {load_hist[-50:].mean():.3f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a5cded42",
   "metadata": {},
   "source": [
    "## 4. The read-only manifold gauge ¶\n",
    "\n",
    "A lone sensor node reporting the field level, emitting nothing, is your read-only manifold gauge. The light version lets you literally SEE the bath — the gauge just puts a number on what your eyes already read."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d471f164",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ---- read-only manifold gauge: SHOW but never TOUCH the medium ----\n",
    "# This reads the shared bath and displays it. It has NO control authority.\n",
    "# The regulation happens in the physics; this is pure observation, like your\n",
    "# InfluxDB pipeline watching the SmartShunt.\n",
    "\n",
    "def manifold_gauge(value, setpoint=500.0, lo=200.00, hi=800.00):\n",
    "    import matplotlib.pyplot as plt\n",
    "    import numpy as np\n",
    "    fig, ax = plt.subplots(figsize=(7,1.6))\n",
    "    ax.barh([0], [hi-lo], left=lo, height=0.5, color=\"#E1F5EE\")\n",
    "    ax.barh([0], [value-lo], left=lo, height=0.5, color=\"#1D9E75\")\n",
    "    ax.axvline(setpoint, color=\"#BA7517\", ls=\"--\", lw=1.5)\n",
    "    ax.text(setpoint, 0.5, \"setpoint\", ha=\"center\", va=\"bottom\", fontsize=9, color=\"#BA7517\")\n",
    "    ax.text(value, -0.6, f\"{value:.2f} lux\", ha=\"center\", va=\"top\",\n",
    "            fontsize=11, fontweight=\"bold\", color=\"#0F6E56\")\n",
    "    ax.set_xlim(lo, hi); ax.set_ylim(-1, 1); ax.set_yticks([])\n",
    "    ax.set_title(\"manifold gauge — ambient field (read-only, no control authority)\")\n",
    "    plt.tight_layout(); plt.show()\n",
    "\n",
    "# show the settled value on the gauge\n",
    "manifold_gauge(amb_hist[-50:].mean())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8453b484",
   "metadata": {},
   "source": [
    "## 5. Bench procedure ¶\n",
    "\n",
    "1. Flash the droop firmware; node reduces its emission as the local field rises past setpoint.\n",
    "\n",
    "2. Power on in the shared space; the field builds from their combined emission.\n",
    "\n",
    "3. Watch: the field settles near setpoint as nodes collectively dim — and because it's spatial, you'll see brighter and dimmer zones self-organize.\n",
    "\n",
    "4. Perturb: shine an external light on one corner (raise the local field) and watch just the nearby nodes dim while distant ones hold — diffusion in space.\n",
    "\n",
    "5. Map local readings across positions to visualize the gradient. Log read-only."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "103c5bca",
   "metadata": {},
   "source": [
    "## 6. The droop curve — the whole analog brain of a node ¶\n",
    "\n",
    "This is all the intelligence each node needs. No messages, no coordinator — just: read local medium, throttle in proportion to overshoot. Tune GAIN and SETPOINT and re-run to feel how the swarm's stability changes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7aa3f113",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np, matplotlib.pyplot as plt\n",
    "SETPOINT = 500.0\n",
    "GAIN     = 1.0\n",
    "local = np.linspace(60.00, 360.00, 200)\n",
    "load  = np.clip(1.0 - GAIN*np.clip(local-SETPOINT,0,None), 0, 1)\n",
    "plt.figure(figsize=(7,4))\n",
    "plt.plot(local, load, color=\"#185FA5\", lw=2)\n",
    "plt.axvline(SETPOINT, color=\"#BA7517\", ls=\"--\", lw=1, label=\"setpoint\")\n",
    "plt.xlabel(\"local ambient field this node senses (lux)\")\n",
    "plt.ylabel(\"this node's load (0-1)\")\n",
    "plt.title(\"the droop curve — the entire control law, run locally on each ESP32\")\n",
    "plt.legend(); plt.grid(alpha=0.3); plt.tight_layout(); plt.show()\n",
    "\n",
    "# pseudo-firmware, the whole loop that runs on each node:\n",
    "print(\"\"\"\n",
    "loop():\n",
    "    local = read_sensor()              # sense the shared bath, locally\n",
    "    over  = max(0, local - SETPOINT)\n",
    "    load  = clamp(1.0 - GAIN*over, 0, 1)\n",
    "    set_work(load)                     # throttle own work; tell NO ONE\n",
    "    sleep(dt)\n",
    "\"\"\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a5538c03",
   "metadata": {},
   "source": [
    "## 7. Scratchpad ¶\n",
    "\n",
    "Swap the medium, add nodes, inject a hot spot, log to InfluxDB (read-only!). The manifold is observed, never commanded."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23d2aa06",
   "metadata": {},
   "outputs": [],
   "source": [
    "# your experiments here"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.12"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
