Files
machineGroupControl/test/_output-manifest.md
znetsixe f18f3cc673 feat(mgc-dashboard): -1 OFF sentinel on per-pump % control chart
fn_chart_pump_a/b/c now emit -1 on the ctrl output when the cached pump
state is off/idle/maintenance, instead of the residual ctrl% (which would
sit at 0 and be indistinguishable from a pump genuinely running at 0%).
ui_chart_pumps_ctrl ymin set to -5 so the OFF rail is visible below the
0-100 band.

Adds test/integration/per-pump-ctrl-fanout.integration.test.js covering
both chart outputs of all three pumps in populated (running), OFF
(off/idle/maintenance), and degraded (missing state/ctrl/flow, pre-tick,
NaN, ctrl-only delta) states per .claude/rules/output-coverage.md. Updates
test/_output-manifest.md to document the previously-undocumented per-pump
fan-out functions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:16:50 +02:00

181 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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`
- `<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) |
## Example flow fan-out — `examples/02-Dashboard.json :: fn_chart_pump_a/b/c` (outputs: 2 each)
Each per-pump fan-out delta-caches the pump's Port 0 then emits two chart msgs.
The ctrl output carries a **-1 OFF sentinel**: when the cached pump `state` is
`off` / `idle` / `maintenance` the pump is not running, so it plots `-1` (below
the 0100 band) — a clear OFF rail distinct from a pump genuinely running at 0%.
`ui_chart_pumps_ctrl` has `ymin: "-5"` so the sentinel is visible. Charts return
the whole msg as `null` (drop the output) when their source is missing — never
`{ payload: null }`. All ports covered by
`test/integration/per-pump-ctrl-fanout.integration.test.js`.
| # | Target chart | Topic / payload | Populated | Degraded |
|---|---|---|---|---|
| 0 | ui_chart_per_pump_flow | `{topic:'Pump A/B/C', payload:flow m³/h}` | ✔ running state | ✔ no `flow.predicted.downstream.*` key → null (drop) |
| 1 | ui_chart_pumps_ctrl | `{topic:'Pump A/B/C', payload:ctrl%}`, or `payload:-1` when state ∈ {off,idle,maintenance} | ✔ running → +ctrl; ✔ off/idle/maintenance → -1 | ✔ no state + ctrl missing/NaN/null → null (drop); ✔ ctrl-only delta keeps cached OFF state |
`fn_chart_total` (outputs: 1) feeds the same flow chart with the group total
(`downstream_predicted_flow ?? atEquipment_predicted_flow`); returns `null` when
both are absent.
## 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** (`<position>_measured_<type>`) 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.