Build seven physical nodes. Connect them. Kill one. Watch the survivors reroute, redistribute load, and adapt their coupling phase. That is the entire goal of this build. No origami. No metabolism. No cognition. One primitive: distributed adaptive coupling under physical stress. If the seven nodes pass, the substrate is materially credible. If they fail, §11 of the preprint tells us where the redesign starts.
A symmetric 6-around-1 hex would hide the dynamics we need to see. The first build is deliberately asymmetric:
One centre node (N0). Six surrounding nodes (N1–N6). All six edges from N0 to N1–N5 are full strength. The edge N0–N4 is intentionally weak — half the coil turns, twice the resistance. When load is applied between N4 and N0, the substrate must reroute around the weak edge. This single asymmetry produces visible decision-making during stress.
Each node is a 30 mm × 30 mm 2-layer PCB. Edges are not soldered — they are pluggable jumper coils mounted on header pins. Edges can be physically removed mid-experiment to simulate edge failure independently from node failure.
| Ref | Part | Why |
|---|---|---|
| U1 | ESP32-C3-MINI-1 | RISC-V MCU, ESP-NOW radio, 4 GPIO available for instrumentation |
| U2 | TI DRV8837 H-bridge | Polarisation flip on coupling coil |
| U3 | TI TPS61023 boost converter | Supercap → 3.3 V rail |
| U4 | Allegro A1324 Hall sensor | Read magnetic field at coil edge |
| C1 | 5 F · 2.7 V supercapacitor | Energy store, mounted vertically off-board for swap |
| L1–L6 | 6× detachable coils on header pins | One per neighbour edge; pluggable; some intentionally weak |
| D1–D6 | 6× LEDs (one per coil) | Phase status display: brightness ∝ phase |
| D_PHASE | RGB indicator LED | Phase 0=off, 1=blue, 2=green, 3=yellow, 4=orange, 5=red |
| D_HB | White heartbeat LED | Blinks on every transmitted heartbeat |
| R_SHUNT | 0.1 Ω current shunt | Coil current measurement; 4-pin Kelvin connection |
| TP1–TP10 | Test pads | V_supercap, V_3v3, GND, INA, INB, HALL_OUT, current shunt ±, UART_TX, UART_RX |
| J_UART | 4-pin debug UART header | Live state log over serial; no firmware update without it |
| J_PWR | 2-pin external supply header | Bench-power injection bypassing supercap during debug |
| NTC | 10 kΩ thermistor | Coil bobbin temperature; ADC-readable |
Each edge between two nodes is one coil. Two header pins on each PCB hold the coil. Coils are removable.
| Coil type | Wire | Turns | Core | Use |
|---|---|---|---|---|
| STANDARD (×5) | 1.0 mm enamelled copper | 30 | 6 mm OD ferrite, 5 mm long | Five strong edges from N0 |
| WEAK (×1) | 0.5 mm enamelled copper | 15 | 6 mm OD ferrite, 5 mm long | The deliberately weak edge N0–N4 |
| OUTER (×6) | 1.0 mm enamelled copper | 30 | 6 mm OD ferrite, 5 mm long | Edges N1–N2, N2–N3, etc. completing the ring |
Wind each by hand on a small lathe-or-jig. Mark them with coloured heat-shrink: red = STANDARD, white = WEAK, blue = OUTER. So during the experiment a researcher can see at a glance which edge is weak.
The Python decision rule from the preprint §3.1 ports verbatim to ESP-IDF C. Each node runs the same firmware; nodes differ only in their assigned ID (set by 4 DIP switches reading the lower nibble at boot).
// dent_node.c — minimal ESP-IDF firmware skeleton
#include <esp_now.h>
#include <esp_wifi.h>
#define HEARTBEAT_PERIOD_MS 16
#define HEARTBEAT_TIMEOUT_MS 150
#define MAX_NEIGHBOURS 6
typedef struct {
uint8_t id;
uint8_t phase; // 0..5
float energy_J;
float load_N;
float damage;
bool alive;
uint64_t last_heartbeat[MAX_NEIGHBOURS];
uint8_t neighbour_ids[MAX_NEIGHBOURS];
uint8_t routes[256]; // dest -> next-hop id, 0xFF = unknown
} node_state_t;
static node_state_t self;
void on_hb_recv(const uint8_t *mac, const uint8_t *data, int len) {
uint8_t nb_id = data[0];
self.last_heartbeat[find_neighbour_idx(nb_id)] = esp_timer_get_time() / 1000;
// import routes from this neighbour
for (int i = 1; i + 1 < len; i += 2) {
uint8_t dest = data[i];
if (dest != self.id && self.routes[dest] == 0xFF) {
self.routes[dest] = nb_id;
}
}
}
uint8_t decide_phase(node_state_t *n) {
if (n->energy_J < 0.9f) return 0; // browned out (5% E_max)
if (n->damage > 0.5f) return 1; // injured
if (n->load_N > 8.0f) return 5; // fused
if (n->load_N > 4.0f) return 4;
if (n->load_N > 1.5f) return 3;
if (n->load_N > 0.2f) return 2;
return 1; // baseline sense
}
void heartbeat_task(void *_) {
while (1) {
uint8_t pkt[64] = {self.id};
int n = 1;
for (int d = 0; d < 256; d++) {
if (self.routes[d] != 0xFF) { pkt[n++] = d; pkt[n++] = self.routes[d]; }
}
esp_now_send(BROADCAST_MAC, pkt, n);
gpio_set_level(LED_HB, 1); vTaskDelay(2 / portTICK_PERIOD_MS); gpio_set_level(LED_HB, 0);
vTaskDelay(HEARTBEAT_PERIOD_MS / portTICK_PERIOD_MS);
}
}
void detect_dead_neighbours(void) {
uint64_t now_ms = esp_timer_get_time() / 1000;
for (int i = 0; i < MAX_NEIGHBOURS; i++) {
if (self.last_heartbeat[i] && (now_ms - self.last_heartbeat[i]) > HEARTBEAT_TIMEOUT_MS) {
uint8_t dead_id = self.neighbour_ids[i];
for (int d = 0; d < 256; d++) {
if (self.routes[d] == dead_id) self.routes[d] = 0xFF;
}
self.last_heartbeat[i] = 0;
ESP_LOGI("dent", "neighbour %u declared dead, routes purged", dead_id);
}
}
}
void apply_phase(uint8_t phase, gpio_num_t ina, gpio_num_t inb) {
int duty = phase * 51; // 0..255 PWM duty per phase
ledc_set_duty(LEDC_LOW_SPEED_MODE, ina, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, ina);
}
Each node runs three FreeRTOS tasks: heartbeat (16 ms), control (62 Hz — phase decision and apply), and telemetry (10 Hz UART log).
Each procedure produces a numbered data file. Don't run a procedure without writing one. Each file gets pushed to git after the run; failed runs go in the same git history with the failure mode noted.
From the preprint §7, we expect to see one or more of these problems on first power-up. Predicting them in advance prevents panic.
| Symptom | Likely cause | First fix |
|---|---|---|
| Heartbeats time out spuriously | ESP-NOW radio congestion at all-broadcast | Switch to unicast pairs; rate-limit at 16 ms |
| Hall sensor reads junk during phase change | Coil switching transient cross-coupling | Software gate: ignore HALL for 100 µs after every flip |
| Coil overheats at phase 5 | Continuous 1.4 A through 1 Ω = 2 W spot | Limit phase 5 to 5 s burst, decay to phase 4 |
| Routes never converge | Heartbeat payload missing routes | Verify the route blob is actually being transmitted; UART dump it |
| Nodes reboot under load | Supercap brown-out during boost transition | Add 100 µF tantalum local to MCU rail |
| 7 PCBs (JLCPCB qty 10) | £18 incl. shipping |
| SMT assembly (5 unique parts × 7 boards) | £32 (JLCPCB Q100 service) |
| Through-hole + headers + supercaps + coils + LEDs | £36 (LCSC + Aliexpress mixed) |
| Imada DS2-200N force gauge (loan from a uni lab if possible; new: £450) | £0–450 |
| USB-UART hub for parallel logging | £18 |
| Acrylic backplate + zip-ties + bench supply | £25 |
| Lab build total (with force gauge) | £579 |
| Lab build total (gauge borrowed) | £129 |
| PCB design (KiCad, 7 days incl. peer review) | 1 week |
| JLCPCB lead time + SMT assembly | 2 weeks |
| Coil winding + through-hole assembly | 3 days |
| Firmware port (Python rule → ESP-IDF C) | 1 week |
| Bring-up + first heartbeat over the wire | 2 days |
| P1–P4 measurement runs | 1 week |
| Total to first published M1 dataset | ≈ 6 weeks |
M1 is complete and publishable when all four conditions hold:
If any single condition fails, write the failure mode into a follow-up commit and iterate. Do not proceed to M2 until all four pass.
This build proves one mechanism: distributed adaptive coupling with self-repair, under physical load, on commodity hardware. That is enough for M1. Higher-order claims wait for higher-numbered milestones.
v1 build doc · 9 May 2026 · ShortFactory Research · sister documents: preprint · live simulator