The dashboard fan-out grew to 18 outputs (output 17 = '% of capacity' chart) but dashboard-fanout.integration.test.js still asserted 17 and had no PORT entry or coverage for output 17. Add chart_pctcap (17) with populated (State C, flow/capMax×100) and degraded (State A → null-drop) assertions, fix the count assertion, and add the fan-out enumeration table to _output-manifest.md per .claude/rules/output-coverage.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
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_<variant>_<type> 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 |
|---|---|---|
<position>_measured_<type> |
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: <string> } |
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 everywriteOwnto flow/predicted/AT_EQUIPMENTflow.predicted.downstream— fired on everywriteOwnto flow/predicted/DOWNSTREAM (the live aggregate the PS subscribes to)power.predicted.atequipmentefficiency.predicted.atequipmentNcog.predicted.atequipment<type>.measured.<position>— 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.
- 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.
headerDiffMbaronly emitted whenunitPolicy.output.pressure === 'mbar'. The fallback (non-mbar configurations) isn't explicitly tested.atEquipment_predicted_efficiencyabsent-state isn't asserted. ThedP > 0 && bestPower > 0guard exists but no test pins the absence.- Forwarded measured measurements (
<position>_measured_<type>) aren't asserted as named output keys — only their underlying behaviour is exercised. scalingundefined behaviour — schema removedscaling.currentfor 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.