- Movement gate: hold non-urgent demand while the group is 'working' (mid-ramp/sequencing) and flush it once 'ready', instead of aborting in-flight ramps on every incoming demand — which could freeze pumps at 0. Urgent demand (stop, mode/priority change, large step) still pre-empts. - getMovementState()/_isUrgentDemand()/_maybeFlushPendingDemand() helpers. - Demand telemetry: emit demandFlow (m³/h) and demandPct (0..100 of envelope) resolved by the last dispatch; omitted before the first demand (degraded). - Capacity envelope now emitted in output flow unit (m³/h) not raw m³/s. - Manifest + populated/degraded tests for the new outputs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
10 KiB
Markdown
134 lines
10 KiB
Markdown
# 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`.
|
||
|
||
---
|
||
|
||
## 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.
|