Two hard rules for the safety controller, matching sewer PS design:
1. BELOW stopLevel (dry-run): pumps CANNOT start.
All downstream equipment shut down. safetyControllerActive=true
blocks _controlLogic so level control can't restart pumps.
Only manual override or emergency can change this.
2. ABOVE overflow level (overfill): pumps CANNOT stop.
Only UPSTREAM equipment is shut down (stop more water coming in).
Machine groups (downstream pumps) are NOT shut down — they must
keep draining. safetyControllerActive is NOT set, so _controlLogic
continues commanding pumps at the demand dictated by the level
curve (which is >100% near overflow = all pumps at maximum).
Only manual override or emergency stop can shut pumps during
an overfill event.
Previously the overfill branch called turnOffAllMachines() on machine
groups AND set safetyControllerActive=true, which shut down the pumps
and blocked level control from restarting them — exactly backwards
for a sewer pumping station where the sewage keeps coming.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously PS only sent demand to MGC when level > startLevel AND
direction === 'filling'. Between startLevel and stopLevel (the 'dead
zone'), pumps kept running at their last commanded setpoint with no
updates. Basin drained uncontrolled until hitting stopLevel.
Fix: send percControl on every tick when level > stopLevel. The
_scaleLevelToFlowPercent math naturally gives:
- Positive % above startLevel (pumps ramp up)
- 0% at exactly startLevel (pumps at minimum)
- Negative % below startLevel → clamped to 0 → MGC scales to 0
→ pumps ramp down gracefully
This creates smooth visible ramp-up and ramp-down as the basin fills
and drains, instead of a sudden jump at startLevel and stuck ctrl in
the dead zone.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs in registerChild caused multi-counted outflow in _updatePredictedVolume:
1. machinegroup registered twice (line 66 + line 70 both called
_registerPredictedFlowChild). Fixed: only register in the
machinegroup branch.
2. Individual machines registered alongside their machinegroup parent.
Each pump's predicted flow is already included in MGC's aggregated
total — subscribing to both triple-counts. Fixed: only register
individual machines when no machinegroup is present (direct-wired
pumps without MGC).
3. _registerPredictedFlowChild subscribed to BOTH flow.predicted.downstream
AND flow.predicted.atequipment events. These carry the same total flow
on two event names — the handler wrote the value twice per tick.
Fixed: subscribe to ONE event per child (downstream for outflow,
upstream for inflow).
These are generalizable patterns:
- When a group aggregator exists, subscribe to IT, not its children.
- One event per measurement type per child — pick the most specific.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two additions to pumpingStation:
1. _controlLevelBased now calls _applyMachineGroupLevelControl in
addition to _applyMachineLevelControl when the basin is filling
above startLevel. Previously only direct-child machines received
the level-based percent-control signal; in a hierarchical topology
(PS → MGC → pumps) the machines sit under MGC and PS.machines is
empty, so the level control never reached them.
2. New 'Qd' input topic + forwardDemandToChildren() method. When PS
is in 'manual' mode (matching the pattern from rotatingMachine's
virtualControl), operator demand from a dashboard slider is forwarded
to all child machine groups and direct machines. When PS is in any
other mode (levelbased, flowbased, etc.), the Qd msg is silently
dropped with a debug log so the automatic control isn't overridden.
No breaking changes — existing flows that don't send 'Qd' are unaffected,
and _controlLevelBased's additional call to machineGroupLevelControl
is a no-op when no machine groups are registered.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents demo code from executing when module is required by Node-RED,
which caused crashes due to missing measurement data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix method name mismatch in tick() that called non-existent _calcTimeRemaining
instead of _calcRemainingTime. Add 27 unit tests for specificClass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace hardcoded position strings with POSITIONS.* constants.
Prefix unused variables with _ to resolve no-unused-vars warnings.
Fix no-prototype-builtins where applicable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces manual base config construction with shared buildConfig() method.
Node now only specifies domain-specific config sections.
Part of #1: Extract base config schema
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>