Skip to content

The sensor path

For the commissioning tech. This is the path a temperature or pressure reading takes from a sensor (or a bench potentiometer standing in for one) to the fleet dashboard, and every place it can be stopped for being wrong.

[pot / real sensor] Arduino Mega 2560 ModBerry CM4 EMQX broker
shaft = state of world (all engineering units)
│ │
▼ ┌──────────────────────────┐ ▼
SIM_MODE only: │ PRODUCTION PATH │ parse frame ── CRC / seq check
shaft_fraction │ (identical SIM & REAL) │ │ (bad → drop, count)
→ world_temp ═══════▶ │ raw ─ fault band check │ linearize(raw, calib_id)
→ R_ntc (β model) │ (§ fault bands) │ │
→ v_node (10k pull-up) │ raw ─ classify → Fault │ invariant gate ── cross-channel
→ synth_adc ─────────▶│ serial: raw + fault ONLY │ │ impossible → drop
REAL_MODE: │ no engineering units │ publish:
divider → ADC pin ──────────────▶│ │ ok → telemetry (QoS1)
└──────────────────────────┘ drop → status (raw attached)

The pot is not the sensor. Its shaft angle is the state of the world. In SIM_MODE the firmware synthesizes the electrical response a real sensor would produce in that world, then feeds that synthetic signal through the exact same linearization and validation code that runs against real hardware. Nothing below the “PRODUCTION PATH” line knows whether it was simulated.

The Mega sends raw ADC counts and fault codes only — never engineering units. Every value in the system is computed once, on the CM4, and tagged with the calib_id it was computed under, so any consumer can recompute it from (raw, calib_id).

Pin Signal Element (emulated) Range Front-end
A0 Outdoor temp 10 kΩ NTC, β=3950 −10 → 110 °F pull-up divider
A1 Supply water 10 kΩ NTC, β=3950 60 → 210 °F pull-up divider
A2 Return water 10 kΩ NTC, β=3950 60 → 190 °F pull-up divider
A3 System pressure 4–20 mA loop, 0–30 psi 0 → 30 psi 220 Ω sense

Steam profile (RISER_PROFILE=steam) remaps A1→header pressure (0–3 psi), A2→vapor temp (60–230 °F), A3→LWCO level. Same architecture, different invariants.

Fault bands — checked on raw, before linearization, never clamped

Section titled “Fault bands — checked on raw, before linearization, never clamped”

A clamped open-circuit NTC reads as extreme cold, and extreme cold is exactly the input that drives an outdoor-reset curve to command maximum fire. Clamping turns a detectable sensor failure into an undetectable overfire. So we don’t clamp — we emit the fault, publish the raw count, and leave the value unread.

Channel Condition (on raw counts) Fault Means
NTC raw ≥ 1015 OpenCircuit sensor disconnected
NTC raw ≤ 8 ShortCircuit sensor / wiring shorted
4–20 mA < 3.8 mA equivalent UnderRange loop broken
4–20 mA > 20.5 mA equivalent OverRange loop saturated / short-to-hi
any no calibration record Uncalibrated never a default coefficient

On the bench: turn a pot fully one way → ShortCircuit; fully the other → OpenCircuit. The extremes of pot travel are reserved as electrical faults, not as extreme weather.

Invariant gate — cross-channel impossibility (runs on the CM4, before publish)

Section titled “Invariant gate — cross-channel impossibility (runs on the CM4, before publish)”

Per-channel electrical validity is the Mega’s job. A combination that cannot physically occur is the CM4’s, and it is checked before signing and publishing. A frame that fails is dropped, and a riser.invariant_violation is published to the status topic with the raw counts attached — a dropped frame is louder than a published one, so the dashboard can show a device producing valid electrical readings in an impossible combination (which is exactly what a swapped supply/return sensor pair looks like on commissioning day).

Hydronic:

  1. return hotter than supplyrwt > swt + 1.0 °F. Impossible in a heat-emitting loop.
  2. negative ΔTswt − rwt < 0 after (1) passes (the tolerance band).
  3. staleness — any reading older than 2× the publish interval.
  4. pressure above reliefpsi > 30 psi. The sensor is lying or the relief has failed; either way it is not a measurement.
  5. impossible OAT rate — outdoor temp moving faster than 20 °F/min (evaluated over a 10 s window so ADC quantization noise doesn’t false-trip). Thermal mass makes a real outdoor sensor incapable of this; it’s a wiring fault.

Steam: no ΔT. Instead — header pressure > 2.0 psi sustained > 60 s is a cut-out-failure; vapor temp < 200 °F while header > 1.0 psi means steam is not reaching the far riser.

Derived values published alongside the readings (flags only, never control actions): delta_t, condensing_risk (rwt < 130 °F), flow_anomaly (ΔT < 5 °F dead loop, or > 40 °F low flow / air-bound).

Nothing in this path actuates anything. No reset curve, no relay. Setpoints and firing commands arrive on the cmd topic or not at all.

ASCII, one frame per 250 ms, CRC-16/CCITT-FALSE over the payload between $ and *. Readable on a scope or a screen session:

$RISER,1,<seq>,<uptime_ms>,<ch>:<raw>:<fault>,<ch>:<raw>:<fault>,...*<crc>\r\n

Fault codes on the wire: N none · O open · S short · V over-range · U under-range · C uncalibrated. Frame loss, CRC failure, and seq gaps are all normal — the reader counts them and drops the frame; it never crashes and never publishes a bad frame.

riser/v1/{tenant}/{site}/{device}/telemetry QoS 1, not retained (rich: raw+value+fault+calib_id+derived)
riser/v1/{tenant}/{site}/{device}/status QoS 1, retained, LWT (invariant violations land here)
riser/v1/{tenant}/{site}/{device}/cmd QoS 1, subscribe only

iOS compatibility. Alongside each rich frame, the edge also emits the flat frame the current operator app already decodes, so the app lights up with no code change:

riser/{tenant}/{site}/{device}/telemetry {"type":"reading","ts":<epoch s>,"supply_c":<°C>}

The app’s viewer ACL is riser/neos/{franklin,mesa}/#, so deploy with tenant=neos and site=franklin/mesa. Set emit_legacy=False on the Pipeline to turn it off once a consumer speaks the rich /v1/ shape. The legacy frame is only sent when the supply-water channel is trustworthy that frame — a dropped or faulted frame sends nothing, so the app never shows a number the invariant gate rejected.

Getting the data onto the operator dashboard

Section titled “Getting the data onto the operator dashboard”

The dashboard reads telemetry over REST from the backend, not from MQTT. A small bridge closes that gap: it subscribes the rich riser/v1/.../telemetry topic, maps each frame onto the backend’s ingest shape (Celsius keys the dashboard already renders — supply_temp_c, return_temp_c, pressure_psi, plus delta_t_c), and POSTs it as the device. Faulted channels are omitted (a blank beats a guessed number); per-channel fault codes, seq, profile, and calib_ids ride along in health. Storage, alert evaluation, and the REST read all light up unchanged.

BRIDGE_KEYMAP=keymap.json MQTT_HOST=... MQTT_CA=... MQTT_CERT=... MQTT_KEY=... make run-bridge

keymap.json maps each device id to its backend API key: {"riser-01": "rk_..."}. Mapping logic: backend/app/services/mqtt_bridge.py (pure, unit-tested). Runner: backend/scripts/run_bridge.py. Invariant violations are not yet forwarded as backend alerts — they remain on the retained status topic.

make firmware-test # golden vectors + fault bands + CRC, on the host (clang++)
make edge-test # the edge package under pytest
make sensor-demo # replay a recorded capture: prints published + every drop