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>
Three user-facing fixes:
1. Outlet was getting pushed below the tank floor by the top-down
nudge because its ideal y is already near the bottom. Now
outflowLevel is PINNED at its proportional y (like basinHeight
is pinned at the rim) and a second bottom-up pass pushes
non-pinned items upward from the outlet anchor. Result: outlet
stays near the tank floor, dryRunLevel sits right above it, the
rest of the stack stays readable. Two anchors, two passes.
2. Zone labels mirrored from the wiki basin-model drawio:
- "Spare volume before spilling" (overflowLevel ↔ maxLevel)
- "Sewage + tank buffer" (maxLevel ↔ startLevel)
- "Tank buffer" (startLevel ↔ minLevel)
- "Tank buffer" (minLevel ↔ dryRunLevel)
- "Dead volume" (outflowLevel ↔ floor)
Each sits at the midpoint of its pair of nudged thresholds and
hides when the gap between them is too small to read (< 14 px).
3. basinVolume moved into the SVG as a pinned input above the tank
rim (alongside basinHeight), replacing the separate form row.
One editor, one diagram — the total volume belongs with the
geometry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the tank-bigger approach from last commit. Instead of
scaling the tank and keeping strict proportionality, the dashed
threshold lines are now nudged apart directly so each gets a
guaranteed 36-px vertical gap. Inputs and labels align with the
lines (no more leader lines needed).
Trade-off: the diagram is now an ordered schematic, not a strictly
to-scale rendering. Values are still shown next to each line via
the input boxes, and the value ordering is preserved. For an editor
where the goal is entering parameters, readability wins over scale
fidelity.
Sizing reverted:
viewBox 620 → 430
tank h 520 → 340
botY 560 → 380
Behavior:
GAP 30 → 36 (more visible space between dashed lines)
placeItem takes a single y now (line + input + label + unit
share it); leader-line mechanism kept as hidden
plumbing in case we switch back to proportional later
Dead-volume band now anchors to the (possibly-nudged) outflow line
instead of the proportional y so it still visually meets the line
cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tank height 340 → 520 px (viewBox 480 → 620). Lines that were
cramped in the bottom metre now have ~50 % more room, so:
- The Outlet arrow no longer visually crowds the minLevel line
- Dashed threshold lines (dryRunLevel, minLevel, outflowLevel)
have visible breathing room between them for typical wastewater
values where they sit in the bottom 1 m
- Input-stack GAP bumped 26 → 30 px to match the extra vertical
real estate
Pure layout change — same proportional mapping, same nudging
algorithm, just more canvas. Floor/datum label and ordering-
warning ribbon positions shifted accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When real wastewater values cluster near the basin floor (minLevel,
dryRunLevel, outflowLevel are often within a few cm of each other),
the threshold inputs were stacking on top of each other. Now:
- Threshold LINE stays at its proportional y on the tank (visual
truth: that's where the level actually is).
- Input BOX / label / unit are positioned in a nudged right-column
stack with a minimum 26-px gap so they never overlap.
- A dashed grey leader line connects each line to its input when
they had to be pulled apart, so the association stays visible.
- Items are sorted by ideal y top-down and nudged downward once;
basinHeight is pinned at the rim and acts as the anchor.
Also: viewBox extended 430 → 480 so the bottom-of-stack items have
room below the tank when the bottom cluster is tight. Warning ribbon
moved to y=460 accordingly.
No schema change; purely UI layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the static parameters-diagram-above-form-rows layout with a
single interactive SVG where every threshold input sits directly on
the tank at its proportional y-position. Typing a value repositions
the corresponding line + input + label live.
What moved into the diagram (via <foreignObject> holding real
<input> elements with their existing node-input-* IDs so Node-RED
save/restore is untouched):
basinHeight — top of tank (fixed at rim by definition)
overflowLevel — weir crest (red, dashed)
maxLevel — 100 % demand line (orange, dashed)
startLevel — ramp-start line (green, dashed)
minLevel — MGC-shutdown line (purple, dashed)
inflowLevel — Inlet arrow + input on left
outflowLevel — Outlet arrow + input on right
dryRunLevel — read-only, computed from outflow × (1+dryRunPct/100)
Also in the diagram:
- Dead-volume band fills the area below outflowLevel dynamically
- Warning ribbon appears below the tank if ordering invariants break
(mirrors specificClass._validateThresholdOrdering)
- All positions scale against the user's basinHeight; if empty, a
default 5 m scale is used just to keep the diagram readable
What stayed as regular form rows:
- Basin Volume (m³) — not a height, can't be placed on a y-axis
- minLevel / startLevel / maxLevel were in the Control Strategy >
Level-based section; removed from there and moved into the diagram
(the level-based subsection now contains a one-line pointer)
- Safety % inputs (dryRun, overfill) stay in the Safety section with
their derived-level readouts, now synced with the diagram
No schema changes, no field additions, no behaviour changes in the
runtime. Pure editor-UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
~3 KB inline SVG showing the 5 threshold lines + inlet/outlet pipe
arrows + floor datum, using the same names as the editor fields
(basinHeight, overflowLevel, maxLevel, startLevel, minLevel,
dryRunLevel). No new inputs — purely a visual reminder of what
each field refers to, so operators don't have to alt-tab to the
wiki to figure out which pipe edge to measure.
Wrapped in <details open> so users can collapse it once they
know the layout — no forced scroll through the diagram on every
edit session.
Matches the vocabulary in wiki/diagrams/basin-model.drawio.svg
(inlet = bottom of pipe, outlet = top of pipe, 0 m datum at
basin floor, dryRunLevel derived from %).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### P1 — match diagram naming (labels only, no schema change)
- "Inlet Elevation" → "Inlet (bottom of pipe, m)"
- "Outlet Elevation" → "Outlet (top of pipe, m)"
- "Overflow Level" → "Overflow (weir crest, m)"
- "Basin Bottom (m Refheight)" → "Basin floor above datum (m)"
- Added a one-line banner at the top of Basin Geometry:
"All heights measured from the basin floor (0 m)."
These map directly to the clarifications added to basin-model.drawio.svg
so editor and diagram speak the same vocabulary.
### P3 — live derived safety levels next to the % fields
Low/High Volume Threshold fields now show the resulting trip level
live as the operator types:
Low Volume Threshold (%) [ 2 ] → dryRunLevel ≈ 0.21 m
High Volume Threshold (%) [ 98 ] → overfillLevel ≈ 4.41 m
Recomputed on every input/change of outflowLevel, inflowLevel,
overflowLevel, minHeightBasedOn, or either %. Pure UI feedback —
no schema change, no save-side change, same formulas as
specificClass._validateThresholdOrdering().
Also includes the user's latest basin-model.drawio.svg update
(inlet=bottom/outlet=top labels + datum annotation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per discussion: "test" and "eval" overlap in meaning; "simulations"
is more honest about what's actually happening — scripted plant
inputs driving a physics sim, then recorded for analysis.
Rename scope:
- eval/ → simulations/ (tracked as git renames)
- Internal references in run.js and README.md updated
- wiki/modes/mpc.md link updated
Also fixes a log-write bug noticed during the rename:
- run.js didn't mkdir simulations/logs/ before createWriteStream,
so the stream opened into a potentially non-existent dir and the
file never materialised. Added fs.mkdirSync(..., recursive:true).
- end() wasn't awaited, so the process could exit before the stream
flushed. Now awaits the 'finish' event. Confirmed: 1200 records
actually land in simulations/logs/<scenario>.jsonl.
- Added simulations/logs/.gitignore so future JSONL artefacts stay
out of the repo but the dir remains tracked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### eval/ (scenario-based evaluation)
Complements the unit tests under test/basic. Scenarios fluctuate inputs
over simulated time, record every tick to JSONL, print a summary
table + event log, and check expectations. Complementary to unit
tests — these answer "how does the system respond to this input
profile" rather than "is this function correct".
- eval/run.js — driver; monkey-patches Date.now so the
volume integrator ticks at 1 s/iter
regardless of wall-clock
- eval/scenarios/ — one file per scenario
- levelbased-steady.js — constant inflow, demand converges
- levelbased-storm.js — inflow surge, demand saturates
- safety-dry-run-trip.js — manual mode, empty basin, safety trips
- eval/formatters/table.js — ASCII summary of sampled ticks
- eval/logs/ — per-scenario JSONL output (one line per tick)
- eval/README.md — usage + scenario file shape + how to pipe
into InfluxDB/Grafana
All three starter scenarios PASS with their expectations.
### wiki/modes/ (tier template pages)
The levelbased page templated Tier-1 modes (static transfer function).
Added worked examples for the other two tiers so all mode pages share
a common skeleton and new modes have something concrete to imitate:
- flowbased.md — Tier 2 (PID on measured outflow)
- powerbased.md — Tier 2 (levelbased curve clipped by grid power budget)
- mpc.md — Tier 3 (optimisation + forecast; block diagram +
scenario time-series instead of a fixed curve)
- modes/README.md — updated with the three-tier classification table
and diagram-type-per-tier guidance
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>
Introduces the pattern: basin model is the shared canvas (mode-agnostic
physics); each control mode is its own page under wiki/modes/ plus a
demand-vs-level transfer-function diagram under wiki/diagrams/modes/.
- wiki/modes/README.md — index + per-mode page template (inputs,
threshold policy, demand formula, edge cases, related)
- wiki/modes/levelbased.md — first worked example using the new naming
convention (dryRunLevel / minLevel / startLevel / maxLevel /
overflowLevel). Forward-looking — the code still uses the old names
until the pending rename refactor.
- wiki/diagrams/modes/levelbased.drawio.svg — transfer-function plot
(zones: STOP / DEAD ZONE / RAMP / SATURATE, safety trips outside the
plot). Round-trippable via embedded drawio XML.
- functional-description.md — replaced the inline levelbased/manual
subsection with a table pointing at the modes/ pages. Removed the
old control-zones ASCII diagram reference (superseded by the
per-mode transfer function).
- wiki/README.md — added Control modes entry + diagrams/modes/ pointer.
The remaining placeholder modes (flowbased, pressureBased,
percentageBased, powerBased, hybrid, manual) can each fill in the
template independently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each <name>.drawio.svg now has the corresponding <name>.drawio XML
embedded as content="..." on the root <svg> element. Opening the
SVG in draw.io (File → Open, or drag-drop) loads the full editable
model — no need to keep the .drawio file around for editing.
Updated diagrams/README.md to reflect that both file formats are
now round-trippable from the start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves documentation into the code repo so code, docs, and diagrams
version-lock and review together. Previous location was
pumpingStation.wiki.git; that will shrink to a pointer.
Contents:
- wiki/README.md — doc index
- wiki/functional-description.md — operator-facing reference derived
from src/specificClass.js: basin model, net-flow selection,
level-based control zones, safety interlocks, registration topology
- wiki/diagrams/ — editable draw.io sources paired with SVG exports
(basin-model, control-zones, safety-rules) + README with the
open/edit/export/commit workflow
The .drawio files are rough starters; iterate in draw.io and re-export.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>