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>
197 lines
9.2 KiB
JavaScript
197 lines
9.2 KiB
JavaScript
// PumpingStation editor — interactive basin SVG (top of the editor).
|
|
// Places threshold lines, derived safety levels, zone labels, dead-volume
|
|
// band, and ordering warnings. Same formulas as
|
|
// specificClass._validateThresholdOrdering.
|
|
|
|
(function () {
|
|
const ns = window.PSEditor = window.PSEditor || {};
|
|
const fNum = (id) => ns.fNum(id);
|
|
|
|
// viewBox y bounds of the tank rect (now 120,40)..(240,380); width
|
|
// shrunk to 360 in the new side-panel layout. y-bounds unchanged.
|
|
const DIAG = { topY: 40, botY: 380 };
|
|
|
|
const yForLevel = (val, basinH) => {
|
|
if (val == null || !basinH) return null;
|
|
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
|
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
|
};
|
|
|
|
// Place a row — line, label, input, unit all share the same y.
|
|
const placeItem = (id, y) => {
|
|
const line = document.getElementById(`ps-line-${id}`);
|
|
const label = document.getElementById(`ps-label-${id}`);
|
|
const unit = document.getElementById(`ps-unit-${id}`);
|
|
const fo = document.getElementById(`ps-fo-${id}`);
|
|
const sub = document.getElementById(`ps-sub-${id}`);
|
|
const lead = document.getElementById(`ps-leader-${id}`);
|
|
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
|
if (label) label.setAttribute('y', y + 4);
|
|
if (unit) unit.setAttribute('y', y + 4);
|
|
if (fo) fo.setAttribute('y', y - 11);
|
|
if (sub) sub.setAttribute('y', y + 15);
|
|
if (lead) lead.setAttribute('visibility', 'hidden');
|
|
};
|
|
|
|
ns.basinDiagram = {
|
|
redraw() {
|
|
const basinH = fNum('basinHeight') || 5;
|
|
|
|
const refLow = fNum('outflowLevel');
|
|
const dryPct = fNum('dryRunThresholdPercent');
|
|
const highPct = fNum('highVolumeSafetyThresholdPercent');
|
|
const ovf = fNum('overflowLevel');
|
|
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
|
const highLvl = (ovf != null && highPct != null) ? ovf * (highPct / 100) : null;
|
|
|
|
// Right-column stack. TWO anchors: basinHeight pinned at the rim,
|
|
// outflowLevel pinned at its proportional y. Two passes (top-down +
|
|
// bottom-up) maintain a minimum vertical gap.
|
|
const items = [
|
|
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
|
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
|
{ id: 'highVolumeSafetyLevel', yIdeal: yForLevel(highLvl, basinH) },
|
|
{ id: 'inflowLevelGuide', yIdeal: yForLevel(fNum('inflowLevel'), basinH) },
|
|
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
|
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
|
].filter(it => it.yIdeal != null);
|
|
|
|
const GAP = 36;
|
|
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
|
for (const it of items) it.y = it.yIdeal;
|
|
for (let i = 1; i < items.length; i++) {
|
|
if (items[i].pinned) continue;
|
|
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
|
}
|
|
for (let i = items.length - 2; i >= 0; i--) {
|
|
if (items[i].pinned) continue;
|
|
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
|
}
|
|
for (const it of items) placeItem(it.id, it.y);
|
|
|
|
// Zone labels show only when the gap between the bracketing
|
|
// thresholds is at least MIN_ZONE_GAP px high — otherwise the label
|
|
// collides with one of the threshold labels (which sit at threshold
|
|
// y ±6 px text-height). 28 px keeps a 6 px clear gap above and
|
|
// below the zone label.
|
|
const MIN_ZONE_GAP = 28;
|
|
const placeZone = (zoneId, topId, botId) => {
|
|
const el = document.getElementById(`ps-zone-${zoneId}`);
|
|
if (!el) return;
|
|
const top = items.find(it => it.id === topId);
|
|
const bot = items.find(it => it.id === botId);
|
|
if (!top || !bot || (bot.y - top.y) < MIN_ZONE_GAP) {
|
|
el.setAttribute('visibility', 'hidden'); return;
|
|
}
|
|
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
|
el.setAttribute('visibility', 'visible');
|
|
};
|
|
placeZone('spare', 'overflowLevel', 'highVolumeSafetyLevel');
|
|
placeZone('sewage', 'highVolumeSafetyLevel', 'inflowLevelGuide');
|
|
placeZone('buffer1', 'inflowLevelGuide', 'dryRunLevel');
|
|
placeZone('buffer2', 'dryRunLevel', 'outflowLevel');
|
|
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
|
const deadLbl = document.getElementById('ps-zone-dead');
|
|
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
|
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
|
deadLbl.setAttribute('visibility', 'visible');
|
|
} else if (deadLbl) {
|
|
deadLbl.setAttribute('visibility', 'hidden');
|
|
}
|
|
|
|
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
|
if (inflowY != null) {
|
|
const line = document.getElementById('ps-line-inflowLevel');
|
|
const lbl = document.getElementById('ps-label-inflowLevel');
|
|
const sub = document.getElementById('ps-sub-inflowLevel');
|
|
const fo = document.getElementById('ps-fo-inflowLevel');
|
|
const unit = document.getElementById('ps-unit-inflowLevel');
|
|
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
|
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
|
if (sub) sub.setAttribute('y', inflowY + 8);
|
|
if (fo) fo.setAttribute('y', inflowY - 11);
|
|
if (unit) unit.setAttribute('y', inflowY + 4);
|
|
}
|
|
|
|
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
|
const deadvol = document.getElementById('ps-deadvol');
|
|
if (deadvol && outflowItem) {
|
|
deadvol.setAttribute('y', outflowItem.y);
|
|
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
|
}
|
|
|
|
// SVG labels — keep them short, side panel shows the numeric value.
|
|
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
|
if (dryLbl) dryLbl.textContent = 'dryRunLevel';
|
|
const highLbl = document.getElementById('ps-label-highVolumeSafetyLevel');
|
|
if (highLbl) highLbl.textContent = 'highVolumeSafety';
|
|
|
|
// Side-panel read-only displays — number only ("m" is shown in the unit span).
|
|
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
|
const d1 = document.getElementById('derived-dryRunLevel');
|
|
if (d1) d1.textContent = fmt(dryLvl);
|
|
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
|
|
if (d2) d2.textContent = fmt(highLvl);
|
|
|
|
// Hierarchy validation. Soft '≤' relations follow the user's choice:
|
|
// start ≤ inflow, max ≤ overflow, overflow ≤ basinHeight (equality OK).
|
|
// dryRunLevel must be < startLevel strictly (otherwise the runtime
|
|
// would trip dry-run before it could ramp).
|
|
// Re-read the raw value (basinH falls back to 5 for diagram scaling;
|
|
// here we want null when the user hasn't entered anything so the
|
|
// ≤-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');
|
|
const issues = [];
|
|
const ok = (a, b, op) => {
|
|
if (!Number.isFinite(a) || !Number.isFinite(b)) return true;
|
|
return op === '<' ? a < b : a <= b;
|
|
};
|
|
if (Number.isFinite(refLow) && refLow <= 0)
|
|
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, 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, '<='))
|
|
issues.push('maxLevel must be ≤ overflowLevel');
|
|
if (!ok(ovfl, basinHraw, '<='))
|
|
issues.push('overflowLevel must be ≤ basinHeight');
|
|
|
|
// Visible ribbon above the basin diagram.
|
|
const warnDiv = document.getElementById('ps-basin-validation');
|
|
if (warnDiv) {
|
|
if (issues.length) {
|
|
warnDiv.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
|
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
|
warnDiv.style.display = '';
|
|
} else {
|
|
warnDiv.style.display = 'none';
|
|
}
|
|
}
|
|
// Legacy in-SVG warning text — kept for the small reminder inside
|
|
// the diagram. Only shows the count.
|
|
const warn = document.getElementById('ps-warning');
|
|
if (warn) {
|
|
if (issues.length) {
|
|
warn.setAttribute('visibility', 'visible');
|
|
warn.textContent = `⚠ ${issues.length} ordering issue${issues.length > 1 ? 's' : ''}`;
|
|
} else {
|
|
warn.setAttribute('visibility', 'hidden');
|
|
}
|
|
}
|
|
window._psBasinValidationIssues = issues;
|
|
},
|
|
};
|
|
})();
|