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:
@@ -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.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user