2026-05-10 21:32:11 +02:00
|
|
|
// MachineGroup — S88 Unit orchestrator coordinating rotatingMachine children.
|
|
|
|
|
//
|
|
|
|
|
// All real work lives in the concern modules under src/{groupOps,totals,
|
|
|
|
|
// combinatorics,optimizer,efficiency,dispatch,control}. This file stitches
|
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>
2026-05-14 22:31:25 +02:00
|
|
|
// them together: child-event routing, demand serialization, mode selection,
|
2026-05-10 21:32:11 +02:00
|
|
|
// and the per-mode dispatch switch.
|
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>
2026-05-14 22:31:25 +02:00
|
|
|
//
|
|
|
|
|
// 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.
|
2026-05-10 21:32:11 +02:00
|
|
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
const { BaseDomain, UnitPolicy, POSITIONS, interpolation, convert } = require('generalFunctions');
|
|
|
|
|
const GroupOperatingPoint = require('./groupOps/groupOperatingPoint');
|
|
|
|
|
const groupCurves = require('./groupOps/groupCurves');
|
|
|
|
|
const TotalsCalculator = require('./totals/totalsCalculator');
|
|
|
|
|
const { validPumpCombinations } = require('./combinatorics/pumpCombinations');
|
|
|
|
|
const optimizer = require('./optimizer');
|
|
|
|
|
const GroupEfficiency = require('./efficiency/groupEfficiency');
|
|
|
|
|
const control = require('./control/strategies');
|
|
|
|
|
const io = require('./io/output');
|
2026-05-11 17:29:18 +02:00
|
|
|
const DemandDispatcher = require('./dispatch/demandDispatcher');
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
const { buildProfile } = require('./movement/machineProfile');
|
|
|
|
|
const movementScheduler = require('./movement/movementScheduler');
|
|
|
|
|
const MovementExecutor = require('./movement/movementExecutor');
|
2026-05-10 21:32:11 +02:00
|
|
|
|
|
|
|
|
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
|
|
|
|
|
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
// Canonical mode names (camelCase). The dispatcher already lowercases for its
|
|
|
|
|
// switch, but we normalise at setMode so this.mode is always in the canonical
|
|
|
|
|
// form — keeps allowedActions/allowedSources lookups (which key on the
|
|
|
|
|
// canonical form) honest. Module-level so tests can import without spinning
|
|
|
|
|
// up a full MachineGroup instance.
|
|
|
|
|
const ALLOWED_MODES = ['optimalControl', 'priorityControl', 'maintenance'];
|
|
|
|
|
function _normaliseMode(input) {
|
|
|
|
|
const lc = String(input || '').toLowerCase();
|
|
|
|
|
return ALLOWED_MODES.find((m) => m.toLowerCase() === lc) || null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
class MachineGroup extends BaseDomain {
|
|
|
|
|
static name = 'machineGroupControl';
|
|
|
|
|
|
|
|
|
|
static unitPolicy = UnitPolicy.declare({
|
|
|
|
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
|
|
|
|
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
|
|
|
|
requireUnitForTypes: ['pressure', 'flow', 'power', 'temperature'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
configure() {
|
|
|
|
|
this.interpolation = new interpolation();
|
2025-07-01 17:03:36 +02:00
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
// Plain id-keyed maps so tests + tight-loop iteration stay readable.
|
|
|
|
|
// The router populates them via the onRegister handlers below; legacy
|
|
|
|
|
// tests still write directly (matches the pumpingStation pattern).
|
|
|
|
|
this.machines = {};
|
2026-03-31 18:17:41 +02:00
|
|
|
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
// Persisted flows may have stored the mode in lowercase (legacy editor
|
|
|
|
|
// behaviour); normalise at construction so allow-list lookups against
|
|
|
|
|
// the schema's camelCase keys work consistently. Fallback to
|
|
|
|
|
// optimalControl if the persisted value is missing/garbage so a typo
|
|
|
|
|
// doesn't quietly disable dispatch.
|
|
|
|
|
this.mode = _normaliseMode(this.config.mode.current) || 'optimalControl';
|
2026-05-10 21:32:11 +02:00
|
|
|
this.absDistFromPeak = 0;
|
2025-07-01 17:03:36 +02:00
|
|
|
this.relDistFromPeak = 0;
|
2026-05-10 21:32:11 +02:00
|
|
|
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
|
|
|
|
|
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
|
2025-07-01 17:03:36 +02:00
|
|
|
|
2026-05-11 17:29:18 +02:00
|
|
|
// Latest-wins demand gate. Awaiting handleInput resolves when THIS
|
|
|
|
|
// call's dispatch settles (LatestWinsGate.fireAndWait); a parked
|
|
|
|
|
// call that is later superseded resolves with { superseded: true }.
|
|
|
|
|
this._demandDispatcher = new DemandDispatcher(
|
|
|
|
|
{ logger: this.logger },
|
|
|
|
|
(payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList),
|
|
|
|
|
);
|
2026-05-10 21:32:11 +02:00
|
|
|
this._shutdownInFlight = new Set();
|
2026-03-31 18:17:41 +02:00
|
|
|
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
// Tick-driven executor for the movement schedule produced by the
|
|
|
|
|
// planner. MGC owns the wall-clock setInterval that calls tick();
|
|
|
|
|
// the executor itself is pure (testable without timers).
|
|
|
|
|
this.movementExecutor = new MovementExecutor({
|
|
|
|
|
logger: this.logger,
|
|
|
|
|
fireCommand: (cmd) => this._fireSchedulerCommand(cmd),
|
|
|
|
|
});
|
|
|
|
|
this._executorTimer = null;
|
|
|
|
|
this._executorIntervalMs = 1000;
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
this.operatingPoint = new GroupOperatingPoint({
|
|
|
|
|
measurements: this.measurements,
|
|
|
|
|
machines: this.machines,
|
2026-05-11 17:13:18 +02:00
|
|
|
unitPolicy: this.unitPolicy,
|
2026-05-10 21:32:11 +02:00
|
|
|
logger: this.logger,
|
2025-07-01 17:03:36 +02:00
|
|
|
});
|
2026-05-10 21:32:11 +02:00
|
|
|
this.totals = new TotalsCalculator({
|
|
|
|
|
machines: this.machines,
|
2026-05-11 17:13:18 +02:00
|
|
|
unitPolicy: this.unitPolicy,
|
2026-05-10 21:32:11 +02:00
|
|
|
logger: this.logger,
|
|
|
|
|
operatingPoint: this.operatingPoint,
|
|
|
|
|
isMachineActive: (id) => this.isMachineActive(id),
|
2025-07-01 17:03:36 +02:00
|
|
|
});
|
2026-05-10 21:32:11 +02:00
|
|
|
this.efficiency = new GroupEfficiency({
|
|
|
|
|
logger: this.logger,
|
|
|
|
|
interpolation: this.interpolation,
|
|
|
|
|
measurements: this.measurements,
|
|
|
|
|
machines: this.machines,
|
2025-07-01 17:03:36 +02:00
|
|
|
});
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
this.router
|
|
|
|
|
.onRegister('machine', (child) => {
|
|
|
|
|
const id = child.config.general.id;
|
|
|
|
|
if (this.machines[id]) {
|
|
|
|
|
this.logger.warn(`Machine ${id} is already registered.`);
|
|
|
|
|
return;
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
2026-05-10 21:32:11 +02:00
|
|
|
this.machines[id] = child;
|
|
|
|
|
})
|
|
|
|
|
.onMeasurement('machine', { type: 'pressure', position: POSITIONS.DOWNSTREAM }, () => this.handlePressureChange())
|
|
|
|
|
.onMeasurement('machine', { type: 'pressure', position: 'differential' }, () => this.handlePressureChange())
|
|
|
|
|
.onPrediction('machine', { type: 'flow', position: POSITIONS.DOWNSTREAM }, () => this.handlePressureChange())
|
|
|
|
|
.onRegister('measurement', (child) => {
|
|
|
|
|
const position = child.config?.functionality?.positionVsParent || child.config?.general?.positionVsParent;
|
|
|
|
|
const measurementType = child.config?.asset?.type;
|
|
|
|
|
if (!measurementType || !position) {
|
|
|
|
|
this.logger.warn(`Measurement child ${child.config?.general?.id} missing asset.type or positionVsParent — skipping`);
|
|
|
|
|
return;
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
2026-05-10 21:32:11 +02:00
|
|
|
const eventName = `${measurementType}.measured.${String(position).toLowerCase()}`;
|
|
|
|
|
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
|
|
|
|
this.measurements
|
|
|
|
|
.type(measurementType).variant('measured').position(position)
|
|
|
|
|
.value(eventData.value, eventData.timestamp, eventData.unit);
|
|
|
|
|
if (measurementType === 'pressure') this.handlePressureChange();
|
2025-11-20 22:28:49 +01:00
|
|
|
});
|
2025-07-01 17:03:36 +02:00
|
|
|
});
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
this.logger.info('MachineGroup initialized.');
|
2025-11-22 21:09:38 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
context() {
|
|
|
|
|
return Object.freeze({
|
|
|
|
|
...super.context(),
|
|
|
|
|
mgc: this,
|
|
|
|
|
machines: this.machines,
|
|
|
|
|
groupCurves,
|
|
|
|
|
readChildMeasurement: (m, t, v, p, u) => this.operatingPoint.readChild(m, t, v, p, u),
|
|
|
|
|
POSITIONS,
|
2025-11-22 21:09:38 +01:00
|
|
|
});
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
// ── Surface kept for tests + commands ──────────────────────────────
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
// Mirror of rotatingMachine/src/specificClass.js:329-339 — same pattern,
|
|
|
|
|
// mode/source allow-lists live in this.config.mode (loaded from the
|
|
|
|
|
// schema as Set instances). Anything not declared in the schema is
|
|
|
|
|
// dropped silently with a warn-level log.
|
|
|
|
|
isValidActionForMode(action, mode) {
|
|
|
|
|
const ok = !!this.config?.mode?.allowedActions?.[mode]?.has?.(action);
|
|
|
|
|
if (ok) this.logger.debug(`action '${action}' allowed in mode '${mode}'`);
|
|
|
|
|
else this.logger.warn(`action '${action}' not allowed in mode '${mode}'`);
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
isValidSourceForMode(source, mode) {
|
|
|
|
|
const ok = !!this.config?.mode?.allowedSources?.[mode]?.has?.(source);
|
|
|
|
|
if (ok) this.logger.debug(`source '${source}' allowed in mode '${mode}'`);
|
|
|
|
|
else this.logger.warn(`source '${source}' not allowed in mode '${mode}'`);
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
setMode(mode) {
|
|
|
|
|
const canonical = _normaliseMode(mode);
|
|
|
|
|
if (!canonical) {
|
|
|
|
|
this.logger.warn(`Invalid mode '${mode}'. Allowed: ${ALLOWED_MODES.join(', ')}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.mode = canonical;
|
|
|
|
|
this.notifyOutputChanged();
|
|
|
|
|
}
|
2026-05-10 21:32:11 +02:00
|
|
|
isMachineActive(id) {
|
|
|
|
|
const s = this.machines[id]?.state?.getCurrentState?.();
|
|
|
|
|
return ACTIVE_STATES.has(s);
|
|
|
|
|
}
|
|
|
|
|
equalizePressure() { this.operatingPoint.equalize(); }
|
|
|
|
|
calcAbsoluteTotals() { return (this.absoluteTotals = this.totals.calcAbsoluteTotals()); }
|
|
|
|
|
calcDynamicTotals() { return (this.dynamicTotals = this.totals.calcDynamicTotals()); }
|
|
|
|
|
activeTotals() { return this.totals.activeTotals(); }
|
|
|
|
|
calcGroupEfficiency(machines) { return this.efficiency.calcGroupEfficiency(machines); }
|
|
|
|
|
calcDistanceBEP(eff, max, min) {
|
|
|
|
|
const d = this.efficiency.calcDistanceBEP(eff, max, min);
|
|
|
|
|
this.absDistFromPeak = d.absDistFromPeak;
|
|
|
|
|
this.relDistFromPeak = d.relDistFromPeak;
|
|
|
|
|
return d;
|
|
|
|
|
}
|
|
|
|
|
validPumpCombinations(machines, Qd, powerCap = Infinity) {
|
|
|
|
|
return validPumpCombinations(machines, Qd, this.context(), powerCap);
|
|
|
|
|
}
|
|
|
|
|
calcBestCombination(combinations, Qd) {
|
|
|
|
|
return optimizer.calcBestCombination(combinations, Qd, this.context());
|
|
|
|
|
}
|
|
|
|
|
calcBestCombinationBEPGravitation(combinations, Qd, method = 'BEP-Gravitation-Directional') {
|
|
|
|
|
return optimizer.calcBestCombinationBEPGravitation(combinations, Qd, this.context(), method);
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
handlePressureChange() {
|
|
|
|
|
this.operatingPoint.equalize();
|
|
|
|
|
const totals = this.calcDynamicTotals();
|
2026-05-11 17:13:18 +02:00
|
|
|
const fUnit = this.unitPolicy.canonical.flow;
|
|
|
|
|
const pUnit = this.unitPolicy.canonical.power;
|
2026-05-10 21:32:11 +02:00
|
|
|
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totals.flow.act, fUnit);
|
|
|
|
|
// Mirror live aggregate onto DOWNSTREAM — PS subscribes here for the
|
|
|
|
|
// outflow signal. See preserve-tests/ps-mgc-flow-contract regression.
|
|
|
|
|
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.DOWNSTREAM, totals.flow.act, fUnit);
|
|
|
|
|
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totals.power.act, pUnit);
|
|
|
|
|
|
|
|
|
|
const { maxEfficiency, lowestEfficiency } = this.efficiency.calcGroupEfficiency(this.machines);
|
|
|
|
|
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null;
|
|
|
|
|
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
|
|
|
|
|
this.notifyOutputChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async abortActiveMovements(reason = 'new demand') {
|
2025-10-02 17:08:41 +02:00
|
|
|
await Promise.all(Object.values(this.machines).map(async machine => {
|
2026-05-09 09:43:12 +02:00
|
|
|
const state = machine.state?.getCurrentState?.();
|
2026-05-10 21:32:11 +02:00
|
|
|
if (state !== 'accelerating' && state !== 'decelerating') return;
|
|
|
|
|
this.logger.warn(`Force-aborting in-flight movement on ${machine.config.general.id} (state=${state}) due to: ${reason}.`);
|
|
|
|
|
if (typeof machine.abortMovement === 'function') await machine.abortMovement(reason);
|
2025-10-02 17:08:41 +02:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
async _optimalControl(Qd, powerCap = Infinity) {
|
|
|
|
|
if (Object.keys(this.machines).length === 0) {
|
|
|
|
|
this.logger.warn('No machines registered. Cannot execute optimal control.');
|
2026-05-08 11:19:47 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-10 21:32:11 +02:00
|
|
|
this.operatingPoint.equalize();
|
|
|
|
|
const dt = this.dynamicTotals;
|
|
|
|
|
const machineStates = Object.entries(this.machines).reduce((acc, [id, m]) => {
|
|
|
|
|
acc[id] = m.state.getCurrentState();
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
2026-05-08 11:19:47 +02:00
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
if (Qd <= 0) { await this.turnOffAllMachines(); return; }
|
|
|
|
|
if (Qd < dt.flow.min) Qd = dt.flow.min;
|
|
|
|
|
else if (Qd > dt.flow.max) Qd = dt.flow.max;
|
2026-05-08 11:19:47 +02:00
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
const combinations = this.validPumpCombinations(this.machines, Qd, powerCap);
|
|
|
|
|
if (!combinations || combinations.length === 0) {
|
|
|
|
|
this.logger.warn(`Demand: ${Qd.toFixed(2)} -> No valid combination found (empty set).`);
|
|
|
|
|
return;
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
const method = this.config.optimization?.method || 'BEP-Gravitation-Directional';
|
|
|
|
|
const ctx = this.context();
|
|
|
|
|
let bestResult;
|
|
|
|
|
if (method === 'NCog') {
|
|
|
|
|
bestResult = optimizer.calcBestCombination(combinations, Qd, ctx);
|
|
|
|
|
} else if (method === 'BEP-Gravitation' || method === 'BEP-Gravitation-Directional') {
|
|
|
|
|
bestResult = optimizer.calcBestCombinationBEPGravitation(combinations, Qd, ctx, method);
|
2025-07-01 17:03:36 +02:00
|
|
|
} else {
|
2026-05-10 21:32:11 +02:00
|
|
|
this.logger.warn(`Unknown optimization method '${method}', falling back to BEP-Gravitation-Directional.`);
|
|
|
|
|
bestResult = optimizer.calcBestCombinationBEPGravitation(combinations, Qd, ctx, 'BEP-Gravitation-Directional');
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
2026-05-10 21:32:11 +02:00
|
|
|
if (bestResult.bestCombination === null) {
|
|
|
|
|
this.logger.warn(`Demand: ${Qd.toFixed(2)} -> No valid combination found => not updating control.`);
|
|
|
|
|
return;
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
// INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate.
|
2026-05-11 17:13:18 +02:00
|
|
|
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);
|
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>
2026-05-14 22:31:25 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-05-10 21:32:11 +02:00
|
|
|
this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
|
|
|
|
|
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
const distribution = bestResult.bestCombination.map((it) => ({ machineId: String(it.machineId), flow: it.flow }));
|
|
|
|
|
await this._dispatchFlowDistribution(distribution);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Shared dispatch path used by every control strategy. Takes a flow
|
|
|
|
|
// distribution {machineId, flow}[] and routes it through the planner
|
|
|
|
|
// and executor. Same-time-landing (rendezvous) is the default and can
|
|
|
|
|
// be turned off via config.planner.useRendezvous, in which case every
|
|
|
|
|
// command fires at tick 0 (legacy fire-and-forget behaviour, like the
|
|
|
|
|
// pre-planner equalFlowControl).
|
|
|
|
|
async _dispatchFlowDistribution(distribution) {
|
|
|
|
|
const profiles = Object.values(this.machines).map((m) => buildProfile(m));
|
|
|
|
|
const headerPa = Number.isFinite(this.operatingPoint.headerDiffPa) ? this.operatingPoint.headerDiffPa : 0;
|
|
|
|
|
const useRendezvous = this.config?.planner?.useRendezvous !== false; // default true
|
|
|
|
|
const schedule = movementScheduler.plan(profiles, distribution, headerPa, { tickS: 1, useRendezvous });
|
|
|
|
|
this.movementExecutor.replan(schedule);
|
|
|
|
|
// AWAIT the first tick to preserve the race-favouring behaviour
|
|
|
|
|
// of the original code. The new move's full chain (residue
|
|
|
|
|
// handler → operational → ramp) settles before _runDispatch
|
|
|
|
|
// returns; the in-flight shutdown sequence's for-loop runs on
|
|
|
|
|
// other microtasks but its invalid-transition exits truncate it.
|
|
|
|
|
await this.movementExecutor.tick();
|
|
|
|
|
this._ensureExecutorTimer();
|
|
|
|
|
|
|
|
|
|
if (this.logger?.debug) {
|
|
|
|
|
this.logger.debug(`MGC planner: ${schedule.commands.length} commands queued, tStar=${schedule.tStarS.toFixed(1)}s, rendezvous=${useRendezvous}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch one scheduled command to the appropriate child. Returns
|
|
|
|
|
// synchronously — the underlying handleInput is fire-and-forget from
|
|
|
|
|
// the executor's perspective, mirroring the existing optimal-control
|
|
|
|
|
// behaviour where commands are scheduled, not awaited.
|
|
|
|
|
_fireSchedulerCommand(cmd) {
|
|
|
|
|
const machine = this.machines[cmd.machineId];
|
|
|
|
|
if (!machine) {
|
|
|
|
|
this.logger?.warn?.(`Scheduler fired ${cmd.action} for unknown machine ${cmd.machineId}`);
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
const handle = typeof machine.handleInput === 'function' ? machine.handleInput.bind(machine) : null;
|
|
|
|
|
if (!handle) return undefined;
|
|
|
|
|
if (cmd.action === 'execsequence') {
|
|
|
|
|
return Promise.resolve(handle('parent', 'execsequence', cmd.sequence))
|
|
|
|
|
.catch((e) => this.logger?.error?.(`execsequence ${cmd.sequence} on ${cmd.machineId} failed: ${e?.message || e}`));
|
|
|
|
|
}
|
|
|
|
|
if (cmd.action === 'flowmovement') {
|
|
|
|
|
const outFlow = this._canonicalToOutputFlow(cmd.flow);
|
|
|
|
|
return Promise.resolve(handle('parent', 'flowmovement', outFlow))
|
|
|
|
|
.catch((e) => this.logger?.error?.(`flowmovement on ${cmd.machineId} failed: ${e?.message || e}`));
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wall-clock driver for the executor. Auto-stops when there's nothing
|
|
|
|
|
// pending so we don't burn a forever-running setInterval.
|
|
|
|
|
_ensureExecutorTimer() {
|
|
|
|
|
if (this._executorTimer) return;
|
|
|
|
|
this._executorTimer = setInterval(() => {
|
|
|
|
|
this.movementExecutor.tick();
|
|
|
|
|
if (this.movementExecutor.pending() === 0) {
|
|
|
|
|
clearInterval(this._executorTimer);
|
|
|
|
|
this._executorTimer = null;
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
}, this._executorIntervalMs);
|
|
|
|
|
// Unref so the timer doesn't keep Node-RED alive on shutdown.
|
|
|
|
|
if (typeof this._executorTimer.unref === 'function') this._executorTimer.unref();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop the executor's wall-clock driver. Called from teardown paths.
|
|
|
|
|
_stopExecutorTimer() {
|
|
|
|
|
if (this._executorTimer) {
|
|
|
|
|
clearInterval(this._executorTimer);
|
|
|
|
|
this._executorTimer = null;
|
|
|
|
|
}
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
2026-03-31 18:17:41 +02:00
|
|
|
|
2026-05-11 17:29:18 +02:00
|
|
|
// Returns when THIS call's dispatch settles. If overwritten by a later
|
|
|
|
|
// handleInput() while parked behind an in-flight dispatch, resolves
|
|
|
|
|
// with the LatestWinsGate.SUPERSEDED sentinel ({ superseded: true }).
|
2025-10-02 17:08:41 +02:00
|
|
|
async handleInput(source, demand, powerCap = Infinity, priorityList = null) {
|
2026-05-11 17:29:18 +02:00
|
|
|
return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList });
|
2026-05-09 09:14:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 21:36:00 +02:00
|
|
|
// Operator-style entry point: accepts a (value, unit) pair and resolves
|
|
|
|
|
// to canonical m³/s before delegating to handleInput. Single source of
|
|
|
|
|
// truth for the unit math shared by the set.demand command handler and
|
|
|
|
|
// by parent nodes (e.g. pumpingStation level-based control) that hold a
|
|
|
|
|
// direct reference to this specificClass and need to push a % demand
|
|
|
|
|
// without re-implementing the interpolation. Negative value is the
|
|
|
|
|
// stop-all signal regardless of unit.
|
|
|
|
|
async setDemand(value, unit = '%') {
|
|
|
|
|
const v = Number(value);
|
|
|
|
|
if (!Number.isFinite(v)) {
|
|
|
|
|
this.logger?.error?.(`setDemand: invalid value '${value}'`);
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
if (v < 0) {
|
|
|
|
|
await this.turnOffAllMachines();
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
let canonical;
|
|
|
|
|
if (unit === '%') {
|
|
|
|
|
const dt = this.calcDynamicTotals();
|
|
|
|
|
canonical = this.interpolation.interpolate_lin_single_point(
|
|
|
|
|
v, 0, 100, dt.flow.min, dt.flow.max);
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
canonical = convert(v).from(unit).to('m3/s');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger?.error?.(`setDemand: cannot convert ${v} ${unit} -> m3/s: ${err?.message || err}`);
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return this.handleInput('parent', canonical);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
async _runDispatch(source, demand, powerCap, priorityList) {
|
2025-11-13 19:39:32 +01:00
|
|
|
const demandQ = parseFloat(demand);
|
2026-05-10 21:32:11 +02:00
|
|
|
if (!Number.isFinite(demandQ)) {
|
|
|
|
|
this.logger.error(`Invalid flow demand input: ${demand}.`);
|
2025-11-13 19:39:32 +01:00
|
|
|
return;
|
|
|
|
|
}
|
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>
2026-05-14 22:31:25 +02:00
|
|
|
// 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; }
|
2026-05-10 21:32:11 +02:00
|
|
|
await this.abortActiveMovements('new demand received');
|
|
|
|
|
const dt = this.calcDynamicTotals();
|
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>
2026-05-14 22:31:25 +02:00
|
|
|
// 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;
|
2026-05-10 21:32:11 +02:00
|
|
|
|
feat(mgc): editor defaults, compact status badge, mode-case fix, real example flows + dashboard
Editor (mgc.html)
- Drag-in defaults now expose mode (optimalControl) and scaling (normalized)
via dropdowns in the edit dialog. Was: no control fields in the UI at all,
so users had to send set.mode/set.scaling after deploy or live with the
hidden schema defaults.
Wire-up (src/nodeClass.js)
- buildDomainConfig now bridges the flat editor fields (mode, scaling) into
the nested schema shape (mode.current, scaling.current). Was: returned {}
so the editor's mode/scaling never reached the runtime.
Mode-case bug fix (src/specificClass.js)
- Schema enum values are camelCase (optimalControl, priorityControl) but the
runtime switch in _runDispatch matched lowercase only. With the default
config, dispatch silently fell through to the warning branch and nothing
ran. Normalise via String(this.mode).toLowerCase() so both forms work.
Status badge (src/io/output.js)
- Compacted from ~80 chars (mode | Ⓝ: 💨=Q/Qmax | ⚡=P | N machine(s)) to
~50 chars (mode | norm | Q=Q/Qmax m³/h | P=P kW | active/total x).
Drops emoji glyphs that rendered inconsistently across themes; uses the
same dot+fill convention as pumpingStation.
Output extension (src/io/output.js)
- getOutput() now also emits flowCapacityMin/Max, machineCount,
machineCountActive. Was: only group-level totals + dist-from-peak +
mode/scaling, so dashboards couldn't show capacity / active count
without subscribing to each rotatingMachine individually.
Examples
- Drop pre-refactor stubs (basic.flow.json, integration.flow.json,
edge.flow.json). They had a single MGC + inject + debug, no children,
and never dispatched anything.
- 01-Basic.json: 1 MGC + 3 rotatingMachine pumps + Setup once-fires
virtualControl + cmd.startup on all pumps via fan-out function. Numbered
driver groups for Control mode / Scaling / Operator demand. Pumps
register with MGC via Port 2 (child.register, automatic).
- 02-Dashboard.json: same plumbing + FlowFuse Dashboard 2.0 page with
Controls (mode + scaling buttons, demand slider 0–100, stop + init
buttons), Status (7 ui-text rows), Trends (3 charts: flow + capacity,
power, BEP rel %), and a raw-output ui-template dumping every Port 0
field. Fan-out function caches last-known values so deltas don't blank.
Wiki + README
- examples/README.md rewritten for the two-file set with canonical command
surface table and "what to try" recipes.
- wiki/Home.md §11 (Examples) updated; §14 #4 (TODO flow item) replaced
with the actual current limitation (no per-pump fan-out on Port 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:24:03 +02:00
|
|
|
// Normalize for the switch — schema enum values use camelCase
|
|
|
|
|
// (optimalControl, priorityControl) while legacy callers send
|
|
|
|
|
// lowercase. Accept both rather than silently falling through.
|
2026-05-10 21:32:11 +02:00
|
|
|
const ctx = { mgc: this };
|
feat(mgc): editor defaults, compact status badge, mode-case fix, real example flows + dashboard
Editor (mgc.html)
- Drag-in defaults now expose mode (optimalControl) and scaling (normalized)
via dropdowns in the edit dialog. Was: no control fields in the UI at all,
so users had to send set.mode/set.scaling after deploy or live with the
hidden schema defaults.
Wire-up (src/nodeClass.js)
- buildDomainConfig now bridges the flat editor fields (mode, scaling) into
the nested schema shape (mode.current, scaling.current). Was: returned {}
so the editor's mode/scaling never reached the runtime.
Mode-case bug fix (src/specificClass.js)
- Schema enum values are camelCase (optimalControl, priorityControl) but the
runtime switch in _runDispatch matched lowercase only. With the default
config, dispatch silently fell through to the warning branch and nothing
ran. Normalise via String(this.mode).toLowerCase() so both forms work.
Status badge (src/io/output.js)
- Compacted from ~80 chars (mode | Ⓝ: 💨=Q/Qmax | ⚡=P | N machine(s)) to
~50 chars (mode | norm | Q=Q/Qmax m³/h | P=P kW | active/total x).
Drops emoji glyphs that rendered inconsistently across themes; uses the
same dot+fill convention as pumpingStation.
Output extension (src/io/output.js)
- getOutput() now also emits flowCapacityMin/Max, machineCount,
machineCountActive. Was: only group-level totals + dist-from-peak +
mode/scaling, so dashboards couldn't show capacity / active count
without subscribing to each rotatingMachine individually.
Examples
- Drop pre-refactor stubs (basic.flow.json, integration.flow.json,
edge.flow.json). They had a single MGC + inject + debug, no children,
and never dispatched anything.
- 01-Basic.json: 1 MGC + 3 rotatingMachine pumps + Setup once-fires
virtualControl + cmd.startup on all pumps via fan-out function. Numbered
driver groups for Control mode / Scaling / Operator demand. Pumps
register with MGC via Port 2 (child.register, automatic).
- 02-Dashboard.json: same plumbing + FlowFuse Dashboard 2.0 page with
Controls (mode + scaling buttons, demand slider 0–100, stop + init
buttons), Status (7 ui-text rows), Trends (3 charts: flow + capacity,
power, BEP rel %), and a raw-output ui-template dumping every Port 0
field. Fan-out function caches last-known values so deltas don't blank.
Wiki + README
- examples/README.md rewritten for the two-file set with canonical command
surface table and "what to try" recipes.
- wiki/Home.md §11 (Examples) updated; §14 #4 (TODO flow item) replaced
with the actual current limitation (no per-pump fan-out on Port 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:24:03 +02:00
|
|
|
switch (String(this.mode || '').toLowerCase()) {
|
2026-05-10 21:32:11 +02:00
|
|
|
case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break;
|
|
|
|
|
case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break;
|
|
|
|
|
default: this.logger.warn(`${this.mode} is not a valid mode.`);
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
const { maxEfficiency, lowestEfficiency } = this.efficiency.calcGroupEfficiency(this.machines);
|
|
|
|
|
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue();
|
|
|
|
|
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
|
|
|
|
|
this.notifyOutputChanged();
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
async turnOffAllMachines() {
|
2026-05-11 17:29:18 +02:00
|
|
|
// Cancel any parked demand — turnOff is latest user intent so a
|
|
|
|
|
// pending fireAndWait must not re-engage pumps post-shutdown.
|
|
|
|
|
this._demandDispatcher.cancelPending();
|
2026-05-10 21:32:11 +02:00
|
|
|
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
|
|
|
|
|
if (this._shutdownInFlight.has(id)) return;
|
|
|
|
|
if (this.isMachineActive(id)) {
|
|
|
|
|
this._shutdownInFlight.add(id);
|
|
|
|
|
try { await machine.handleInput('parent', 'execsequence', 'shutdown'); }
|
|
|
|
|
finally { this._shutdownInFlight.delete(id); }
|
2026-05-09 18:17:55 +02:00
|
|
|
}
|
2025-10-02 17:08:41 +02:00
|
|
|
}));
|
2026-05-11 17:13:18 +02:00
|
|
|
const fUnit = this.unitPolicy.canonical.flow;
|
|
|
|
|
const pUnit = this.unitPolicy.canonical.power;
|
2026-05-10 21:32:11 +02:00
|
|
|
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.DOWNSTREAM, 0, fUnit);
|
|
|
|
|
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, 0, fUnit);
|
|
|
|
|
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, 0, pUnit);
|
|
|
|
|
this.notifyOutputChanged();
|
2025-10-02 17:08:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 13:40:45 +02:00
|
|
|
_canonicalToOutputFlow(value) {
|
2026-05-11 17:13:18 +02:00
|
|
|
const from = this.unitPolicy.canonical.flow;
|
|
|
|
|
const to = this.unitPolicy.output.flow;
|
2026-04-07 13:40:45 +02:00
|
|
|
if (!from || !to || from === to) return value;
|
|
|
|
|
return convert(value).from(from).to(to);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:32:11 +02:00
|
|
|
getOutput() { return io.getOutput(this); }
|
|
|
|
|
getStatusBadge() { return io.getStatusBadge(this); }
|
2025-07-01 17:03:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = MachineGroup;
|
feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00
|
|
|
// Module-level helpers exposed for unit tests.
|
|
|
|
|
module.exports._normaliseMode = _normaliseMode;
|
|
|
|
|
module.exports.ALLOWED_MODES = ALLOWED_MODES;
|