# machineGroupControl — Output Manifest Per `.claude/rules/output-coverage.md`. Single source of truth for what MGC emits on Port 0/1/2, where the value comes from, and which test exercises it in populated AND degraded states. **Convention for missing values:** keys are **absent** when the underlying source has not produced a value yet (pre-first-tick, no demand, no pressure). Once produced, a key may be **explicitly null/undefined** only in the documented degenerate cases below. The dashboard formatter must treat both absent and null/undefined as "no data" (display `'—'`) — see the `pct`/`num` helpers in `examples/02-Dashboard.json :: fn_status_split`. --- ## Port 0 — process data Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by `outputUtils.formatMsg(..., 'process')` — only changed keys appear in each emit. ### Static fields (always emitted once MGC has been initialised) | Key | Source | Type / Range | Populated test | Degraded test | |---|---|---|---|---| | `mode` | `mgc.mode` (set via `set.mode` command; normalised by `specificClass.setMode`) | string ∈ {`optimalControl`, `priorityControl`, `maintenance`} (canonical camelCase) | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default | | `scaling` | `mgc.scaling` | string ∈ {`absolute`, `normalized`} or undefined | commands.basic.test.js | dashboard-fanout (undefined → raw-rows shows '—') | | `absDistFromPeak` | `groupEfficiency.calcDistanceFromPeak` (specificClass.js:132) | number ≥ 0 (η-points) | bep-distance-demand-sweep, group-bep-cascade, groupEfficiency.basic | groupEfficiency.basic test 7 (undefined when current = null) | | `relDistFromPeak` | `groupEfficiency.calcRelativeDistanceFromPeak` | number ∈ [0,1] **OR `undefined`** for degenerate (homogeneous pumps) | bep-distance-demand-sweep, group-bep-cascade | groupEfficiency.basic tests 5/6/7 (undefined cases), dashboard-fanout test 11 (undefined → '—' display) | | `flowCapacityMax` | `mgc.dynamicTotals.flow.max` (totalsCalculator), **converted to `unitPolicy.output.flow` (m³/h)** in output.js:62 | number m³/h ≥ 0; `0` when envelope unresolved (Infinity/NaN) | totalsCalculator.basic, dashboard-fanout (post-setup), demand-telemetry.basic | absent until first equalize; dashboard-fanout (state A); demand-telemetry (Infinity → 0) | | `flowCapacityMin` | `mgc.dynamicTotals.flow.min`, **converted to output flow unit (m³/h)** | number m³/h ≥ 0; `0` when unresolved | totalsCalculator.basic, demand-telemetry.basic | same as above | | `demandFlow` | `mgc._lastDemand.clamped` (set in `_runDispatch`, output.js:62) | number, canonical m³/s clamped to envelope, converted to `unitPolicy.output.flow` | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand); turnOff → 0 | | `demandPct` | derived `(clamped − flow.min)/(flow.max − flow.min)·100` (output.js:62) | number ∈ [0,100], `0` when capacity span ≤ 0 | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand) | | `machineCount` | `Object.keys(mgc.machines).length` | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | n/a — always reflects current registration count | | `machineCountActive` | filtered count excluding `off`/`maintenance` states | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | dashboard-fanout (state A: 0 active) | | `movementState` | `mgc.getMovementState()` (specificClass) — `'working'` while any child is ramping/sequencing or the executor has pending commands, else `'ready'` | string `'working'`\|`'ready'`, never null | movement-gate.basic (working: accelerating/warmingup/delayedMove/moveTimeLeft/executor-pending) | movement-gate.basic (ready: no machines, all settled) | ### Conditional pressure-header fields (emitted only when equalize resolved a positive ΔP) | Key | Source | Type / Range | Populated test | Degraded test | |---|---|---|---|---| | `headerDiffPa` | `mgc.operatingPoint.headerDiffPa` (groupOperatingPoint.equalize) | number Pa > 0 | groupOperatingPoint.basic, dashboard-fanout (state B/C) | dashboard-fanout (state A — absent) | | `headerDiffMbar` | derived `headerDiffPa / 100` when `unitPolicy.output.pressure === 'mbar'` | number mbar > 0 | dashboard-fanout (state B/C) | absent when output pressure unit ≠ mbar — **not explicitly tested** | ### Dynamic measurement fields — pattern `{position}_{variant}_{type}` Built by the loop at `io/output.js:23-39`. For each type×variant×position the container holds, one key is emitted **only if the value is non-null**. Positions: `downstream`, `upstream`, `atEquipment`. Plus `differential__` when both `downstream` and `upstream` exist. **Predicted measurements MGC writes itself (via writeOwn):** | Key | Source (write site) | Type / Range | Populated test | Degraded test | |---|---|---|---|---| | `atEquipment_predicted_flow` | `handlePressureChange` (specificClass:153), `_optimalControl` (specificClass:214), `equalFlowControl` (control/strategies:118), `turnOffAllMachines` (specificClass:297) | number, canonical m³/s converted to `unitPolicy.output.flow` | bep-distance-demand-sweep, dashboard-fanout (state B/C), ncog-distribution | dashboard-fanout (state A: absent), turnoff-deadlock (post-shutdown = 0) | | `downstream_predicted_flow` | `handlePressureChange` (specificClass:156 — mirrors AT_EQUIPMENT for PS contract), `turnOffAllMachines` (specificClass:296) | same as above | implicit in bep-distance-demand-sweep getOutput | turnoff-deadlock (post-shutdown = 0) | | `atEquipment_predicted_power` | same call sites as flow (specificClass:157, 213; strategies:117; specificClass:298) | number, canonical W converted to `unitPolicy.output.power` | bep-distance-demand-sweep, dashboard-fanout, distribution-power-table | turnoff-deadlock (= 0) | | `atEquipment_predicted_efficiency` | `_optimalControl` (specificClass:221), `equalFlowControl` (strategies:122) — only when `dP > 0 && bestPower > 0` | number ∈ [0, 1] hydraulic η = (Q·ΔP)/P | bep-distance-demand-sweep, dashboard-fanout (state C) | **absent** when dP ≤ 0 or bestPower ≤ 0 — guarded but not explicitly tested | | `atEquipment_predicted_Ncog` | `_optimalControl` (specificClass:224), `equalFlowControl` (strategies:125) | number, range **0..N where N = active pumps** (SUM of per-pump NCog from `bepGravitation.js:162` totalCog) — NOT 0..1; see [[project-mgc-bep-metrics-semantics]] | ncog-distribution (9 tests), bep-distance-demand-sweep, dashboard-fanout (state C) | dashboard-fanout normalizes by `machineCountActive` for display — tests 6/7/8/9/10 | **Measured pressures forwarded from children:** MGC subscribes to each registered measurement child (specificClass.js:91-104) and re-emits the child's reading on its own `MeasurementContainer`. If a pressure measurement child registers at position `downstream`, MGC will emit `downstream_measured_pressure` on Port 0 the next time `getOutput` runs. | Key pattern | Source | Tests | |---|---|---| | `_measured_` | child measurement node forwarded via `MeasurementContainer.emitter` (specificClass:91-105) | indirect — group-bep-cascade.integration drives pressure events through registered children; not asserted as a named output key | | `differential_measured_pressure` | computed when both `downstream_measured_pressure` and `upstream_measured_pressure` exist (output.js:33-37) | indirect via dashboard-fanout (used by fn_qh_point for header ΔP fallback) | --- ## Port 1 — InfluxDB telemetry Built by `outputUtils.formatMsg(..., 'influxdb')` — same `getOutput` source, different formatter. Emits the same key set as Port 0 with InfluxDB line-protocol tag/field discipline (cardinality rules per `.claude/rules/telemetry.md`). | Concern | Status | |---|---| | Keys | Identical to Port 0; the influxdb formatter (`generalFunctions/src/helper/formatters/influxdbFormatter.js`) decides which become tags vs fields. | | Test coverage | **None.** No test file imports/asserts the influxdb formatter for MGC. Regression vector if a key is added/renamed without checking cardinality. Tracked. | --- ## Port 2 — registration / control plumbing Emitted on startup by `BaseNodeAdapter` (one message per node). | Topic | Payload shape | Source | Tests | |---|---|---|---| | `registerChild` | `{ id: node.id, positionVsParent: }` | BaseNodeAdapter init — sends to upstream parent so it can subscribe to this node's measurements | structure-examples.integration, commands.basic.test.js test 5 (`child.register`) — receiver side | --- ## Events emitted on `mgc.source.measurements.emitter` These are NOT Port 0/1/2 emissions — they're in-process events that downstream EVOLV nodes (e.g., pumpingStation) subscribe to via the parent-child handshake. Listed here for completeness; covered by `.claude/rules/telemetry.md` rather than this manifest. - `flow.predicted.atequipment` — fired on every `writeOwn` to flow/predicted/AT_EQUIPMENT - `flow.predicted.downstream` — fired on every `writeOwn` to flow/predicted/DOWNSTREAM (the live aggregate the PS subscribes to) - `power.predicted.atequipment` - `efficiency.predicted.atequipment` - `Ncog.predicted.atequipment` - `.measured.` — re-emit of any registered measurement child Documented in `CONTRACT.md`; tested indirectly via `group-bep-cascade.integration.test.js` and `ncog-distribution.integration.test.js`. --- ## Example flow fan-out — `examples/02-Dashboard.json :: fn_status_split` (outputs: 18) Delta-caches Port 0 then fans one msg per dashboard widget. Charts return the whole msg as `null` (drop the output) when their source is missing — never `{ payload: null }`. All ports covered by `test/integration/dashboard-fanout.integration.test.js`. | # | Target widget | Topic / payload | Populated | Degraded (missing source) | |---|---|---|---|---| | 0 | ui_txt_mode | string | ✔ State C | ✔ State A → mode string | | 1 | ui_txt_flow | `'… m³/h'` | ✔ | ✔ State A → `—` | | 2 | ui_txt_power | `'… kW'` | ✔ | ✔ → `—` | | 3 | ui_txt_capacity | `'min – max m³/h'` | ✔ State B | ✔ → `—` | | 4 | ui_txt_machines | `'nAct / nTot'` | ✔ | ✔ → `—` | | 5 | ui_txt_bep (rel%) | `'… %'` | ✔ | ✔ null/undefined → `—` | | 6 | ui_txt_eta | `'… %'` | ✔ | ✔ → `—` | | 7 | ui_txt_eta_peak | `'… %'` | ✔ | ✔ → `—` | | 8 | ui_txt_bep_abs | `'…'` (η pts, 3dp) | ✔ | ✔ → `—` | | 9 | ui_txt_ncog | `'… %'` (sum/nAct) | ✔ | ✔ nAct=0/missing → `—` | | 10 | ui_chart_flow | `{topic:'Flow', payload:number}` | ✔ | ✔ → null (drop) | | 11 | ui_chart_flow (capacity) | `{topic:'Capacity', …}` | ✔ | ✔ → null | | 12 | ui_chart_power | `{topic:'Power', …}` | ✔ | ✔ → null | | 13 | ui_chart_bep | `{topic:'BEP rel %', ×100}` | ✔ | ✔ → null | | 14 | ui_chart_eta | `{topic:'η (%)', ×100}` | ✔ | ✔ → null | | 15 | ui_tpl_raw | `[{key,value}]` rows | ✔ | ✔ | | 16 | ui_chart_qh (passthrough) | raw `msg.payload` | ✔ | ✔ | | 17 | ui_chart_mgc_pctcap | `{topic:'% of capacity', payload:flow/capMax×100}` | ✔ State C | ✔ State A → null (drop) | ## Coverage gaps (open items) These are known holes flagged during the 2026-05-14 governance review; not yet fixed but documented so they don't regress silently. 1. **Port 1 (InfluxDB) has no dedicated tests.** Any rename of a Port 0 key should add an explicit Port 1 assertion to prevent silent cardinality regressions. 2. **`headerDiffMbar` only emitted when `unitPolicy.output.pressure === 'mbar'`.** The fallback (non-mbar configurations) isn't explicitly tested. 3. **`atEquipment_predicted_efficiency` absent-state isn't asserted.** The `dP > 0 && bestPower > 0` guard exists but no test pins the absence. 4. **Forwarded measured measurements** (`_measured_`) aren't asserted as named output keys — only their underlying behaviour is exercised. 5. **`scaling` undefined behaviour** — schema removed `scaling.current` for several modes; what MGC emits for those is implicit, not tested. When any of these is closed, move the row up into the appropriate table and delete the entry here.