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>
296 lines
13 KiB
JavaScript
296 lines
13 KiB
JavaScript
// 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);
|
||
},
|
||
};
|
||
})();
|