specificClass.js 319 → 311 lines. Removed inline _dispatchInFlight +
_delayedCall + finally block. handleInput is now a 1-line delegate
to DemandDispatcher.fireAndWait({source, demand, ...}).
turnOffAllMachines calls _demandDispatcher.cancelPending().
DemandDispatcher 39 → 53 lines. One integration test rewritten to
use the new sentinel-resolution semantics. 77/77 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UnitPolicy now exposes canonical/output/curve as both methods AND
frozen property bags, so this.unitPolicy = this.constructor.unitPolicy
works directly. Removes the 14-line _unitView assembly in configure().
specificClass.js 336→318. 77/77 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PS calls turnOffAllMachines on every tick once level < stopLevel. Two
ways the pump could re-engage after we shut it down:
1. _delayedCall: a 1% dead-zone keep-alive parked in MGC's deferred
dispatch fires from the in-flight handleInput's finally block AFTER
the shutdown completes, dispatching flow + startup to a fresh pump.
Clear _delayedCall at the top of turnOff.
2. Concurrent shutdown calls on the same pump interrupt each other
before the sequence can transition past stopping. Track shutdown-
in-flight per pump and skip if one is already underway.
Together with the rotatingMachine delayedMove-clearing fix, this lets
the level-based hysteresis cycle complete: pumps shut off cleanly at
stopLevel, basin reverses direction, refills to startLevel, repeat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In normal operation the _dispatchInFlight gate (handleInput)
guarantees no pump movement is in flight when a new dispatch starts,
so the per-machine abort call is a no-op. The previous unconditional
WARN flooded the log with one line per pump per tick (~3/s) for what
was actually a normal-path no-op.
Now the WARN fires ONLY when a pump's state is accelerating or
decelerating — i.e. the gate has been bypassed and we're force-
aborting an in-flight ramp. The wording reflects that:
Force-aborting in-flight movement on pump_a (state=accelerating)
due to: new demand received — _dispatchInFlight gate bypassed.
If you ever see this in production logs, the gate has a hole and
needs investigating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors rotatingMachine state.delayedMove. PS ticks demand into MGC at
1 Hz but a real pump ramp takes several seconds; before this gate every
PS tick aborted the in-flight optimalControl and started a new one, so
pumps never reached their setpoint. Live observation: 120 aborts / 2
min, pump_a drifting to 138 m³/h while pump_b stayed clamped at minFlow
60 m³/h ("near_curve_edge").
While a dispatch is in flight, the latest {source, demand, powerCap,
priorityList} is parked in _delayedCall and the new call returns.
The in-flight dispatch's finally block picks up the latest delayed
value when it settles. Latest-wins — intermediate demands are stomped
because they were obsolete by the time the pumps were ready for them.
Regression test in superproject:
test/mgc-overactive-demand-serialization.integration.test.js
30 concurrent demand calls now produce ≤ 5 aborts (was 30).
All existing tests still pass: 21 MGC integration + 7 cross-node
integration (incl. realistic-startup-timing, inflow-overcapacity-
stability, ps-mgc-flow-contract, idle-startup-deadlock).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts a14aa0d. The "skip when demand unchanged" optimisation broke
the live demo: in some real conditions (basin transitions, safety
controller activations) PS sends repeated demand=0 and the optimisation
correctly turned pumps off the first time but then declined to re-act
when conditions changed in a way the test suite didn't cover. Live
result: pumps stayed off even when basin filled to overflow.
The original symptom (pumps stuck mid-ramp under saturated demand) needs
a different approach — likely a pump-side guard rather than an MGC-side
demand filter. Investigating in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live trace showed PS ticking every 1 s and re-firing the SAME demand
(100% saturated under storm inflow) while the basin level evolved slowly.
Each tick was calling abortActiveMovements + optimalControl, which
aborted in-flight pump moves before they could finish (move duration
~0.4 s vs 1 s tick) and immediately re-issued the same setpoint. Pumps
got stuck ramping from the same starting position toward the same
target indefinitely — moveTimeleft stable at 0.379 s for minutes,
flow.predicted frozen.
Now early-return when |demandQ - prev| < max(0.5, prev*0.005). PS
hysteresis float jitter is filtered, real demand changes still
propagate. Pumps finish their first move and stay at the right
setpoint instead of being aborted forever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handlePressureChange writes the live aggregate (sum of every pump's
current predicted-flow measurement) to flow.predicted.downstream — that
is the channel PS subscribes to for its outflow estimate, and it must
reflect what pumps are actually delivering.
optimalControl + equalFlowControl + prioPercentageControl were also
writing to DOWNSTREAM with the optimizer's TARGET (bestFlow / totalFlow).
That's a planned setpoint, not an achieved aggregate, and it was
clobbering the live value every handleInput tick — leaving PS reading
e.g. 105 m³/h while the real aggregate was 681 m³/h. Test
ps-mgc-flow-contract caught this deterministically.
Move all the optimizer-target writes to AT_EQUIPMENT (the "what we
commanded the equipment to do" channel). DOWNSTREAM is now
single-writer (handlePressureChange) and faithfully tracks reality.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PS subscribes to MGC's flow.predicted.downstream and uses it as the
outflow estimate for net-flow computation. MGC was only writing to
DOWNSTREAM inside optimalControl (the optimizer's bestFlow TARGET, not
the achieved aggregate), and to AT_EQUIPMENT in handlePressureChange.
During transients — e.g. demand dropping to dead-band keep-alive while
pumps are still ramping down from full throttle — PS saw a stale 25 m³/h
target on DOWNSTREAM while pumps were physically delivering 500+ m³/h.
NetFlow looked small and stable when the basin was actually draining
fast.
flow.act = sum of every pump's current predicted output = achieved
aggregate. Mirror it onto DOWNSTREAM so PS gets a live signal on every
pump flow/pressure update, not just every MGC.handleInput.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The priority-control codepath had the same stale dispatch shape that
caused the live deadlock in optimalControl: only handling idle and
operational states, and chaining flowmovement after execsequence
startup. Aligns it with the optimalControl fix so a future mode switch
to prioritycontrol doesn't reintroduce the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the dispatch loop only fired flowmovement for pumps in
'operational' or transitioned 'idle' pumps via execsequence-startup-then-flowmovement.
Pumps mid-startup (starting/warmingup) were silently skipped. With PS
sending demand every tick, intermediate setpoints during the startup
window never reached the pump — it locked onto the very first
snapshot's flowmovement and froze there.
Now flowmovement is sent regardless of state and rotatingMachine's
state.moveTo handles the queueing (delayedMove for transients, unpark
for residue, immediate for operational). Crucially, flowmovement runs
BEFORE execsequence-startup so the FIRST call's stale setpoint can't
land on an already-operational pump and overwrite the latest
delayedMove that fires at end of startup.
Adds three integration tests:
- demand-cycle-walkthrough: 0..100% sweep with clean per-step table
- idle-startup-deadlock: four scenarios that pin the dispatch behaviour
including the regression guard for varying-demand-during-startup
- optimizer-combination-choice: physical-validity invariants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### Bug fix — stale flow cache on shutdown (specificClass.js)
When turnOffAllMachines() fires (negative demand, zero flow demand, or
safety trip), the MGC was only shutting pumps down. The pumps' last
emitted predicted flow / power stayed in the MeasurementContainer,
so the parent pumpingStation kept computing net flow from cached
non-zero values — reading the MGC as "still draining" when it wasn't.
Net: net-flow direction and safety triggers misfired during and
shortly after an MGC shutdown.
Fix: after shutting down all machines, write 0 to the predicted
flow (downstream + atEquipment) and predicted power (atEquipment)
slots so the cache reflects reality immediately.
### Correctness — async/await on shutdown (specificClass.js)
Two call sites invoked turnOffAllMachines() without awaiting it, so
the subsequent `return` raced the shutdown promises. Now awaited.
Also DRY'd one inline shutdown loop into a call to
turnOffAllMachines().
### Physics correction — NCog for centrifugal pumps (integration tests)
The previous tests asserted NCog > 0 for centrifugal pumps. That's
physically wrong: for variable-speed centrifugal pumps P ∝ n³ and
Q ∝ n, so Q/P ∝ 1/n² is monotonically decreasing with speed. Peak
efficiency (peak Q/P) is always at minimum speed → cogIndex = 0 →
NCog = 0 by the current formula.
Tests now:
- Assert NCog == 0 for all centrifugal configurations
- Assert distributeByNCog() falls back to equal distribution when
NCog == 0 (confirmed by the existing tests 4-6 that slope-based
redistribution is what actually differentiates pumps with different
BEPs — not NCog)
This matches the actual implementation; the previous tests were
asserting an idealised COG model that doesn't apply here.
### Editor hygiene (mgc.html, nodeClass.js)
- mgc.html: add missing asset-menu defaults (uuid, supplier, category,
assetType, model, unit) — brings MGC in line with rotatingMachine
and pumpingStation editor shapes.
- nodeClass.js: clear node status badge on close.
All 13 tests (basic + integration) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix flowmovement unit mismatch: MGC computed flow in canonical (m³/s)
but rotatingMachine expects output units (m³/h). All flowmovement calls
now convert via _canonicalToOutputFlow(). Without this fix, every pump
stayed at minimum flow regardless of demand.
- Fix absolute scaling: demandQout vs demandQ comparison bug, reorder
conditions so <= 0 is checked first, add else branch for valid demand.
- Fix empty Qd <= 0 block: now calls turnOffAllMachines().
- Add empty-machines guards on optimalControl and equalizePressure.
- Add null fallback (|| 0) on pressure measurement reads.
- Fix division-by-zero in calcRelativeDistanceFromPeak.
- Fix missing flowmovement after startup in equalFlowControl.
- Add marginal-cost refinement loop in BEP-Gravitation: after slope-based
redistribution, iteratively shifts flow from highest actual dP/dQ to
lowest using real power evaluations. Closes gap to brute-force optimum
from 2.1% to <0.1% without affecting combination selection stability.
- Add NCog distribution comparison tests and brute-force power table test.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>