Lab 2 — homeostasis by diffusion: the shared voltage rail¶

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)
  • 1x current-limited bench supply OR a supply + series resistor + big cap bank
  • 8x load elements (a resistor + MOSFET per node, PWM-driven, is ideal)
  • wiring to tie every node to ONE shared rail
  • each node reads the rail with its own ADC

2. Wiring — the shared medium¶

Tie all 8 boards to one current-limited rail (or a shared cap bank behind a series resistor). When nodes draw hard, the rail sags — that sag is the ambient scalar every node feels at once. Each node reads the rail voltage on its own ADC and throttles its load as the rail droops. No node signals another. The rail hydraulics carry the message. This is the 'power-aware scheduling with a conscience' idea in miniature: the rail is the grid, the droop curve is the conscience.

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.

In [1]:
import numpy as np
import matplotlib.pyplot as plt

# ---- the swarm ----
N          = 8            # ESP32 nodes sharing ONE medium
SETPOINT   = 4.6   # target ambient rail voltage
AMBIENT0   = 5.0   # starting ambient rail voltage (V)
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 rail voltage (the shared bath)")
ax1.axhline(SETPOINT, color="#BA7517", ls="--", lw=1, label="setpoint")
ax1.set_ylabel("rail voltage (V)"); ax1.legend(loc="upper right"); ax1.grid(alpha=0.3)
ax1.set_title("homeostasis by diffusion — voltage-rail 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}")
No description has been provided for this image
settled ambient: 4.611 (setpoint 4.6)
settled load   : 0.708

4. The read-only manifold gauge¶

A single ADC node reading the shared rail, displaying volts, with no MOSFET of its own = your read-only manifold gauge. It watches the bath; it cannot stir it.

In [2]:
# ---- 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=4.6, lo=1.84, hi=7.36):
    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} V", 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 — rail voltage (read-only, no control authority)")
    plt.tight_layout(); plt.show()

# show the settled value on the gauge
manifold_gauge(amb_hist[-50:].mean())
No description has been provided for this image

5. Bench procedure¶

  1. Flash the droop firmware; here the node throttles when rail voltage drops BELOW setpoint (invert the sign vs the thermal lab).
  2. Power the shared rail, bring nodes online, let them load it.
  3. Watch: over-draw sags the rail, every node feels the sag and eases off, the rail settles near setpoint — no brownout, no coordinator.
  4. Perturb: add a big sudden load and watch the swarm yield together within milliseconds; remove it and watch them ramp back up.
  5. Log rail voltage read-only. Never let the log drive control — the physics does.

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.

In [3]:
import numpy as np, matplotlib.pyplot as plt
SETPOINT = 4.6
GAIN     = 1.0
local = np.linspace(1.50, 9.00, 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 rail voltage this node senses (V)")
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)
""")
No description has been provided for this image
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.

In [4]:
# your experiments here