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:
@@ -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, '<='))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user