governance + unit-self-describing demand + dashboard fixes

Two governance items from the 2026-05-14 quality review:
- test/_output-manifest.md enumerates every Port 0/1/2 key MGC emits, its
  source, type, range, and which tests cover it in populated/degraded states
  (per .claude/rules/output-coverage.md).
- src/control/strategies.js extracts computeEqualFlowDistribution as a pure
  function so the equal-flow algorithm is testable without an MGC fixture.
  test/basic/equalFlowDistribution.basic.test.js (6 tests) covers all three
  demand branches and pins the legacy quirk where the default branch counts
  active machines but iterates priority-ordered first-N (documented in the
  test so the future cleanup is a deliberate change).

Plus rolled-up session work that landed alongside:
- set.demand is now unit-self-describing ({value, unit:'m3/h'|'l/s'|'%'|...}
  or bare number = %); setScaling/scaling.current removed from MGC, commands,
  editor (mgc.html), specificClass.
- _optimalControl + equalFlowControl now compute eta = (Q*dP)/P_shaft rather
  than Q/P, keeping the metric in the same scale as each child's cog.
- groupEfficiency.calcRelativeDistanceFromPeak returns undefined (was 1) when
  pumps are homogeneous (|max-min| < 1e-9). Dashboard treats undefined as
  '-' instead of showing a misleading 100% / 0% reading.
- examples/02-Dashboard.json: auto-init inject so the dashboard populates at
  deploy, NCog formatter normalizes the SUM emitted by MGC by
  machineCountActive, Q-H fanout trims the flat-Q tail so the H axis isn't
  stretched to 40m by curve-envelope clamp points, num/pct treat null AND
  undefined as no-data (closes the +null === 0 trap).
- new test/integration/dashboard-fanout.integration.test.js (17 tests),
  bep-distance-demand-sweep.integration.test.js (3 tests),
  group-bep-cascade.integration.test.js -- total suite now 108/108 green.
- .gitignore: wiki/test.gif (143 MB screen recording, kept locally only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-14 22:31:25 +02:00
parent d238270530
commit 26e92b54f7
26 changed files with 2573 additions and 1790 deletions

View File

@@ -2,8 +2,12 @@
//
// All real work lives in the concern modules under src/{groupOps,totals,
// combinatorics,optimizer,efficiency,dispatch,control}. This file stitches
// them together: child-event routing, demand serialization, mode/scaling,
// them together: child-event routing, demand serialization, mode selection,
// and the per-mode dispatch switch.
//
// Operator demand is always passed in here as a canonical m³/s number. The
// set.demand handler resolves units (%, m³/h, l/s, etc.) before calling
// handleInput, so this orchestrator has no scaling state and no unit logic.
'use strict';
@@ -37,7 +41,6 @@ class MachineGroup extends BaseDomain {
// tests still write directly (matches the pumpingStation pattern).
this.machines = {};
this.scaling = this.config.scaling.current;
this.mode = this.config.mode.current;
this.absDistFromPeak = 0;
this.relDistFromPeak = 0;
@@ -117,11 +120,6 @@ class MachineGroup extends BaseDomain {
// ── Surface kept for tests + commands ──────────────────────────────
setMode(mode) { this.mode = mode; this.notifyOutputChanged(); }
setScaling(scaling) {
const allowed = new Set(this.defaultConfig.scaling.current.rules.values.map(v => v.value));
if (allowed.has(scaling)) { this.scaling = scaling; this.notifyOutputChanged(); }
else this.logger.warn(`${scaling} is not a valid scaling option.`);
}
isMachineActive(id) {
const s = this.machines[id]?.state?.getCurrentState?.();
return ACTIVE_STATES.has(s);
@@ -214,7 +212,15 @@ class MachineGroup extends BaseDomain {
// INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate.
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestPower, this.unitPolicy.canonical.power);
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestFlow, this.unitPolicy.canonical.flow);
this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestFlow / bestResult.bestPower);
// Hydraulic efficiency η = (Q·ΔP)/P_shaft — a dimensionless 0..1
// ratio in the same scale as each child rotatingMachine's `cog`.
// Keeps `calcDistanceBEP(eff, maxEfficiency, lowestEfficiency)` in
// handlePressureChange comparing apples to apples.
const dP = this.operatingPoint.headerDiffPa;
if (Number.isFinite(dP) && dP > 0 && bestResult.bestPower > 0) {
this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.value((bestResult.bestFlow * dP) / bestResult.bestPower);
}
this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
@@ -246,19 +252,16 @@ class MachineGroup extends BaseDomain {
this.logger.error(`Invalid flow demand input: ${demand}.`);
return;
}
// Demand is canonical m³/s (the handler has already resolved units).
// The handler routes negatives directly to turnOffAllMachines, but
// keep a defensive check in case turnOff-state arrives some other way.
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
await this.abortActiveMovements('new demand received');
const dt = this.calcDynamicTotals();
let demandQout = 0;
if (this.scaling === 'absolute') {
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
if (demandQ < this.absoluteTotals.flow.min) demandQout = this.absoluteTotals.flow.min;
else if (demandQ > this.absoluteTotals.flow.max) demandQout = this.absoluteTotals.flow.max;
else demandQout = demandQ;
} else if (this.scaling === 'normalized') {
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
demandQout = this.interpolation.interpolate_lin_single_point(demandQ, 0, 100, dt.flow.min, dt.flow.max);
}
// Clamp against the current-pressure envelope.
let demandQout = demandQ;
if (demandQout < dt.flow.min) demandQout = dt.flow.min;
else if (demandQout > dt.flow.max) demandQout = dt.flow.max;
// Normalize for the switch — schema enum values use camelCase
// (optimalControl, priorityControl) while legacy callers send
@@ -266,10 +269,6 @@ class MachineGroup extends BaseDomain {
const ctx = { mgc: this };
switch (String(this.mode || '').toLowerCase()) {
case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break;
case 'prioritypercentagecontrol':
if (this.scaling !== 'normalized') { this.logger.warn('Priority percentage control needs normalized scaling.'); return; }
await control.prioPercentageControl(ctx, demandQout, priorityList);
break;
case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break;
default: this.logger.warn(`${this.mode} is not a valid mode.`);
}