Lab 1 — homeostasis by diffusion: the thermal medium¶
A homeostasis-by-diffusion lab manual. The goal: demonstrate collective regulation with zero coordination messages on a cluster of ESP32s sharing one physical medium.
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.
Run the cells to model it first, then build the bench. Everything is editable.
1. Bill of materials¶
- 8x ESP32-S3 N16R8 dev boards (ESP32-S3-DevKitC-1 layout)
- 8x temperature sensors (DS18B20 or the ESP32's internal, or a cheap NTC + ADC)
- 1x insulated enclosure (a cooler, a foam box, or a length of capped pipe)
- optional per node: a small resistor or the CPU busy-loop as the heat source
- USB power for each board
2. Wiring — the shared medium¶
All 8 boards go inside one insulated enclosure. The enclosure air is your interstitial fluid — the shared bath. Each board reads only its own local sensor and dissipates heat under load (drive a resistor, or just run a busy-loop). Nothing is wired between boards for control. The only shared thing is the air they're all soaking in.
3. Model it before you build it¶
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.
import numpy as np
import matplotlib.pyplot as plt
# ---- the swarm ----
N = 8 # ESP32 nodes sharing ONE medium
SETPOINT = 30.0 # target ambient temperature
AMBIENT0 = 24.0 # starting ambient temperature (degC)
DIFFUSION = 0.15 # how fast the shared medium mixes (the "wetness")
LEAK = 0.04 # medium bleeds back toward rest when load drops
NOISE = 0.03 # medium is physical -> a little noise
STEPS = 600
# each node's droop curve: the more the LOCAL medium exceeds setpoint,
# the more this node throttles its own load. NO node messages any other node.
gain = np.random.uniform(0.6, 1.4, N) # nodes differ (real hardware does)
demand = np.random.uniform(0.7, 1.0, N) # each wants to run this hard
# each node senses the shared medium with a little local offset (sensor spread)
local_bias = np.random.uniform(-0.4, 0.4, N)
ambient = AMBIENT0
amb_hist, load_hist = [], []
for t in range(STEPS):
# each node reads ONLY its own local view of the shared medium
local = ambient + local_bias + np.random.normal(0, NOISE, N)
# droop: throttle in proportion to how far local sits above setpoint
over = np.clip(local - SETPOINT, 0, None)
load = np.clip(demand - gain*over, 0.0, 1.0) # analog, local, no address
# the shared medium integrates every node's load (diffusion) + leaks to rest
ambient += DIFFUSION*(load.mean()) - LEAK*(ambient - AMBIENT0*0.4)
ambient += np.random.normal(0, NOISE)
amb_hist.append(ambient)
load_hist.append(load.mean())
amb_hist = np.array(amb_hist); load_hist = np.array(load_hist)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8,6), sharex=True)
ax1.plot(amb_hist, color="#1D9E75", lw=1.5, label="ambient temperature (the shared bath)")
ax1.axhline(SETPOINT, color="#BA7517", ls="--", lw=1, label="setpoint")
ax1.set_ylabel("temperature (degC)"); ax1.legend(loc="upper right"); ax1.grid(alpha=0.3)
ax1.set_title("homeostasis by diffusion — thermal medium, no coordinator, no messages")
ax2.plot(load_hist, color="#185FA5", lw=1.5)
ax2.set_ylabel("mean node load"); ax2.set_xlabel("time step"); ax2.grid(alpha=0.3)
ax2.set_ylim(0,1.05)
plt.tight_layout(); plt.show()
print(f"settled ambient: {amb_hist[-50:].mean():.3f} (setpoint {SETPOINT})")
print(f"settled load : {load_hist[-50:].mean():.3f}")
settled ambient: 12.967 (setpoint 30.0) settled load : 0.871
4. The read-only manifold gauge¶
Put a temperature probe in the enclosure air wired to a display node whose ONLY job is to show the ambient — it has no throttle authority. That's your manifold gauge: show but never touch. Exactly the SmartShunt-watching pattern you already run.
# ---- read-only manifold gauge: SHOW but never TOUCH the medium ----
# This reads the shared bath and displays it. It has NO control authority.
# The regulation happens in the physics; this is pure observation, like your
# InfluxDB pipeline watching the SmartShunt.
def manifold_gauge(value, setpoint=30.0, lo=12.00, hi=48.00):
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(7,1.6))
ax.barh([0], [hi-lo], left=lo, height=0.5, color="#E1F5EE")
ax.barh([0], [value-lo], left=lo, height=0.5, color="#1D9E75")
ax.axvline(setpoint, color="#BA7517", ls="--", lw=1.5)
ax.text(setpoint, 0.5, "setpoint", ha="center", va="bottom", fontsize=9, color="#BA7517")
ax.text(value, -0.6, f"{value:.2f} degC", ha="center", va="top",
fontsize=11, fontweight="bold", color="#0F6E56")
ax.set_xlim(lo, hi); ax.set_ylim(-1, 1); ax.set_yticks([])
ax.set_title("manifold gauge — temperature (read-only, no control authority)")
plt.tight_layout(); plt.show()
# show the settled value on the gauge
manifold_gauge(amb_hist[-50:].mean())
5. Bench procedure¶
- Flash each node with the droop firmware from section 6 (setpoint ~30degC).
- Seal them in the enclosure, power on, let the air warm from their load.
- Watch: as the air heats past setpoint, every node feels it and throttles — no node told any other. The collective load settles so the air holds near setpoint.
- Perturb: open the lid (dump heat) and watch load rise back up automatically; or block one node's airflow (local hot spot) and watch just its neighbors ease off.
- Log local temps to your Pi/InfluxDB for the record — read-only, never feed the log back into control.
6. The droop curve — the whole analog brain of a node¶
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.
import numpy as np, matplotlib.pyplot as plt
SETPOINT = 30.0
GAIN = 1.0
local = np.linspace(7.20, 43.20, 200)
load = np.clip(1.0 - GAIN*np.clip(local-SETPOINT,0,None), 0, 1)
plt.figure(figsize=(7,4))
plt.plot(local, load, color="#185FA5", lw=2)
plt.axvline(SETPOINT, color="#BA7517", ls="--", lw=1, label="setpoint")
plt.xlabel("local temperature this node senses (degC)")
plt.ylabel("this node's load (0-1)")
plt.title("the droop curve — the entire control law, run locally on each ESP32")
plt.legend(); plt.grid(alpha=0.3); plt.tight_layout(); plt.show()
# pseudo-firmware, the whole loop that runs on each node:
print("""
loop():
local = read_sensor() # sense the shared bath, locally
over = max(0, local - SETPOINT)
load = clamp(1.0 - GAIN*over, 0, 1)
set_work(load) # throttle own work; tell NO ONE
sleep(dt)
""")
loop():
local = read_sensor() # sense the shared bath, locally
over = max(0, local - SETPOINT)
load = clamp(1.0 - GAIN*over, 0, 1)
set_work(load) # throttle own work; tell NO ONE
sleep(dt)
7. Scratchpad¶
Swap the medium, add nodes, inject a hot spot, log to InfluxDB (read-only!). The manifold is observed, never commanded.
# your experiments here