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
+
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,
},