From 2e4ad8d3f195ef4f4828fd434d1b20a4a6d89400 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Tue, 19 May 2026 21:36:29 +0200 Subject: [PATCH] fix(levelBased): drop hold zone, route through MGC.setDemand, add holdLevel + integrator variant pick; slim npm pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 10 ++ .npmignore | 31 ++++++ pumpingStation.html | 8 ++ src/basin/thresholdValidator.js | 23 ++++- src/control/levelBased.js | 75 +++++++++++---- src/editor/basin-diagram.js | 9 +- src/editor/bounds.js | 22 ++++- src/editor/mode-preview.js | 27 +++--- src/editor/oneditprepare.js | 20 +++- src/measurement/flowAggregator.js | 35 ++++++- src/nodeClass.js | 2 + test/basic/_probe_upstream_emit.test.js | 85 +++++++++++++++++ test/basic/control-levelBased.basic.test.js | 82 +++++++++++++--- test/basic/flowAggregator.basic.test.js | 42 +++++++++ test/basic/specificClass.test.js | 94 +++++++++++++------ .../shifted-ramp-end-to-end.test.js | 6 +- 16 files changed, 485 insertions(+), 86 deletions(-) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 test/basic/_probe_upstream_emit.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63dbd8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay +# in sync — anything that shouldn't be committed AND shouldn't ship in the +# npm tarball goes in both files. +node_modules/ +package-lock.json +*.tgz +.env +.env.* +.DS_Store +npm-debug.log* diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..eeae07e --- /dev/null +++ b/.npmignore @@ -0,0 +1,31 @@ +# === Mirrors .gitignore — items below this block are also excluded from +# the npm tarball. Kept here verbatim so npm pack doesn't fall back to +# the .gitignore inheritance (silent + surprising). === +node_modules/ +package-lock.json +*.tgz +.env +.env.* +.DS_Store +npm-debug.log* + +# === Dev-only content the npm tarball doesn't need === +# Tests + their harness — Node-RED loads the entry .js, not the test tree. +test/ +*.test.js + +# Wiki, screenshots, drawio diagrams — useful in the repo, big in the pack. +wiki/ + +# Local simulation harness + scenario data (dev-only). 870+ KB on disk. +simulations/ + +# Build/maintenance tooling not used at runtime. +tools/ + +# Project memory + IDE configs. +.claude/ +.codex/ +.repo-mem/ +CLAUDE.md +CLAUDE.local.md diff --git a/pumpingStation.html b/pumpingStation.html index 3255d05..6d7842f 100644 --- a/pumpingStation.html +++ b/pumpingStation.html @@ -86,6 +86,8 @@ shiftArmPercent: { value: 95 }, startLevel: { value: 1 }, // m, pump-on threshold (engagement edge) stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back) + holdLevel: { value: 1 }, // m, ramp 0%-foot; defaults to startLevel (= no hold zone) + deadZoneKeepAlivePercent: { value: 1 }, // % emitted across [stopLevel, startLevel] keep-alive band minLevel: { value: 0.3 }, // m, hard-stop (just above outflow pipe top) maxLevel: { value: 3.8 }, // m, 100% demand saturation flowSetpoint: { value: null }, @@ -418,6 +420,11 @@ m +
+
0 % ramp foot — leave at startLevel for no hold band
+ + m +
from basin above
— m @@ -475,6 +482,7 @@ + diff --git a/src/basin/thresholdValidator.js b/src/basin/thresholdValidator.js index 1768778..d10f76b 100644 --- a/src/basin/thresholdValidator.js +++ b/src/basin/thresholdValidator.js @@ -4,7 +4,14 @@ // // Invariants enforced (level-space, bottom → top): // 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight -// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel +// dryRunLevel ≤ minLevel ≤ startLevel ≤ holdLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel +// +// startLevel is INTENTIONALLY not constrained against inflowLevel: setting +// startLevel above the gravity-feed inlet is the "buffer in the sewer" +// configuration where the upstream pipe network is used as overflow storage +// before pumping engages. holdLevel (optional, defaults to startLevel when +// omitted) is the 0 % ramp foot — pumps engage at startLevel but hold at +// min flow until level rises through holdLevel. // // dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages. // The validator recomputes them so a config that places minLevel below the @@ -56,14 +63,26 @@ function validateThresholdOrdering(basin, levelbased, safety) { const points = computeSafetyPoints(basin, safety); const { dryRunLevel, highVolumeSafetyLevel } = points; + // holdLevel is optional — when omitted (null/undefined/NaN) it equals + // startLevel at runtime, so skip both holdLevel-related checks in that + // case (the canonical engine semantics still hold). Explicit null/undefined + // check first so `Number(null) === 0` doesn't accidentally flag a default + // schema value as a real operator-provided one. + const rawHold = lvl.holdLevel; + const holdLevelProvided = rawHold != null && Number.isFinite(Number(rawHold)); + const holdLevel = holdLevelProvided ? Number(rawHold) : null; + const checks = [ ['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel], ['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel], ['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin], ['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel], ['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel], - ['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel], ['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel], + ...(holdLevelProvided ? [ + ['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel], + ['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel], + ] : []), ['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel], ]; diff --git a/src/control/levelBased.js b/src/control/levelBased.js index 132172d..aed0e1b 100644 --- a/src/control/levelBased.js +++ b/src/control/levelBased.js @@ -7,7 +7,9 @@ // through the dead band [stopLevel, startLevel] emitting a small // keep-alive demand so MGC keeps a single pump draining the basin. // 3. Up-curve mapping — level mapped to demand 0..100 % across -// [inflowLevel, maxLevel] using linear or log shape. +// [max(startLevel, inflowLevel), maxLevel] using linear or log shape. +// Foot at startLevel when startLevel > inflowLevel allows buffering +// in the upstream sewer above the gravity-feed point. // 4. Shifted-ramp hysteresis — when the up-curve crosses // shiftArmPercent the strategy ARMS; on the next filling→draining // flip it captures the up-curve value as `hold`; while draining @@ -45,13 +47,21 @@ function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) { async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) { if (!machineGroups || Object.keys(machineGroups).length === 0) return; - await Promise.all( - Object.values(machineGroups).map((group) => - group.handleInput('parent', percentControl).catch((err) => { - logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`); - }) - ) - ); + // The caller (run() below) already gated turn-off via the minLevel + // hard-stop, stopLevel falling-edge, and the rising-edge engagement gate. + // By the time we get here, pumps should be running — `0 %` is the engaged + // "min flow" floor (MGC.setDemand interpolates 0 → dt.flow.min), NOT a + // soft turn-off. Forward unconditionally. + const forward = (group) => { + if (typeof group.setDemand !== 'function') { + logger?.error?.(`Group "${group.config?.general?.name}" missing setDemand — refusing to call handleInput with a percent value`); + return Promise.resolve(); + } + return Promise.resolve(group.setDemand(percentControl, '%')).catch((err) => { + logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err && err.message}`); + }); + }; + await Promise.all(Object.values(machineGroups).map(forward)); } async function _applyMachineLevelControl(machines, percentControl, logger) { @@ -118,6 +128,8 @@ async function run(ctx, controlState, direction) { controlState.percControl = 0; if (host) { host._stopHystRunning = false; + host._shiftArmed = false; + host._shiftHoldValue = null; host._lastDirection = direction; } Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines()); @@ -131,13 +143,38 @@ async function run(ctx, controlState, direction) { } } - // 3. Up-curve mapping. Foot stays at inflowLevel (the basin's - // gravity-feed point): demand is 0 % in [startLevel, inflowLevel] - // (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel]. - const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel; + // 3. Engagement gate. Pumps stay OFF until level rises through startLevel + // for the first time (rising-edge); once engaged they stay on until + // level drops through stopLevel (falling-edge — handled by case 2). + // Without an explicit stopLevel the gate collapses to `level >= startLevel`. + // Moved out of the percentControl path so 0 % can mean "engaged at + // min flow" instead of "stopped". Disengagement also clears the + // shifted-ramp hysteresis so it doesn't survive a stop/start cycle. + const isEngaged = host ? host._stopHystRunning : (level >= startLevel); + if (!isEngaged) { + controlState.percControl = 0; + if (host) { + host._shiftArmed = false; + host._shiftHoldValue = null; + host._lastDirection = direction; + } + Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines()); + return; + } + + // 4. Up-curve mapping. Foot = holdLevel (defaults to startLevel; operators + // can raise it to introduce a hold band [startLevel, holdLevel] where + // pumps run at min flow before the ramp begins). `inflowLevel` does NOT + // shape the curve — it's basin geometry, not a control setpoint. + // Explicit null/undefined check first so `Number(null) === 0` doesn't + // silently put the ramp foot at the basin floor. + const rawHold = cfg.holdLevel; + const holdLevel = (rawHold != null && Number.isFinite(Number(rawHold))) + ? Number(rawHold) : startLevel; + const rampFoot = Math.max(startLevel, holdLevel); const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg); - // 4. Shifted-ramp arming. + // 5. Shifted-ramp arming. if (host) { if (cfg.enableShiftedRamp) { const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95; @@ -177,10 +214,14 @@ async function run(ctx, controlState, direction) { let percControl; if (!inDrainingHold) { if (level < rampFoot) { - // While engaged via stopLevel hysteresis AND inside the dead band - // [stopLevel, startLevel], emit a small keep-alive so MGC keeps a - // single pump running. - if (stopThresholdActive && host?._stopHystRunning && level < startLevel) { + // Engaged (we passed the gate above) but below the ramp foot. Two + // sub-cases: + // (a) Inside the configurable hold band [startLevel, holdLevel] — + // emit 0 %, which MGC's setDemand interpolates to flow.min. + // (b) Inside the falling-edge keep-alive band [stopLevel, startLevel] + // — emit deadZoneKeepAlivePercent (default 1 %) so MGC keeps + // at least one pump turning rather than dispatching a clean min. + if (stopThresholdActive && level < startLevel) { const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent)) ? Number(cfg.deadZoneKeepAlivePercent) : 1; percControl = Math.max(0, keepAlive); diff --git a/src/editor/basin-diagram.js b/src/editor/basin-diagram.js index f2a1ffb..fc199d5 100644 --- a/src/editor/basin-diagram.js +++ b/src/editor/basin-diagram.js @@ -142,6 +142,7 @@ // ≤-checks below are skipped rather than false-flagged). const basinHraw = fNum('basinHeight'); const start = fNum('startLevel'); + const hold = fNum('holdLevel'); const inlet = fNum('inflowLevel'); const max = fNum('maxLevel'); const ovfl = fNum('overflowLevel'); @@ -154,8 +155,12 @@ issues.push('outflowLevel must be > 0'); if (!ok(dryLvl, start, '<')) issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`); - if (!ok(start, inlet, '<=')) - issues.push('startLevel must be ≤ inflowLevel'); + if (!ok(start, max, '<')) + issues.push('startLevel must be < maxLevel'); + if (!ok(start, hold, '<=')) + issues.push('holdLevel must be ≥ startLevel (use startLevel for no hold band)'); + if (!ok(hold, max, '<')) + issues.push('holdLevel must be < maxLevel'); if (!ok(inlet, max, '<=')) issues.push('inflowLevel must be ≤ maxLevel'); if (!ok(max, ovfl, '<=')) diff --git a/src/editor/bounds.js b/src/editor/bounds.js index acdc387..e4c4b13 100644 --- a/src/editor/bounds.js +++ b/src/editor/bounds.js @@ -3,8 +3,14 @@ // the current values of related inputs, so the up/down arrows stop at // values that respect the basin hierarchy: // -// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel -// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight +// 0 < outflowLevel < dryRunLevel < startLevel < maxLevel ≤ overflowLevel ≤ basinHeight +// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight +// +// startLevel is intentionally NOT clamped against inflowLevel: pushing +// startLevel above the gravity-feed inlet is the "buffer in the sewer" +// configuration where upstream pipe storage absorbs flow before pumping +// engages. The level-based ramp foot is max(startLevel, inflowLevel) so +// either ordering is valid. // // The user can still type out-of-range values via the keyboard (HTML5 // min/max only constrain the spinner). The validation ribbons in @@ -52,10 +58,10 @@ setBounds('startLevel', Number.isFinite(dryRun) ? dryRun + EPS : EPS, - inlet ?? max ?? overflow ?? basinHeight); + max ?? overflow ?? basinHeight); setBounds('inflowLevel', - start ?? EPS, + EPS, max ?? overflow ?? basinHeight); setBounds('maxLevel', @@ -73,6 +79,14 @@ Number.isFinite(dryRun) ? dryRun + EPS : EPS, start ?? inlet ?? max ?? overflow ?? basinHeight); + // holdLevel — 0 % ramp foot. Defaults to startLevel (no hold band); + // when raised above startLevel, pumps engage at startLevel but emit + // 0 % across [startLevel, holdLevel] before the ramp begins. Bounds: + // startLevel ≤ holdLevel < maxLevel. + setBounds('holdLevel', + Number.isFinite(start) ? start : EPS, + max ?? overflow ?? basinHeight); + // Shift inputs (only relevant when shifted ramp enabled). if (shiftEnabled) { setBounds('shiftLevel', diff --git a/src/editor/mode-preview.js b/src/editor/mode-preview.js index be793ba..bb2918c 100644 --- a/src/editor/mode-preview.js +++ b/src/editor/mode-preview.js @@ -23,13 +23,16 @@ const svg = document.getElementById('ps-levelbased-mode-diagram'); if (!svg) return; const start = fNum('startLevel'); + const hold = fNum('holdLevel'); const inlet = fNum('inflowLevel'); const max = fNum('maxLevel'); // Optional stopLevel — explicit pump-off threshold. Drawn as its - // own marker line; does NOT shift the ramp foot. Must be < startLevel - // for the marker to render. + // own marker line; does NOT shift the ramp foot. Renders as long as + // the typed value is a non-negative number — the start-vs-stop + // ordering check belongs to the validation ribbon, not the visual + // marker (otherwise the line vanishes while the user is mid-edit). const stopRaw = fNum('stopLevel'); - const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null; + const stop = Number.isFinite(stopRaw) && stopRaw >= 0 ? stopRaw : null; // dryRunLevel is derived from the basin's outflowLevel + dryRun% // (no separate input). Below dryRunLevel the runtime hard-stops; // we draw it as the leftmost vertical marker so the user sees @@ -91,18 +94,17 @@ }; // Up curve. Engagement edge is startLevel (pump-on threshold); the - // ramp foot is inflowLevel — matching the runtime in - // _controlLevelBased, which scales demand over [inflowLevel, maxLevel]. - // The OFF baseline is drawn for level < startLevel; between startLevel - // and inflowLevel demand sits flat at 0 % (system armed but not yet - // ramping); from inflowLevel demand ramps to 100 % at maxLevel. + // ramp foot is holdLevel, with a Math.max(startLevel, …) safety + // floor — matching the runtime in levelBased.run. + // - holdLevel == startLevel (default): no hold band, 0..100 % across + // [startLevel, maxLevel]. + // - holdLevel > startLevel: pumps engaged across [startLevel, + // holdLevel] at 0 % (= MGC flow.min), then 0..100 % across + // [holdLevel, maxLevel]. const up = document.getElementById('ps-mode-curve-up'); const down = document.getElementById('ps-mode-curve-down'); const downLabel = document.getElementById('ps-mode-curve-down-label'); - // Runtime falls back to startLevel when inflowLevel is missing - // (basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel); mirror that - // in the preview so the curve is still drawn instead of blank. - const upFoot = Number.isFinite(inlet) && inlet > start ? inlet : start; + const upFoot = Number.isFinite(hold) && hold > start ? hold : start; if (up) up.setAttribute('points', buildPath(start, upFoot, max)); // Shifted-DOWN curve (only when shift enabled): represents the @@ -167,6 +169,7 @@ ['dryRunLevel', dryRun], ['startLevel', start], ['stopLevel', stop], + ['holdLevel', hold], ['inflowLevel', inlet], ['maxLevel', max], ['overflowLevel', overflow], diff --git a/src/editor/oneditprepare.js b/src/editor/oneditprepare.js index cf43a20..cce5100 100644 --- a/src/editor/oneditprepare.js +++ b/src/editor/oneditprepare.js @@ -65,6 +65,14 @@ // Numeric field defaults. ns.setNumberField('node-input-startLevel', node.startLevel); + ns.setNumberField('node-input-stopLevel', node.stopLevel); + // holdLevel defaults to startLevel when omitted (no hold band). Show + // the saved value if there is one; otherwise mirror startLevel so the + // user immediately sees the "no hold band" baseline. + ns.setNumberField('node-input-holdLevel', + Number.isFinite(node.holdLevel) ? node.holdLevel : node.startLevel); + ns.setNumberField('node-input-deadZoneKeepAlivePercent', + Number.isFinite(node.deadZoneKeepAlivePercent) ? node.deadZoneKeepAlivePercent : 1); ns.setNumberField('node-input-maxLevel', node.maxLevel); ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor); ns.setNumberField('node-input-shiftLevel', node.shiftLevel); @@ -77,16 +85,22 @@ const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp'); if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp; - // Bind redraws to the inputs each diagram cares about. + // Bind redraws to the inputs each diagram cares about. The basin + // diagram itself only paints inflow/outflow/overflow lines, but its + // validation ribbon also enforces startLevel/holdLevel/maxLevel + // ordering — so it has to refire when any of those change too, or + // the "Fix before deploy" ribbon goes stale mid-edit. ns.bindRedraw( ['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel', + 'startLevel', 'stopLevel', 'holdLevel', 'maxLevel', 'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'], ns.basinDiagram.redraw ); ns.bindRedraw( // dryRunLevel is derived (outflowLevel + dryRunThresholdPercent), // so the mode preview must redraw when either of those change. - ['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel', + ['startLevel', 'stopLevel', 'holdLevel', 'maxLevel', + 'inflowLevel', 'outflowLevel', 'overflowLevel', 'dryRunThresholdPercent', 'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'], @@ -97,7 +111,7 @@ // so the next redraw + validation sees the correct min/max attrs. ns.bindRedraw( ['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel', - 'inflowLevel', 'startLevel', 'outflowLevel', + 'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel', 'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent', 'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'], () => ns.bounds?.apply() diff --git a/src/measurement/flowAggregator.js b/src/measurement/flowAggregator.js index 25dc61b..2145624 100644 --- a/src/measurement/flowAggregator.js +++ b/src/measurement/flowAggregator.js @@ -57,6 +57,32 @@ class FlowAggregator { this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp }; } + // Pick the best-available variant for one side of the basin balance. + // Mirrors selectBestNetFlow's variant precedence (measured first, then + // predicted) but resolves each side independently — so a real measured + // upstream sensor + a predicted pump outflow both feed the integrator. + // Returns the summed flow at the requested positions. The first variant + // that has any registered measurement at one of those positions wins, + // even if its sum is 0 (a sensor that reads 0 is still data). + _pickFlowSum(positions, flowUnit = 'm3/s') { + const buckets = this.measurements.measurements?.flow; + if (!buckets) return { sum: 0, variant: null }; + for (const variant of this.flowVariants) { + const variantBucket = buckets[variant]; + if (!variantBucket) continue; + const hasAny = positions.some((pos) => { + const posBucket = variantBucket[pos]; + return posBucket && Object.keys(posBucket).length > 0; + }); + if (!hasAny) continue; + return { + sum: this.measurements.sum('flow', variant, positions, flowUnit) || 0, + variant, + }; + } + return { sum: 0, variant: null }; + } + update() { const flowUnit = 'm3/s'; const now = Date.now(); @@ -64,8 +90,13 @@ class FlowAggregator { // Synthetic spill flow lives at its OWN position ('overflow') — // not as a child of 'out'. That keeps it out of the operational // outflow sum here so no self-subtraction is needed. - const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0; - const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0; + // Inflow + outflow are resolved per-side: a real measured upstream + // sensor (variant=measured) + a predicted pump-curve outflow + // (variant=predicted) is the common realistic mix. + const inflowPick = this._pickFlowSum(this.flowPositions.inflow, flowUnit); + const outflowPick = this._pickFlowSum(this.flowPositions.outflow, flowUnit); + const inflow = inflowPick.sum; + const outflowReal = outflowPick.sum; if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now }; diff --git a/src/nodeClass.js b/src/nodeClass.js index d5a41bc..bb69b88 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -37,6 +37,7 @@ class nodeClass extends BaseNodeAdapter { minLevel: uiConfig.minLevel, startLevel: uiConfig.startLevel, stopLevel: uiConfig.stopLevel, + holdLevel: uiConfig.holdLevel, maxLevel: uiConfig.maxLevel, // Editor names the field levelCurveType; runtime uses curveType. curveType: uiConfig.levelCurveType || uiConfig.curveType, @@ -44,6 +45,7 @@ class nodeClass extends BaseNodeAdapter { enableShiftedRamp: uiConfig.enableShiftedRamp, shiftLevel: uiConfig.shiftLevel, shiftArmPercent: uiConfig.shiftArmPercent, + deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent, }, }, safety: { diff --git a/test/basic/_probe_upstream_emit.test.js b/test/basic/_probe_upstream_emit.test.js new file mode 100644 index 0000000..f6ecab1 --- /dev/null +++ b/test/basic/_probe_upstream_emit.test.js @@ -0,0 +1,85 @@ +// Throwaway probe — exercises the exact path: +// measurement child writes flow.measured.upstream → pumpingStation parent +// subscribes → getOutput() (≡ what Port 0 emits). +// Run with: node --test test/basic/_probe_upstream_emit.test.js + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const PumpingStation = require('../../src/specificClass'); +const { MeasurementContainer, configManager } = require('generalFunctions'); +const EventEmitter = require('node:events'); + +// Minimal PumpingStation config — matches the editor defaults shape. +function makePsConfig() { + const ui = { + name: 'PS', basinVolume: 50, basinHeight: 5, + inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, + minHeightBasedOn: 'outlet', + controlMode: 'levelbased', + minLevel: 1, startLevel: 2, maxLevel: 4, + levelCurveType: 'linear', + processOutputFormat: 'process', dbaseOutputFormat: 'influxdb', + }; + const cm = new configManager(); + // Use the same buildConfig pipeline the runtime uses. + return cm.buildConfig('pumpingStation', ui, 'ps-probe', { + basin: { + volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, + }, + hydraulics: { minHeightBasedOn: 'outlet' }, + control: { + mode: 'levelbased', + allowedModes: new Set(['levelbased']), + levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' }, + }, + safety: {}, + }); +} + +// Fake measurement child that looks exactly like the real one to the router: +// - softwareType 'measurement' +// - config.asset.type = 'flow' +// - config.functionality.positionVsParent = 'upstream' +// - .measurements is a real MeasurementContainer with a real emitter +function makeMeasurementChild(id = 'meas-probe') { + const measurements = new MeasurementContainer({ + autoConvert: true, + preferredUnits: { flow: 'm3/s' }, + }); + // Real container ships an emitter; sanity check. + assert.ok(measurements.emitter instanceof EventEmitter || typeof measurements.emitter?.on === 'function'); + return { + id, + source: { + config: { + general: { id, name: id }, + functionality: { softwareType: 'measurement', positionVsParent: 'upstream' }, + asset: { type: 'flow' }, + }, + measurements, + }, + }; +} + +test('PROBE: measurement child writes flow.measured.upstream — parent surfaces it on getOutput()', () => { + const ps = new PumpingStation(makePsConfig()); + const child = makeMeasurementChild(); + + // Register the child the same way the runtime does. + ps.childRegistrationUtils.registerChild(child.source, 'upstream'); + + // Drive a value through the child's MeasurementContainer the way Channel + // does — type/variant/position chain then .value(). + child.source.measurements + .type('flow').variant('measured').position('upstream') + .value(12, Date.now(), 'm3/h'); // 12 m³/h ≈ 0.00333 m³/s + + const out = ps.getOutput(); + const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream')); + console.log('flow.measured.upstream.* keys in Port 0 payload:', upstreamKeys); + for (const k of upstreamKeys) console.log(` ${k} = ${out[k]}`); + + // The contract: the parent should surface the upstream measurement. + assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* on Port 0'); +}); diff --git a/test/basic/control-levelBased.basic.test.js b/test/basic/control-levelBased.basic.test.js index 78a929f..6c0fc86 100644 --- a/test/basic/control-levelBased.basic.test.js +++ b/test/basic/control-levelBased.basic.test.js @@ -24,9 +24,10 @@ function makeMeasurements(levelMeters) { } function makeGroup(name) { - const calls = { handleInput: [], turnOff: 0 }; + const calls = { setDemand: [], handleInput: [], turnOff: 0 }; return { config: { general: { name } }, + setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); }, handleInput: async (...args) => { calls.handleInput.push(args); }, turnOffAllMachines: () => { calls.turnOff += 1; }, _calls: calls, @@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl assert.equal(state.percControl, 0); for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group'); - assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone'); + assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone'); } }); -// basin-docs behavior: between minLevel and the active ramp foot, demand -// is commanded to 0 % (not "unchanged"). MGC still receives the command; -// only the explicit minLevel hard-stop path skips handleInput. -test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => { +// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge +// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so +// MGC doesn't kick a pump on at flow.min before the gate is ever passed. +test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => { const ctx = makeCtx(1.5); const state = { percControl: 17 }; await levelBased.run(ctx, state); - assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone'); + assert.equal(state.percControl, 0, 'percControl held at 0 before engagement'); for (const g of Object.values(ctx.machineGroups)) { - assert.equal(g._calls.turnOff, 0); - assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group'); - assert.deepEqual(g._calls.handleInput[0], ['parent', 0]); + assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff'); + assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement'); } }); -test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => { +test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => { const ctx = makeCtx(2); const state = { percControl: null }; await levelBased.run(ctx, state); assert.equal(state.percControl, 0); + // Critical: at startLevel pumps are engaged at min flow, NOT turned off. + // The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps + // at this boundary even though the hysteresis was armed. + for (const g of Object.values(ctx.machineGroups)) { + assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel'); + assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC'); + assert.deepEqual(g._calls.setDemand[0], [0, '%']); + } }); test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => { @@ -101,19 +109,65 @@ test('level above maxLevel → percControl clamped at 100 (interpolation limit_i assert.equal(state.percControl, 100); }); -test('percControl forwarded to every group via handleInput("parent", percControl)', async () => { +test('percControl forwarded to every group via setDemand(pct, "%")', async () => { const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50% const state = { percControl: null }; await levelBased.run(ctx, state); assert.equal(state.percControl, 50); for (const g of Object.values(ctx.machineGroups)) { - assert.equal(g._calls.handleInput.length, 1, 'one forward per group'); - assert.deepEqual(g._calls.handleInput[0], ['parent', 50]); + assert.equal(g._calls.setDemand.length, 1, 'one forward per group'); + assert.deepEqual(g._calls.setDemand[0], [50, '%']); + assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand'); assert.equal(g._calls.turnOff, 0); } }); +test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => { + // startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between + // startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now + // the ramp is anchored at startLevel so level=2.5 → 25 %. + const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } }); + ctx.basin = { inflowLevel: 3 }; + const state = { percControl: null }; + await levelBased.run(ctx, state); + assert.ok(Math.abs(state.percControl - 25) < 1e-9, + `expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`); +}); + +test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => { + // Same geometry but operator raises holdLevel to 3 so the ramp's 0 % + // foot moves up. Level=2.5 should now sit in the hold band: pumps are + // engaged but emit 0 % (= MGC's flow.min, NOT turn-off). + const ctx = makeCtx(2.5, { + levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 }, + }); + const state = { percControl: null }; + await levelBased.run(ctx, state); + assert.equal(state.percControl, 0, '0 % in the configurable hold band'); + for (const g of Object.values(ctx.machineGroups)) { + assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band'); + assert.deepEqual(g._calls.setDemand[0], [0, '%']); + } +}); + +test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => { + // stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the + // band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %). + const ctx = makeCtx(1.5, { + levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 }, + }); + // Pre-arm: simulate that level previously crossed startLevel. + ctx.host = { _stopHystRunning: true }; + const state = { percControl: null }; + await levelBased.run(ctx, state); + assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band'); + for (const g of Object.values(ctx.machineGroups)) { + assert.equal(g._calls.turnOff, 0); + assert.deepEqual(g._calls.setDemand[0], [1, '%']); + } +}); + test('no valid level → warns and returns without mutating percControl or calling groups', async () => { const ctx = makeCtx(NaN); let warned = false; diff --git a/test/basic/flowAggregator.basic.test.js b/test/basic/flowAggregator.basic.test.js index 09ee042..d098ca5 100644 --- a/test/basic/flowAggregator.basic.test.js +++ b/test/basic/flowAggregator.basic.test.js @@ -58,6 +58,48 @@ test('FlowAggregator.update integrates inflow-outflow over delta-t', async () => assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`); }); +test('FlowAggregator.update integrates measured inflow when predicted side is empty', async () => { + // Regression: a real upstream sensor writes `flow.measured.upstream.` + // (the measurement node hard-codes variant='measured'), but the integrator + // used to read variant='predicted' only — so level stayed flat while the + // status row reported +N m³/h. The fix mirrors selectBestNetFlow's + // variant precedence per side. + const { fa, measurements } = makeAggregator(); + const t0 = Date.now() - 10_000; + // Measured inflow at 'upstream' (one of the inflow position aliases), + // no outflow side at all. + measurements.type('flow').variant('measured').position('upstream').child('sensor-A') + .value(0.01, t0, 'm3/s'); + + fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 }; + fa.update(); + + const vol = measurements.type('volume').variant('predicted').position('atequipment') + .getCurrentValue('m3'); + // Expect minVol(2) + 0.01 × ~10 ≈ 2.10 m3. + assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`); +}); + +test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => { + // Realistic mix: real upstream sensor (measured) + pump-curve outflow + // (predicted). The picker resolves each side independently, so the net + // balance uses both. + const { fa, measurements } = makeAggregator(); + const t0 = Date.now() - 10_000; + measurements.type('flow').variant('measured').position('upstream').child('sensor-A') + .value(0.01, t0, 'm3/s'); + measurements.type('flow').variant('predicted').position('downstream').child('pump-A') + .value(0.004, t0, 'm3/s'); + + fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 }; + fa.update(); + + const vol = measurements.type('volume').variant('predicted').position('atequipment') + .getCurrentValue('m3'); + // minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3. + assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${vol}`); +}); + test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => { const { fa, measurements } = makeAggregator(); measurements.type('flow').variant('measured').position('in').child('m') diff --git a/test/basic/specificClass.test.js b/test/basic/specificClass.test.js index d6864de..158066a 100644 --- a/test/basic/specificClass.test.js +++ b/test/basic/specificClass.test.js @@ -10,7 +10,7 @@ const PumpingStation = require('../../src/specificClass'); // assignment is no longer possible. Tests inject mock groups through the // real registration handshake so the registry remains the source of truth. function registerMockGroup(ps, id, behavior = {}) { - const calls = { handleInput: [], turnOff: 0 }; + const calls = { setDemand: [], handleInput: [], turnOff: 0 }; const mock = { config: { general: { id, name: id }, @@ -21,6 +21,8 @@ function registerMockGroup(ps, id, behavior = {}) { emitter: { on: () => {} }, setChildId: () => {}, setChildName: () => {}, setParentRef: () => {}, }, + setDemand: behavior.setDemand + || (async (value, unit) => { calls.setDemand.push([value, unit]); }), handleInput: behavior.handleInput || (async (...args) => { calls.handleInput.push(args); }), turnOffAllMachines: behavior.turnOffAllMachines @@ -163,7 +165,10 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => { assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel')); }); - await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => { + await t.test('startLevel > inflowLevel is allowed (sewer-buffer mode), no issue raised', () => { + // Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed + // to fill past the inlet before pumps engage. levelBased shifts the ramp + // foot to startLevel; the validator no longer flags the ordering. const ps = new PumpingStation(makeConfig({ control: { mode: 'levelbased', @@ -171,7 +176,8 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => { levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' }, }, })); - assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel')); + assert.ok(!ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'), + 'startLevel vs inflowLevel ordering must not raise an issue'); }); await t.test('outflowLevel >= inflowLevel flagged', () => { @@ -261,51 +267,77 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { assert.equal(mock._calls.turnOff, 1); }); - await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => { + await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => { const ps = new PumpingStation(makeConfig()); ps.percControl = 42; // simulated previous demand const mock = registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2 await ps._controlLevelBased(); assert.equal(ps.percControl, 0); - assert.equal(mock._calls.handleInput[0][1], 0); + // pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min). + assert.equal(mock._calls.turnOff, 1); + assert.equal(mock._calls.setDemand.length, 0); }); - await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => { + await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => { const ps = new PumpingStation(makeConfig()); const mock = registerMockGroup(ps, 'mgc1'); - ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3 + ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3, maxLevel=4 + await ps._controlLevelBased('filling'); + // Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25. + assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`); + assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp'); + assert.equal(mock._calls.setDemand.length, 1); + assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9); + }); + + await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => { + const ps = new PumpingStation(makeConfig()); + const mock = registerMockGroup(ps, 'mgc1'); + ps.calibratePredictedLevel(3.5); // 3/4 of the [2,4] ramp → 75 %. + await ps._controlLevelBased('filling'); + assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`); + assert.equal(mock._calls.setDemand.length, 1); + assert.equal(mock._calls.setDemand[0][1], '%'); + assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9); + }); + + await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => { + const ps = new PumpingStation(makeConfig({ + control: { + mode: 'levelbased', + allowedModes: new Set(['levelbased']), + levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 }, + }, + })); + const mock = registerMockGroup(ps, 'mgc1'); + ps.calibratePredictedLevel(2.5); // inside [startLevel, holdLevel] await ps._controlLevelBased('filling'); assert.equal(ps.percControl, 0); - assert.equal(mock._calls.handleInput[0][1], 0); + assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off'); + assert.deepEqual(mock._calls.setDemand[0], [0, '%']); }); - await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => { - const ps = new PumpingStation(makeConfig()); - const mock = registerMockGroup(ps, 'mgc1'); - ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4 - await ps._controlLevelBased('filling'); - // lerp(3.5, [3,4], [0,100]) = 50 - assert.ok(Math.abs(ps.percControl - 50) < 1e-9); - assert.equal(mock._calls.handleInput.length, 1); - assert.ok(Math.abs(mock._calls.handleInput[0][1] - 50) < 1e-9); - }); - - await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => { + await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => { const ps = new PumpingStation(makeConfig()); registerMockGroup(ps, 'mgc1'); - // Climb past inflowLevel and beyond, then fall to a level inside [start..inflow]. + // Climb above startLevel, then fall to a level inside [start, inflow]. With + // the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling + // level still produces a positive demand on the way down. ps.calibratePredictedLevel(3.8); await ps._controlLevelBased(); assert.ok(ps.percControl > 0); - ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3 + ps.calibratePredictedLevel(2.5); // startLevel=2, maxLevel=4 → 25 % await ps._controlLevelBased(); - // Without shift the foot is inflowLevel → 0% in the hold zone. - assert.equal(ps.percControl, 0); + assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`); }); - await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => { - // Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4. + await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => { + // The original shifted-ramp test was authored against the legacy ramp + // foot = inflowLevel (=3). With the new defaults the foot moves to + // startLevel (=2), which changes every percentage in the trace. Pin + // the foot back to 3 by setting holdLevel = 3 — that keeps this test's + // arithmetic self-consistent: up curve goes 0 %@3 to 100 %@4. // shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8. // shiftLevel=3.5 ⇒ held output starts ramping down at this level. const ps = new PumpingStation(makeConfig({ @@ -313,7 +345,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { mode: 'levelbased', allowedModes: new Set(['levelbased']), levelbased: { - minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9, + minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9, enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80, }, }, @@ -355,7 +387,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { mode: 'levelbased', allowedModes: new Set(['levelbased']), levelbased: { - minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9, + // Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic + // self-consistent with the original test (up curve 0 %@3 → 100 %@4). + minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9, enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80, }, }, @@ -381,7 +415,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { control: { mode: 'levelbased', allowedModes: new Set(['levelbased']), - levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 }, + // holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching + // the legacy assertion bracket. + levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 }, }, })); registerMockGroup(ps, 'mgc1'); diff --git a/test/integration/shifted-ramp-end-to-end.test.js b/test/integration/shifted-ramp-end-to-end.test.js index 6013ea9..f07ee7f 100644 --- a/test/integration/shifted-ramp-end-to-end.test.js +++ b/test/integration/shifted-ramp-end-to-end.test.js @@ -37,7 +37,11 @@ function makeConfig() { mode: 'levelbased', allowedModes: new Set(['levelbased', 'manual']), levelbased: { - minLevel: 1, startLevel: 2, maxLevel: 4, + // holdLevel pins the ramp foot at 3 to preserve the original geometry + // (up curve 0 %@3 → 100 %@4). New default would put the foot at + // startLevel=2; this test specifically exercises shifted-ramp arming + // behaviour, not the ramp-foot semantic itself. + minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9, enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80, },