Files
pumpingStation/src/editor/mode-preview.js
znetsixe 2e4ad8d3f1 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>
2026-05-19 21:36:29 +02:00

296 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PumpingStation editor — level-based mode preview SVG.
// Draws zone bands, level markers, the up curve (inflowLevel→maxLevel) and
// the optional shifted-down curve (startLevel→shiftLevel). Computes
// validation issues and stashes them on window._psModeValidationIssues
// for oneditsave to read.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const fNum = (id) => ns.fNum(id);
// Derive dryRunLevel the same way the basin diagram does.
// dryRunLevel = outflowLevel × (1 + dryRunThresholdPercent/100).
// Returns null if either input is missing.
ns.deriveDryRunLevel = () => {
const refLow = fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
if (refLow == null || dryPct == null) return null;
return refLow * (1 + dryPct / 100);
};
ns.modePreview = {
redraw() {
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. 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 ? 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
// exactly where it lands.
const dryRun = ns.deriveDryRunLevel();
const overflow = fNum('overflowLevel');
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
const shiftRaw = fNum('shiftLevel');
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
const armRaw = fNum('shiftArmPercent');
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
// Plot window is FIXED relative to basin geometry so that moving any
// single level slides only that line, not all the others. Lower bound
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
// if overflow isn't set) plus a small margin.
const upperRefs = [max, overflow].filter(Number.isFinite);
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
const pad = Math.max(upperBase * 0.05, 0.1);
const levelMin = 0;
const levelMax = upperBase + pad;
// Plot rectangle (viewBox px).
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
const yOffPx = 160;
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
const scale = (x) => {
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
return clamped;
};
// Path with three flat regions and a ramp:
// [levelMin..startX] OFF (pump off; below startLevel)
// [startX..footX] 0 % (system armed but not yet ramping)
// [footX..topX] ramp (linear or log scaled 0..100 %)
// [topX..levelMax] 100 % (saturated)
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
const buildPath = (startX, footX, topX) => {
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
const pts = [];
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(0)}`);
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const level = footX + t * (topX - footX);
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
}
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
// Up curve. Engagement edge is startLevel (pump-on threshold); the
// 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');
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
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
// then linear/log ramp from (shiftLevel, 100 %) down to
// (startLevel, 0 %), then OFF below startLevel.
// Real runtime hold value depends on where direction flips, so the
// preview shows the maximum extent.
const buildShiftedDown = () => {
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
const pts = [];
// OFF baseline far-left to startLevel
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
// Jump 0 % at startLevel
pts.push(`${xFor(start)},${yForPct(0)}`);
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const lvl = start + t * (shift - start);
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
}
// Held at 100 % from shift → far-right
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
if (down) {
if (shiftEnabled) {
down.setAttribute('points', buildShiftedDown());
down.style.display = '';
if (downLabel) downLabel.style.display = '';
} else {
down.setAttribute('points', '');
down.style.display = 'none';
if (downLabel) downLabel.style.display = 'none';
}
}
// Horizontal arming-% line — only meaningful when shift enabled.
const armLine = document.getElementById('ps-mode-line-armPercent');
const armLabel = document.getElementById('ps-mode-label-armPercent');
if (armLine && armLabel) {
if (shiftEnabled) {
const yArm = yForPct(armPct);
armLine.setAttribute('y1', yArm);
armLine.setAttribute('y2', yArm);
armLabel.setAttribute('y', yArm - 2);
armLabel.textContent = `arm ${Math.round(armPct)}%`;
armLine.style.display = '';
armLabel.style.display = '';
} else {
armLine.style.display = 'none';
armLabel.style.display = 'none';
}
}
// Vertical level markers — line only. Axis labels were removed;
// identification comes from line colour + side-panel labels +
// hover coupling.
[
['dryRunLevel', dryRun],
['startLevel', start],
['stopLevel', stop],
['holdLevel', hold],
['inflowLevel', inlet],
['maxLevel', max],
['overflowLevel', overflow],
].forEach(([id, level]) => {
const line = document.getElementById(`ps-mode-line-${id}`);
if (!line) return;
if (!Number.isFinite(level)) {
line.style.display = 'none';
return;
}
const x = xFor(level);
line.style.display = '';
line.setAttribute('x1', x); line.setAttribute('x2', x);
});
// Background zone bands.
const plotL = xFor(levelMin);
const plotR = xFor(levelMax);
const setBand = (id, a, b) => {
const r = document.getElementById(id);
if (!r) return;
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) {
r.setAttribute('x', 0); r.setAttribute('width', 0);
return;
}
r.setAttribute('x', a);
r.setAttribute('width', b - a);
};
const xMin = Number.isFinite(dryRun) ? xFor(dryRun) : plotL;
const xStart = Number.isFinite(start) ? xFor(start) : xMin;
const xMax = Number.isFinite(max) ? xFor(max) : plotR;
const xOvf = Number.isFinite(overflow) ? xFor(overflow) : xMax;
setBand('ps-zone-dryRun', plotL, xMin);
setBand('ps-zone-safetyLow', xMin, xStart);
setBand('ps-zone-safe', xStart, xMax);
setBand('ps-zone-safetyHigh', xMax, xOvf);
setBand('ps-zone-overflow', xOvf, plotR);
// Shift level marker (line only).
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
if (shiftLine) {
if (shiftEnabled && Number.isFinite(shift)) {
const x = xFor(shift);
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
shiftLine.style.display = '';
} else {
shiftLine.style.display = 'none';
}
}
// Title + row visibility.
const curveLabel = document.getElementById('ps-mode-curve-label');
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
const shiftRow = document.getElementById('ps-shiftLevel-row');
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
const armRow = document.getElementById('ps-shiftArmPercent-row');
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
const logRow = document.getElementById('ps-log-factor-row');
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
// Auto-default shiftLevel when shift is enabled and current value
// is missing/out-of-range. Visible default avoids a hidden ramp.
const shiftInput = document.getElementById('node-input-shiftLevel');
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
const cur = parseFloat(shiftInput.value);
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
shiftInput.value = (max * 0.9).toFixed(2);
}
}
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
// current value is missing / out of [0, 100].
const armInput = document.getElementById('node-input-shiftArmPercent');
if (shiftEnabled && armInput) {
const cur = parseFloat(armInput.value);
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
armInput.value = 95;
}
}
// Validation: only mode-specific (shift) ordering. Basin-level
// hierarchy (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
// dryRun < start) is owned by basin-diagram.js so it shows in the
// basin section near the offending inputs.
const issues = [];
if (shiftEnabled) {
const shiftVal = Number(shiftInput?.value);
if (Number.isFinite(shiftVal)) {
if (Number.isFinite(start) && shiftVal <= start)
issues.push('shiftLevel must be > startLevel');
if (Number.isFinite(max) && shiftVal > max)
issues.push('shiftLevel must be ≤ maxLevel');
} else {
issues.push('shiftLevel is required when shifted ramp is enabled');
}
const armVal = Number(armInput?.value);
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
issues.push('shiftArmPercent must be in (0, 100]');
}
const warnBox = document.getElementById('ps-mode-validation');
if (warnBox) {
if (issues.length) {
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
warnBox.style.display = '';
} else {
warnBox.style.display = 'none';
}
}
window._psModeValidationIssues = issues;
// Read-only readouts in the side panel — number only; the row's
// .ps-unit span already shows "m".
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
const setText = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = fmt(val);
};
setText('ps-mode-readout-dryRun', dryRun);
setText('ps-mode-readout-inflow', inlet);
setText('ps-mode-readout-overflow', overflow);
},
};
})();