fix(levelBased): drop hold zone, route through MGC.setDemand, add holdLevel + integrator variant pick; slim npm pack

levelBased ramp + engagement:
- Ramp foot is now max(startLevel, holdLevel) — was max(startLevel,
  inflowLevel). inflowLevel is basin geometry, not a control setpoint;
  the implicit hold zone it created was causing pumps to "start at
  inflowLevel" instead of startLevel.
- New optional `holdLevel` config (defaults to startLevel = no hold band).
  When raised, pumps engage at startLevel and hold at 0 % = MGC flow.min
  across [startLevel, holdLevel], then ramp 0..100 % to maxLevel.
- Engagement decided in run() (not in `_applyMachineGroupLevelControl`):
  rising-edge hysteresis arming gates a clean turnOff early-return.
  Once armed, the helper always forwards setDemand(pct, '%') — 0 %
  legitimately means "engaged at min flow", no more soft-turnOff at
  the boundary.
- Disengagement paths (minLevel hard-stop, stopLevel falling-edge,
  pre-arming idle) now all clear the shifted-ramp hysteresis state too.
- Threshold validator drops the startLevel ≤ inflowLevel rule; adds
  startLevel ≤ holdLevel < maxLevel (only checked when holdLevel is
  explicitly set, so default-null doesn't false-flag).

MGC unit math:
- Replace direct group.handleInput(percent) with group.setDemand(pct, '%')
  in _applyMachineGroupLevelControl. The percent → m³/s resolution now
  lives in MGC.setDemand (committed separately in the MGC submodule).

FlowAggregator variant picking:
- New _pickFlowSum() helper mirrors selectBestNetFlow's variant
  precedence (measured first, then predicted) and resolves each side
  independently. Realistic mixed case — real measured upstream sensor +
  predicted pump outflow — now feeds the predicted-volume integrator.
  Was reading only `flow.predicted.*` so a real upstream sensor
  (which writes `flow.measured.*`) never moved the level.

Editor:
- New `holdLevel` and `deadZoneKeepAlivePercent` defaults + side-panel
  input rows in the levelbased mode preview.
- Add the missing `ps-mode-line-holdLevel` SVG marker (was declared in
  the side-panel coupling but the SVG element didn't exist, so the
  dashed line never rendered).
- Relax stopLevel marker gate so it renders for any non-negative typed
  value — start/stop ordering is the ribbon's job, not the marker's
  (was hiding the line whenever startLevel was momentarily smaller).
- Add holdLevel to the marker loop in mode-preview so changes track.
- Add stopLevel + holdLevel + maxLevel to all three bindRedraw lists
  (basin-diagram, mode-preview, bounds.apply) so the SVG, validation
  ribbon, and HTML5 min/max attrs update on every edit.
- Initialise stopLevel + holdLevel + deadZoneKeepAlivePercent inputs
  in oneditprepare so reopening the editor shows the saved values.
- nodeClass passes holdLevel + deadZoneKeepAlivePercent into the
  domain config.

Tests:
- New test/basic/_probe_upstream_emit.test.js: confirms the parent
  surfaces flow.measured.upstream.* on Port 0 after a measurement
  child write — pins the previously-invisible measured variant flow.
- flowAggregator.basic.test.js: two new regression cases — measured
  inflow when predicted side is empty, and the measured-in /
  predicted-out mixed case.
- control-levelBased.basic.test.js: new cases for the holdLevel hold
  band, the [stopLevel, startLevel] keep-alive, the engagement gate,
  and the "0 % at startLevel = setDemand" contract.
- specificClass.test.js: zone tests adjusted to the new ramp foot.
  Shifted-ramp tests pin holdLevel = 3 explicitly so their legacy
  arithmetic (ramp foot at inflowLevel) stays self-consistent.
- shifted-ramp-end-to-end.test.js: same holdLevel pin for the same
  reason.

Packaging:
- Add .gitignore + .npmignore so the published tarball drops the
  wiki/, simulations/, test/, tools/, .claude/ etc. The pack went
  from 1.5 MB (72 files) to ~57 KB (30 files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-19 21:36:29 +02:00
parent d4de3cf5c5
commit 2e4ad8d3f1
16 changed files with 485 additions and 86 deletions

View File

@@ -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],
];

View File

@@ -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);

View File

@@ -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, '<='))

View File

@@ -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',

View File

@@ -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],

View File

@@ -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()

View File

@@ -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 };

View File

@@ -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: {