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:
@@ -7,7 +7,9 @@
|
||||
// through the dead band [stopLevel, startLevel] emitting a small
|
||||
// keep-alive demand so MGC keeps a single pump draining the basin.
|
||||
// 3. Up-curve mapping — level mapped to demand 0..100 % across
|
||||
// [inflowLevel, maxLevel] using linear or log shape.
|
||||
// [max(startLevel, inflowLevel), maxLevel] using linear or log shape.
|
||||
// Foot at startLevel when startLevel > inflowLevel allows buffering
|
||||
// in the upstream sewer above the gravity-feed point.
|
||||
// 4. Shifted-ramp hysteresis — when the up-curve crosses
|
||||
// shiftArmPercent the strategy ARMS; on the next filling→draining
|
||||
// flip it captures the up-curve value as `hold`; while draining
|
||||
@@ -45,13 +47,21 @@ function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
|
||||
|
||||
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||
await Promise.all(
|
||||
Object.values(machineGroups).map((group) =>
|
||||
group.handleInput('parent', percentControl).catch((err) => {
|
||||
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
// The caller (run() below) already gated turn-off via the minLevel
|
||||
// hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
|
||||
// By the time we get here, pumps should be running — `0 %` is the engaged
|
||||
// "min flow" floor (MGC.setDemand interpolates 0 → dt.flow.min), NOT a
|
||||
// soft turn-off. Forward unconditionally.
|
||||
const forward = (group) => {
|
||||
if (typeof group.setDemand !== 'function') {
|
||||
logger?.error?.(`Group "${group.config?.general?.name}" missing setDemand — refusing to call handleInput with a percent value`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve(group.setDemand(percentControl, '%')).catch((err) => {
|
||||
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err && err.message}`);
|
||||
});
|
||||
};
|
||||
await Promise.all(Object.values(machineGroups).map(forward));
|
||||
}
|
||||
|
||||
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
||||
@@ -118,6 +128,8 @@ async function run(ctx, controlState, direction) {
|
||||
controlState.percControl = 0;
|
||||
if (host) {
|
||||
host._stopHystRunning = false;
|
||||
host._shiftArmed = false;
|
||||
host._shiftHoldValue = null;
|
||||
host._lastDirection = direction;
|
||||
}
|
||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||
@@ -131,13 +143,38 @@ async function run(ctx, controlState, direction) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Up-curve mapping. Foot stays at inflowLevel (the basin's
|
||||
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
|
||||
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
|
||||
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
|
||||
// 3. Engagement gate. Pumps stay OFF until level rises through startLevel
|
||||
// for the first time (rising-edge); once engaged they stay on until
|
||||
// level drops through stopLevel (falling-edge — handled by case 2).
|
||||
// Without an explicit stopLevel the gate collapses to `level >= startLevel`.
|
||||
// Moved out of the percentControl path so 0 % can mean "engaged at
|
||||
// min flow" instead of "stopped". Disengagement also clears the
|
||||
// shifted-ramp hysteresis so it doesn't survive a stop/start cycle.
|
||||
const isEngaged = host ? host._stopHystRunning : (level >= startLevel);
|
||||
if (!isEngaged) {
|
||||
controlState.percControl = 0;
|
||||
if (host) {
|
||||
host._shiftArmed = false;
|
||||
host._shiftHoldValue = null;
|
||||
host._lastDirection = direction;
|
||||
}
|
||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Up-curve mapping. Foot = holdLevel (defaults to startLevel; operators
|
||||
// can raise it to introduce a hold band [startLevel, holdLevel] where
|
||||
// pumps run at min flow before the ramp begins). `inflowLevel` does NOT
|
||||
// shape the curve — it's basin geometry, not a control setpoint.
|
||||
// Explicit null/undefined check first so `Number(null) === 0` doesn't
|
||||
// silently put the ramp foot at the basin floor.
|
||||
const rawHold = cfg.holdLevel;
|
||||
const holdLevel = (rawHold != null && Number.isFinite(Number(rawHold)))
|
||||
? Number(rawHold) : startLevel;
|
||||
const rampFoot = Math.max(startLevel, holdLevel);
|
||||
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
||||
|
||||
// 4. Shifted-ramp arming.
|
||||
// 5. Shifted-ramp arming.
|
||||
if (host) {
|
||||
if (cfg.enableShiftedRamp) {
|
||||
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||
@@ -177,10 +214,14 @@ async function run(ctx, controlState, direction) {
|
||||
let percControl;
|
||||
if (!inDrainingHold) {
|
||||
if (level < rampFoot) {
|
||||
// While engaged via stopLevel hysteresis AND inside the dead band
|
||||
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
|
||||
// single pump running.
|
||||
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
|
||||
// Engaged (we passed the gate above) but below the ramp foot. Two
|
||||
// sub-cases:
|
||||
// (a) Inside the configurable hold band [startLevel, holdLevel] —
|
||||
// emit 0 %, which MGC's setDemand interpolates to flow.min.
|
||||
// (b) Inside the falling-edge keep-alive band [stopLevel, startLevel]
|
||||
// — emit deadZoneKeepAlivePercent (default 1 %) so MGC keeps
|
||||
// at least one pump turning rather than dispatching a clean min.
|
||||
if (stopThresholdActive && level < startLevel) {
|
||||
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
||||
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
||||
percControl = Math.max(0, keepAlive);
|
||||
|
||||
Reference in New Issue
Block a user