A station engaged above startLevel computes a real demand, but if no machine
group is registered (e.g. the Port 2 parent↔group registration was dropped by a
partial redeploy) the demand is silently forwarded nowhere and the pumps never
react — invisible to the operator. levelBased now warns once when engaged with
an empty machineGroups map (throttled via host._warnedNoMachineGroup, re-arms
when a group reappears); manual.forwardDemand warns when neither a group nor a
direct machine is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A measurement child that already holds a value when the pumpingStation
registers it (e.g. a once:true inject that fired during startup before the
parent subscribed) was never surfaced — the emitter only delivers future
updates. _subscribeMeasurement now seeds from the child's current sample via
getLaggedSample(0), so late subscribers pick up present state. This is what
makes a measured upstream inflow register as inflow on a clean startup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Example flows were renamed to the numbered-tier convention
(02-Dashboard.json). The integration test still loaded the old
basic-dashboard.flow.json and asserted the old 6-output parser shape
+ raw-number payloads. Update both the filename and the assertions
to match the current 14-output fn_status_split (topic labels like
'Level', payload strings like '3.25 m').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MeasurementRouter.onLevelMeasurement was writing level samples via
.value(value).unit(context.unit), which dropped the timestamp. The
level-rate fallback in FlowAggregator derives netFlow from dlevel/dt,
so without a timestamp on each sample it had nothing to differentiate.
Switch to the positional .value(value, timestamp, unit) form so the
fallback works. Add a basic test that drives two level samples 2 s
apart and asserts the aggregator produces direction=filling with a
finite dlevel/dt-derived netFlow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the M3H_TO_M3S constant in control/manual.js and the `* 3600`
inline conversion in the status badge with this.unitPolicy.convert
calls. Expose unitPolicy on the frozen control context so manual
strategies pick it up without reaching into host. Matches the
contract direction in .claude/refactor/CONTRACTS.md §6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
levelBased ramp + engagement:
- Ramp foot is now max(startLevel, holdLevel) — was max(startLevel,
inflowLevel). inflowLevel is basin geometry, not a control setpoint;
the implicit hold zone it created was causing pumps to "start at
inflowLevel" instead of startLevel.
- New optional `holdLevel` config (defaults to startLevel = no hold band).
When raised, pumps engage at startLevel and hold at 0 % = MGC flow.min
across [startLevel, holdLevel], then ramp 0..100 % to maxLevel.
- Engagement decided in run() (not in `_applyMachineGroupLevelControl`):
rising-edge hysteresis arming gates a clean turnOff early-return.
Once armed, the helper always forwards setDemand(pct, '%') — 0 %
legitimately means "engaged at min flow", no more soft-turnOff at
the boundary.
- Disengagement paths (minLevel hard-stop, stopLevel falling-edge,
pre-arming idle) now all clear the shifted-ramp hysteresis state too.
- Threshold validator drops the startLevel ≤ inflowLevel rule; adds
startLevel ≤ holdLevel < maxLevel (only checked when holdLevel is
explicitly set, so default-null doesn't false-flag).
MGC unit math:
- Replace direct group.handleInput(percent) with group.setDemand(pct, '%')
in _applyMachineGroupLevelControl. The percent → m³/s resolution now
lives in MGC.setDemand (committed separately in the MGC submodule).
FlowAggregator variant picking:
- New _pickFlowSum() helper mirrors selectBestNetFlow's variant
precedence (measured first, then predicted) and resolves each side
independently. Realistic mixed case — real measured upstream sensor +
predicted pump outflow — now feeds the predicted-volume integrator.
Was reading only `flow.predicted.*` so a real upstream sensor
(which writes `flow.measured.*`) never moved the level.
Editor:
- New `holdLevel` and `deadZoneKeepAlivePercent` defaults + side-panel
input rows in the levelbased mode preview.
- Add the missing `ps-mode-line-holdLevel` SVG marker (was declared in
the side-panel coupling but the SVG element didn't exist, so the
dashed line never rendered).
- Relax stopLevel marker gate so it renders for any non-negative typed
value — start/stop ordering is the ribbon's job, not the marker's
(was hiding the line whenever startLevel was momentarily smaller).
- Add holdLevel to the marker loop in mode-preview so changes track.
- Add stopLevel + holdLevel + maxLevel to all three bindRedraw lists
(basin-diagram, mode-preview, bounds.apply) so the SVG, validation
ribbon, and HTML5 min/max attrs update on every edit.
- Initialise stopLevel + holdLevel + deadZoneKeepAlivePercent inputs
in oneditprepare so reopening the editor shows the saved values.
- nodeClass passes holdLevel + deadZoneKeepAlivePercent into the
domain config.
Tests:
- New test/basic/_probe_upstream_emit.test.js: confirms the parent
surfaces flow.measured.upstream.* on Port 0 after a measurement
child write — pins the previously-invisible measured variant flow.
- flowAggregator.basic.test.js: two new regression cases — measured
inflow when predicted side is empty, and the measured-in /
predicted-out mixed case.
- control-levelBased.basic.test.js: new cases for the holdLevel hold
band, the [stopLevel, startLevel] keep-alive, the engagement gate,
and the "0 % at startLevel = setDemand" contract.
- specificClass.test.js: zone tests adjusted to the new ramp foot.
Shifted-ramp tests pin holdLevel = 3 explicitly so their legacy
arithmetic (ramp foot at inflowLevel) stays self-consistent.
- shifted-ramp-end-to-end.test.js: same holdLevel pin for the same
reason.
Packaging:
- Add .gitignore + .npmignore so the published tarball drops the
wiki/, simulations/, test/, tools/, .claude/ etc. The pack went
from 1.5 MB (72 files) to ~57 KB (30 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decision 2026-05-11: 'highVolumeSafetyLevel' is canonical. The legacy
'overfillLevel' name is gone from computeSafetyPoints + the validator
issue tuple. 'overfillVol' parallel alias kept (out of scope for this
task; flagged for follow-up). 130/130 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.
src/basin/ BasinGeometry + thresholdValidator (pure)
src/measurement/ flowAggregator + measurementRouter + calibration
src/control/ levelBased + flowBased(stub) + manual + index dispatcher
src/safety/ safetyController split into dryRun + overfill rules
src/commands/ registry array + handlers (canonical names from start)
src/editor.js 260 lines of SVG basin-diagram redraw, was inline in .html
examples/standalone-demo.js was if(require.main===module) at bottom of specificClass.js
CONTRACT.md canonical inputs + outputs + emitted events
Modified:
src/specificClass.js removed the 170-line standalone demo block
pumpingStation.html oneditprepare/oneditsave delegate to editor.{init,save}
pumpingStation.js added admin endpoint serving src/editor.js
102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Volume integrator changes:
- Hard physical floor at 0 added to _updatePredictedVolume. Without
it, a basin seeded below dryRunSafetyVol (calibration / startup
/ low seed) under continued net-outflow drifted volume arbitrarily
negative; the level output looked clamped only because
_calcLevelFromVolume floors at 0, masking the underlying drift.
- New cumulative diagnostic: underflowVolume.predicted.atequipment
(m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a
flow-balance error (over-reported outflow / missing inflow).
- The transition-only dryRunSafetyVol clamp is preserved so
startup-from-empty doesn't snap to 2.1 m³ on tick 1.
Spill flow refactor (taxonomic + bug fix):
- Synthetic spill moved from flow.predicted.out.<child='overflow'>
to its own position flow.predicted.overflow.<default>. The spill
is a derived quantity, not a physical sub-source sharing a position
with pumps — .child() was the wrong knob.
- Removes the spillPrev self-subtraction in the integrator (no longer
needed: outflowTotal at ['out','downstream'] cleanly excludes spill).
- Closes a latent fall-through bug exposed during this work:
.child('overflow').getCurrentValue() returned the value of any
available sibling child when overflow itself didn't yet exist.
Hardened separately in generalFunctions@a516c2b.
- _selectBestNetFlow folds the overflow position into the outflow
side so the predicted net-flow balance still reads ~0 while pinned.
Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated
underflow tracking, getOutput surface, and refill-from-empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow]
in _updatePredictedVolume — the integrator can no longer drift above
the weir crest (only a real measurement can show level > overflow,
e.g. inflow exceeding pump+weir capacity). Excess is recorded as:
- overflowVolume.predicted.atequipment.default — cumulative spill (m3)
- flow.predicted.out.overflow — instantaneous spill rate (m3/s),
registered as a synthetic outflow so net-flow balance reads ~0
while pinned. The integrator subtracts the prior tick's synthetic
flow before integrating so it never feeds back into volume math.
Lower clamp at dryRunSafetyVol fires only on the transition — a low
seed/calibration is left alone; inflow is what brings it back up.
_selectBestNetFlow holds the last non-zero level-rate net flow when
level pins at overflowLevel and dL/dt collapses to 0, so dashboards
keep showing roughly what's coming in. Auto-refreshes once level
drops.
getOutput() exposes predictedOverflowVolume + predictedOverflowRate
as top-level convenience keys; the underlying measurements flow to
InfluxDB via the standard MeasurementContainer flatten path.
9 new test assertions cover the upper-clamp + spill increment, stable
spill across ticks, net-flow ~0 while pinned, spill clearing when
inflow stops, low-seed left alone, drain-across-threshold clamp, and
the new top-level output keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runtime (specificClass.js):
- Replace the "shift left both ramp ends" geometry with a true
hold-then-ramp hysteresis driven by output %, not level:
• Up-curve % crosses shiftArmPercent on the way up → ARM.
• Filling→draining transition while armed → capture the up-curve %
at that moment as _shiftHoldValue.
• Draining + level ≥ shiftLevel → output stays at _shiftHoldValue
(horizontal hold, matching the dashed segment in the SVG).
• Draining + level in [start, shift] → output ramps holdValue → 0 %
along the same curve shape (linear or log) as the up curve.
• Draining + level < startLevel → 0 % AND disarm.
• Returning to filling clears holdValue, stays armed; next drain
transition captures a fresh hold so bouncing fills rearm cleanly.
• Disarm only when level ≤ startLevel.
- New _curveShape(x) helper for shared linear/log shaping.
- Removed legacy _levelBasedRampStart / _levelBasedRampTop /
_updateShiftArmed in favour of the inline state machine.
Adapter (nodeClass.js):
- Pipe shiftArmPercent through to control.levelbased.
Editor (pumpingStation.html + src/editor/):
- Add shiftArmPercent input row (% with unit) to the mode side panel
(only shown when shifted ramp is enabled). Default 95 %.
- Add the horizontal arming-% line + label inside the mode SVG —
this is the "% Threshold triggering shifted ramp down" line from
the original drawing that had been missing.
- Redraw the shifted-down curve to match the SVG geometry literally:
100 % flat from maxLevel → shiftLevel, then ramp shiftLevel →
startLevel down to 0 %, OFF below startLevel. Preview shows the
worst-case envelope (hold = 100 %); runtime hold is captured live.
- Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules
preserved (start < shift ≤ max etc.).
- Auto-default shiftArmPercent to 95 when shift is enabled and the
current value is missing or out of range.
Dashboard example (examples/basic-dashboard.flow.json):
- Parser now reads `level.predicted.atequipment.default` etc. The
MeasurementContainer flatten format includes the implicit 'default'
childId; consumers must include it. Comment in the parser points
at the documenting source in generalFunctions.
Tests:
- test/basic: replace old level-armed-shift tests with two new ones
that exercise the hold-then-ramp arming, capture, hold, ramp-down,
disarm, and the bounce case (filling→draining→filling→draining
captures a fresh hold each time).
- test/integration/shifted-ramp-end-to-end.test.js: new file. Drives
Q_IN/Q_OUT through the full runtime tick with a controllable clock,
asserting the same hysteresis path the dashboard exercises.
- test/integration/basic-dashboard-flow.test.js: fixture keys updated
to the .default-suffixed form so they match the real flatten output.
56/56 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runtime (specificClass.js):
- Replace direction-based hysteresis with level-armed _shiftArmed state.
Arms when level rises past shiftLevel; disarms when level drops below
startLevel. While armed, ramp foot moves to startLevel and ramp top
to shiftLevel — both ends shift left, then saturate at 100 % up to
maxLevel.
- _scaleLevelToFlowPercent now takes (rampStartLevel, rampTopLevel) so
the saturation point follows the shift state.
- New setManualOutflow mirroring setManualInflow.
Adapter (nodeClass.js):
- Pipe enableShiftedRamp / shiftLevel through to control.levelbased.
- New q_out topic handler.
Editor (pumpingStation.html + new src/editor/ modules):
- Split monolithic <script> into modules: index.js (helpers),
basin-diagram.js, mode-preview.js, hover-couple.js, oneditprepare.js,
oneditsave.js — served via /pumpingStation/editor/:file.
- Mode preview redrawn per the SVG diagrams: OFF tier below 0 %, 0 %
flat from start→inlet, ramp inlet→max, optional shifted-down curve
start→shift with 100 % saturation past shift.
- Mode preview gains zone bands (dryRun / safetyLow / safe / safetyHigh /
overflow), level markers (dryRun derived, start, inlet, max, shift,
overflow), validation ribbon that blocks save on bad ordering.
- Auto-default shiftLevel to 0.9 × maxLevel on enable so the marker is
always visible.
- All level inputs moved to a side panel left of each diagram, color-
coded to match line strokes; hover-couple highlights the paired SVG
line on input focus / mouseover.
- Removed UI for non-static parameters: minHeightBasedOn,
pipelineLength, maxDischargeHead, staticHead, defaultFluid,
maxInflowRate, temperatureReferenceDegC,
timeleftToFullOrEmptyThresholdSeconds, inletPipeDiameter,
outletPipeDiameter, minLevel (now derived = dryRunLevel).
- foreignObject inputs in basin SVG removed (single source of truth in
side panel).
Dashboard example (examples/basic-dashboard.flow.json):
- Add manual Q_OUT slider + q_out builder mirroring the existing q_in
trio so the basin can be exercised end-to-end without a connected
rotating-machine downstream.
Tests (test/basic/specificClass.test.js):
- Replace direction-shift test with two new cases covering shift-disabled
hold-zone behaviour and shift-armed/disarmed transitions through
shiftLevel and startLevel boundaries. 53/53 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### Guardrails (specificClass.js)
New _validateThresholdOrdering() runs in the constructor. Checks every
ordered pair of basin + control + derived-safety levels and logs a
warning for each violation; returns the list as this.thresholdIssues
so tests and the eval harness can inspect. Non-fatal — we prefer a
running-but-warned station to a refusal-to-start (availability-first).
Strict invariants (bottom → top):
0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel
Uses a list-of-checks pattern rather than a switch — easier to add new
invariants without reflowing cases, and the list itself is readable
documentation.
### Bug fix (specificClass.js)
calibratePredictedLevel was writing the volume value into the LEVEL
slot. Root cause: MeasurementContainer is stateful — its type()/
variant()/position() calls mutate the container's own cursor, so
caching chain references (const levelChain = ...; const volumeChain
= ...) doesn't isolate them. The second cached chain ended up sharing
the state of the last type() call. Rebuilt chains fresh each time,
matching the calibratePredictedVolume pattern that already worked.
### Tests (test/basic/specificClass.test.js)
Ported from Jest to node:test + node:assert — the project's standard
per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js
(tests referenced methods that no longer exist post-rename).
New coverage, 42 passing subtests:
- Basin geometry derivations + minHeightBasedOn
- Level/volume roundtrip
- Threshold guardrails (5 violation cases)
- Direction derivation
- Mode change accept/reject
- Calibration (volume and level paths — catches the bug above)
- Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate)
- getOutput flattening
- setManualInflow
Run with: node --test test/basic/*.test.js
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the code with the 5-threshold convention used throughout the
wiki (basin model + per-mode transfer-function diagrams):
heightInlet → inflowLevel
heightOutlet → outflowLevel
heightOverflow → overflowLevel
stopLevel → minLevel
maxFlowLevel → maxLevel
minFlowLevel → removed (collapsed into startLevel; they were
always supposed to hold the same value)
minVolIn → minVolAtInflow
minVolOut → minVolAtOutflow
maxVolOverflow → maxVolAtOverflow
startLevel → unchanged
Config schema (generalFunctions/src/configs/pumpingStation.json) is
updated in a parallel commit in that submodule.
Also:
- Stripped the ~150-line ASCII basin diagram from initBasinProperties
JSDoc; it now points at wiki/functional-description.md#basin-model.
- Trimmed the top-of-class JSDoc — the config-sections breakdown was
drifting from the schema anyway; wiki is now the source of truth.
- Tidied inline comments in _controlLevelBased, _scaleLevelToFlowPercent.
- Editor order reshuffled to match the bottom→top basin order:
minLevel, startLevel, maxLevel.
Breaking change for saved flows: existing pumpingStation nodes in
production flows reference the old field names and will need to be
re-entered in the editor. No compat shim — node is RnD/trial.
Co-Authored-By: Claude Opus 4.7 (1M context) <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>