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>
129 lines
6.1 KiB
JavaScript
129 lines
6.1 KiB
JavaScript
// PumpingStation editor — oneditprepare entry. Wires up form-field
|
|
// initialization, control-mode toggle, safety toggles, and binds
|
|
// redraws for the basin diagram + level-based mode preview.
|
|
|
|
(function () {
|
|
const ns = window.PSEditor = window.PSEditor || {};
|
|
|
|
ns.oneditprepare = function () {
|
|
const node = this;
|
|
|
|
// Wait for menu data (asset/logger/position dropdowns) before init.
|
|
const waitForMenuData = () => {
|
|
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
|
window.EVOLV.nodes.pumpingStation.initEditor(node);
|
|
} else {
|
|
setTimeout(waitForMenuData, 50);
|
|
}
|
|
};
|
|
waitForMenuData();
|
|
|
|
const refHeightEl = document.getElementById('node-input-refHeight');
|
|
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
|
|
|
|
// Safety toggle pairs — each toggle enables/disables its threshold input.
|
|
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
|
|
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
|
|
const highVolumeToggle = document.getElementById('node-input-enableHighVolumeSafety');
|
|
const highVolumePercent = document.getElementById('node-input-highVolumeSafetyThresholdPercent');
|
|
|
|
const toggleInput = (toggleEl, inputEl) => {
|
|
if (!toggleEl || !inputEl) return;
|
|
inputEl.disabled = !toggleEl.checked;
|
|
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
|
};
|
|
|
|
if (dryRunToggle && dryRunPercent) {
|
|
dryRunToggle.checked = !!node.enableDryRunProtection;
|
|
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
|
|
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
|
toggleInput(dryRunToggle, dryRunPercent);
|
|
}
|
|
|
|
if (highVolumeToggle && highVolumePercent) {
|
|
highVolumeToggle.checked = node.enableHighVolumeSafety !== undefined
|
|
? !!node.enableHighVolumeSafety
|
|
: !!node.enableOverfillProtection;
|
|
const highVolumePct = node.highVolumeSafetyThresholdPercent ?? node.overfillThresholdPercent;
|
|
highVolumePercent.value = Number.isFinite(highVolumePct) ? highVolumePct : 98;
|
|
highVolumeToggle.addEventListener('change', () => toggleInput(highVolumeToggle, highVolumePercent));
|
|
toggleInput(highVolumeToggle, highVolumePercent);
|
|
}
|
|
|
|
// Control-mode section toggle (levelbased / manual).
|
|
const toggleModeSections = (val) => {
|
|
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
|
const active = document.getElementById(`ps-mode-${val}`);
|
|
if (active) active.style.display = '';
|
|
};
|
|
const modeSelect = document.getElementById('node-input-controlMode');
|
|
if (modeSelect) {
|
|
modeSelect.value = node.controlMode === 'manual' ? 'manual' : 'levelbased';
|
|
toggleModeSections(modeSelect.value);
|
|
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
|
}
|
|
|
|
// 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);
|
|
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
|
|
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
|
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
|
|
|
|
const curveSelect = document.getElementById('node-input-levelCurveType');
|
|
if (curveSelect) curveSelect.value = node.levelCurveType || node.curveType || 'linear';
|
|
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
|
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
|
|
|
// 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', 'stopLevel', 'holdLevel', 'maxLevel',
|
|
'inflowLevel', 'outflowLevel', 'overflowLevel',
|
|
'dryRunThresholdPercent',
|
|
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
|
'shiftArmPercent'],
|
|
ns.modePreview.redraw
|
|
);
|
|
|
|
// Whenever any level/percent input changes, refresh the bounds first
|
|
// so the next redraw + validation sees the correct min/max attrs.
|
|
ns.bindRedraw(
|
|
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
|
'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel',
|
|
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
|
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
|
() => ns.bounds?.apply()
|
|
);
|
|
|
|
// Initial render + hover-couple wiring once the DOM is settled.
|
|
setTimeout(() => {
|
|
ns.bounds?.apply();
|
|
ns.basinDiagram.redraw();
|
|
ns.modePreview.redraw();
|
|
ns.hoverCouple?.init();
|
|
}, 60);
|
|
};
|
|
})();
|