Physics-grounded digital twin of an ARCNODE deployment — coherent sim-mode telemetry, dispatch answering, shadow-mode mirroring, and what-if economics from one plant model.
The EMS stack has a first-class sim mode (ADR-001 §5) but nothing that fills
it with coherent plant behavior: protocol fixtures emit independent sawtooth
values with no causal link between dispatch, state of charge, bus power flow,
and metering. This service is that missing plant.
It consumes the Device Topology Manifest (from ems-device-api GET /topology
or a local file), assembles a causally-coupled site model, and publishes
contract-conformant telemetry to MQTT per ADR-002 — every measurement of every
device in the DTM, at its template poll rate, strict {ts, value} payloads,
QoS 0 retained.
Physics (BESS-centric, Lebowitz/Kirschen-grounded):
- split-efficiency SoC integration (η_ch = η_dis = √η_rt, default η_rt = 0.88 at POI)
- power rating + energy-headroom clamps, ramp limiting, usable SoC window
- full-equivalent-cycle throughput, two-term SOH fade (calendar + cycling)
- first-order thermal, threshold-driven warn/alarm evaluation from template bands
- module ↔ rack fanout and energy-weighted rollups
- reactive dispatch on the four-quadrant PQ circle (√(P²+Q²) ≤ S)
- site coupling: diurnal compute load → grid import = load − BESS discharge → meter integration
Vocabulary-driven, not slug-driven: any template declaring both
state_of_charge and active_power is treated as a battery. Everything else
gets deterministic bounded profiles around template bounds.nominal — no dead
channels, on any DTM.
Dispatch: subscribes commands/set/active_power/watts, answers the locked
received|done|failed lifecycle on events/dispatch_state (same contract as
ems-industrial-gateway dispatch.rs), and actually ramps the plant.
Twin, not just simulator: twin_mode: shadow subscribes to live
measurements and mirrors them into plant state instead of publishing —
derived quantities (headroom, SOH, alarms, what-if) then run against the
mirrored plant.
Full-path fidelity: twin_mode: fixture drives the
ems-industrial-fixtures mock servers' registers over their HTTP control
API (engineering → raw via the exact inverse of the gateway's ADR-002 §4
scaling), so the real Rust gateway polls real Modbus and publishes through
its real conversion path. In this mode the twin follows dispatch commands
silently — the gateway owns the ack lifecycle. Run the twin or the
gateway as MQTT publisher, never both (see docs/adr-001-twin-charter.md).
What-if replay: POST /whatif/replay runs a dispatch schedule × price
series through the same step_bess the live loop uses — SoC trajectory,
revenue/cost/profit, constraint clamps. Arbitrage math per Kirschen: profit
requires the price spread to beat 1/η_rt.
| Route | Purpose |
|---|---|
GET / |
healthcheck |
GET /twin/state |
full twin snapshot: mode, site coupling, units, active alarms |
POST /whatif/replay |
schedule × prices → trajectory + economics |
uv sync
uv run poe dev # api on :8010, twin loop against cfg.yml mqtt
uv run poe checks # deptry, black, ruff, ty, bandit, pip-audit
uv run poe unit # colocated unit tests
uv run poe integration # testcontainers: HiveMQ CE round-trip e2eConfig in cfg.yml (ENV=local|beta). Secrets via env only —
template-secrets.env lists MQTT_USERNAME / MQTT_PASSWORD.
src/
├── shared/ # ADR-002 topics + consumer mirrors of edp-api schemas
│ └── schemas/ # Dtm, DeviceTemplate, samples (tolerant-reader subset)
├── plant/ # pure physics: bess step, profiles, alarms, site assembly
├── telemetry/ # poll-rate scheduler + wire frames
├── fixture/ # engineering->raw encoding + control-API pusher
├── dispatch/ # commands/ consumer, lifecycle acks (or silent follow)
├── twin/ # runtime: MQTT session, tick loop, snapshot API
└── whatif/ # schedule replay + economics
tests/ # HiveMQ CE testcontainer e2e + API e2e + DTM fixtures
ems/system_adr.md(ADR-001) andems/topic_structure_adr.md(ADR-002)docs/adr-001-twin-charter.md— scope, seams, deployment compositiondocs/external-references.md— cross-repo contracts this service consumes