Compare commits
4 Commits
b59d8e60f7
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18f3cc673 | ||
|
|
2af6c904da | ||
|
|
f41e319b30 | ||
|
|
551ee6d70e |
@@ -1239,7 +1239,7 @@
|
|||||||
"z": "tab_mgc_dash",
|
"z": "tab_mgc_dash",
|
||||||
"g": "grp_status_panel",
|
"g": "grp_status_panel",
|
||||||
"name": "chart: Pump A",
|
"name": "chart: Pump A",
|
||||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\nconst flowMsg = (flow == null) ? null : { topic: 'Pump A', payload: Number(flow) };\nconst ctrlMsg = (ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump A', payload: +ctrl };\nreturn [flowMsg, ctrlMsg];\n",
|
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump A', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump A', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump A', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
|
||||||
"outputs": 2,
|
"outputs": 2,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -1263,7 +1263,7 @@
|
|||||||
"z": "tab_mgc_dash",
|
"z": "tab_mgc_dash",
|
||||||
"g": "grp_status_panel",
|
"g": "grp_status_panel",
|
||||||
"name": "chart: Pump B",
|
"name": "chart: Pump B",
|
||||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\nconst flowMsg = (flow == null) ? null : { topic: 'Pump B', payload: Number(flow) };\nconst ctrlMsg = (ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump B', payload: +ctrl };\nreturn [flowMsg, ctrlMsg];\n",
|
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump B', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump B', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump B', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
|
||||||
"outputs": 2,
|
"outputs": 2,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -1287,7 +1287,7 @@
|
|||||||
"z": "tab_mgc_dash",
|
"z": "tab_mgc_dash",
|
||||||
"g": "grp_status_panel",
|
"g": "grp_status_panel",
|
||||||
"name": "chart: Pump C",
|
"name": "chart: Pump C",
|
||||||
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\nconst flowMsg = (flow == null) ? null : { topic: 'Pump C', payload: Number(flow) };\nconst ctrlMsg = (ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump C', payload: +ctrl };\nreturn [flowMsg, ctrlMsg];\n",
|
"func": "// Cache the pump's delta-compressed port 0 payload so we always know the\n// last reported flow even when the current msg only contains other fields.\nconst cache = context.get('c') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('c', cache);\nfunction find(prefix) {\n for (const k in cache) { if (k.indexOf(prefix) === 0) return cache[k]; }\n return null;\n}\n// Pump's downstream predicted flow. 4-segment key per generalFunctions'\n// MeasurementContainer convention (type.variant.position.childId).\nconst flow = find('flow.predicted.downstream.');\n// Pump's commanded control %. rotatingMachine emits top-level `ctrl` from\n// state.getCurrentPosition() \u2014 see rotatingMachine/src/io/output.js.\nconst ctrl = cache.ctrl;\n// OFF sentinel: off/idle/maintenance pumps are not running, so plot -1 (below the\n// 0-100 band) instead of a residual ctrl% -- a clear OFF rail, distinct from a\n// pump running at 0%. State comes from the cached pump Port 0 state field.\nconst offState = (cache.state === 'off' || cache.state === 'idle' || cache.state === 'maintenance');\nconst flowMsg = (flow == null) ? null : { topic: 'Pump C', payload: Number(flow) };\nconst ctrlMsg = offState ? { topic: 'Pump C', payload: -1 } : ((ctrl == null || !Number.isFinite(+ctrl)) ? null : { topic: 'Pump C', payload: +ctrl });\nreturn [flowMsg, ctrlMsg];\n",
|
||||||
"outputs": 2,
|
"outputs": 2,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -1850,7 +1850,7 @@
|
|||||||
"yAxisLabel": "%",
|
"yAxisLabel": "%",
|
||||||
"yAxisProperty": "payload",
|
"yAxisProperty": "payload",
|
||||||
"yAxisPropertyType": "msg",
|
"yAxisPropertyType": "msg",
|
||||||
"ymin": "0",
|
"ymin": "-5",
|
||||||
"ymax": "100",
|
"ymax": "100",
|
||||||
"bins": 10,
|
"bins": 10,
|
||||||
"action": "append",
|
"action": "append",
|
||||||
|
|||||||
@@ -14,11 +14,16 @@
|
|||||||
// (stopping / coolingdown / unknown) are skipped.
|
// (stopping / coolingdown / unknown) are skipped.
|
||||||
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
|
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
|
||||||
// slowest move (typically a startup ladder + ramp) sets the deadline.
|
// slowest move (typically a startup ladder + ramp) sets the deadline.
|
||||||
// 4. Every command is delayed by (t* − eta_j) so it FINISHES at t*.
|
// 4. Every command — including a startup's `execsequence` — is delayed by
|
||||||
// Exception: a startup's `execsequence` command must fire NOW so the
|
// (t* − eta_j) so its move FINISHES at t*. A startup is delayed as a
|
||||||
// ladder can begin — its own duration is what defines eta and thus
|
// whole: its ladder begins at (t* − eta) and completes at (t* − rampS),
|
||||||
// t* — but the startup's queued flowmovement (held in the pump's
|
// then the queued flowmovement (held in the pump's delayedMove) ramps to
|
||||||
// delayedMove) lands at t* by construction.
|
// finish at t*. The slowest mover (t* − eta == 0) fires immediately.
|
||||||
|
// Delaying the ladder — rather than firing it at tick 0 — is what keeps a
|
||||||
|
// faster-than-slowest startup from reaching `operational` early and
|
||||||
|
// sitting at its MINIMUM flow before t* (calcFlow at min position is not
|
||||||
|
// zero), which otherwise leaks ~min-flow into the group total ahead of
|
||||||
|
// the rendezvous (the staging bump).
|
||||||
//
|
//
|
||||||
// Net effect: ALL pumps reach their per-pump flow target at the same
|
// Net effect: ALL pumps reach their per-pump flow target at the same
|
||||||
// wall-clock instant t*. Sum-of-flows is monotonic during the transition
|
// wall-clock instant t*. Sum-of-flows is monotonic during the transition
|
||||||
@@ -177,38 +182,31 @@ function plan(profiles, combination, currentPressure, options = {}) {
|
|||||||
const isUnchanged = q.direction === 'unchanged';
|
const isUnchanged = q.direction === 'unchanged';
|
||||||
|
|
||||||
if (q.action === 'startup') {
|
if (q.action === 'startup') {
|
||||||
// execsequence MUST begin NOW — the ladder duration is
|
// Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
|
||||||
// baked into eta and can't be compressed.
|
// by (t* − eta), so the warmup ladder finishes (and the ramp
|
||||||
|
// begins) at (t* − rampS) and the flow lands exactly at t*.
|
||||||
|
//
|
||||||
|
// The ladder duration can't be compressed, but it CAN be delayed.
|
||||||
|
// Firing the execsequence at tick 0 (the old behaviour) made a
|
||||||
|
// faster-than-slowest startup reach `operational` early and sit at
|
||||||
|
// its minimum flow from warmup-end until its delayed ramp — leaking
|
||||||
|
// ~min-flow into the group total before t* (the staging bump). For
|
||||||
|
// the slowest pump (eta == t*) fireAtTickNDelayed is 0, so it still
|
||||||
|
// fires immediately. The flowmovement fires on the same tick; the
|
||||||
|
// pump holds it in delayedMove through the ladder, then ramps over
|
||||||
|
// rampS to finish at t*.
|
||||||
commands.push({
|
commands.push({
|
||||||
machineId: q.machineId,
|
machineId: q.machineId,
|
||||||
action: 'execsequence',
|
action: 'execsequence',
|
||||||
sequence: 'startup',
|
sequence: 'startup',
|
||||||
fireAtTickN: 0,
|
fireAtTickN: fireAtTickNDelayed,
|
||||||
eta: q.eta,
|
eta: q.eta,
|
||||||
});
|
});
|
||||||
// flowmovement timing.
|
|
||||||
//
|
|
||||||
// Default behaviour: queue it at tick 0; the pump's
|
|
||||||
// delayedMove holds it until warmup completes, after which
|
|
||||||
// the pump ramps at its own velocity. That ramp finishes at
|
|
||||||
// ladderS + rampS = eta. For a single pump (eta == tStar)
|
|
||||||
// this naturally lands at tStar — no extra delay needed.
|
|
||||||
//
|
|
||||||
// Mixed-speed multi-startup: if this pump is FASTER than
|
|
||||||
// the slowest one, its natural landing (at its own eta)
|
|
||||||
// is EARLIER than tStar. Delay the flowmovement so the
|
|
||||||
// ramp starts at (tStar − rampS), making the ramp finish
|
|
||||||
// at tStar regardless of per-pump speed.
|
|
||||||
const naturalRampStartS = q.ladderS;
|
|
||||||
const rendezvousRampStartS = tStar - q.rampS;
|
|
||||||
const flowMoveFireAtS = rendezvousRampStartS > naturalRampStartS
|
|
||||||
? rendezvousRampStartS
|
|
||||||
: 0;
|
|
||||||
commands.push({
|
commands.push({
|
||||||
machineId: q.machineId,
|
machineId: q.machineId,
|
||||||
action: 'flowmovement',
|
action: 'flowmovement',
|
||||||
flow: q.targetFlow,
|
flow: q.targetFlow,
|
||||||
fireAtTickN: Math.max(0, Math.round(flowMoveFireAtS / tickS)),
|
fireAtTickN: fireAtTickNDelayed,
|
||||||
eta: q.eta,
|
eta: q.eta,
|
||||||
});
|
});
|
||||||
} else if (q.action === 'flowmove') {
|
} else if (q.action === 'flowmove') {
|
||||||
|
|||||||
@@ -78,11 +78,16 @@ class MachineGroup extends BaseDomain {
|
|||||||
// Demand held by the movement gate while the group is 'working'. Latest
|
// Demand held by the movement gate while the group is 'working'. Latest
|
||||||
// wins; flushed by _maybeFlushPendingDemand once the group is 'ready'.
|
// wins; flushed by _maybeFlushPendingDemand once the group is 'ready'.
|
||||||
this._pendingDemand = null;
|
this._pendingDemand = null;
|
||||||
// Intent of the last dispatch that actually proceeded — used by the
|
// Intent of the last dispatch that actually proceeded — recorded so a
|
||||||
// movement gate to treat a mode/priority change as urgent (a new
|
// pressure-emergency re-dispatch can re-plan the SAME intent against
|
||||||
// intent), not a hold-worthy nudge.
|
// the new envelope without inventing a setpoint.
|
||||||
this._lastDispatchedMode = null;
|
this._lastDispatchedMode = null;
|
||||||
this._lastPriorityKey = JSON.stringify(null);
|
this._lastPriorityKey = JSON.stringify(null);
|
||||||
|
this._lastPriorityList = null;
|
||||||
|
// Pressure-emergency latch. Set when handlePressureChange fires a
|
||||||
|
// bypass dispatch; cleared once pressure falls back below threshold,
|
||||||
|
// so the (several-times-a-second) handler doesn't re-fire every tick.
|
||||||
|
this._emergencyLatched = false;
|
||||||
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
|
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
|
||||||
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
|
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ class MachineGroup extends BaseDomain {
|
|||||||
// call that is later superseded resolves with { superseded: true }.
|
// call that is later superseded resolves with { superseded: true }.
|
||||||
this._demandDispatcher = new DemandDispatcher(
|
this._demandDispatcher = new DemandDispatcher(
|
||||||
{ logger: this.logger },
|
{ logger: this.logger },
|
||||||
(payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList),
|
(payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList, { emergency: payload.emergency === true }),
|
||||||
);
|
);
|
||||||
this._shutdownInFlight = new Set();
|
this._shutdownInFlight = new Set();
|
||||||
|
|
||||||
@@ -233,7 +238,27 @@ class MachineGroup extends BaseDomain {
|
|||||||
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null;
|
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null;
|
||||||
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
|
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
|
||||||
this.notifyOutputChanged();
|
this.notifyOutputChanged();
|
||||||
// Group may have just settled — release any demand the gate is holding.
|
// Emergency bypass: a pressure excursion pre-empts the rendezvous lock
|
||||||
|
// and re-plans the last intent against the new envelope immediately.
|
||||||
|
// Inert until planner.emergencyPressurePa is configured (see
|
||||||
|
// _pressureEmergency). Latched so we fire once per excursion, not every
|
||||||
|
// tick; the latch clears when pressure falls back below threshold.
|
||||||
|
if (this._pressureEmergency()) {
|
||||||
|
if (!this._emergencyLatched && Number.isFinite(this._lastDemand?.canonical)) {
|
||||||
|
this._emergencyLatched = true;
|
||||||
|
this.logger.warn(`Pressure emergency — pre-empting rendezvous, re-planning last demand ${this._lastDemand.canonical.toFixed(3)}.`);
|
||||||
|
Promise.resolve(this._demandDispatcher.fireAndWait({
|
||||||
|
source: 'pressure-emergency',
|
||||||
|
demand: this._lastDemand.canonical,
|
||||||
|
powerCap: Infinity,
|
||||||
|
priorityList: this._lastPriorityList,
|
||||||
|
emergency: true,
|
||||||
|
})).catch((e) => this.logger?.error?.(`emergency dispatch failed: ${e?.message || e}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._emergencyLatched = false;
|
||||||
|
}
|
||||||
|
// Group may have just settled — release any demand the lock is holding.
|
||||||
this._maybeFlushPendingDemand();
|
this._maybeFlushPendingDemand();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,25 +287,34 @@ class MachineGroup extends BaseDomain {
|
|||||||
return 'ready';
|
return 'ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is this demand urgent enough to pre-empt an in-flight group movement?
|
// May this demand pre-empt an in-flight rendezvous? Only an EMERGENCY may —
|
||||||
// • a stop (≤0) is always urgent — never make the operator wait to stop;
|
// a committed rendezvous is otherwise locked, and ordinary new setpoints
|
||||||
// • the first demand (no prior) dispatches immediately;
|
// (any size, mode/priority changes included) are deferred and dispatched
|
||||||
// • a control-mode switch or a changed priority order is a new intent,
|
// sequentially once the group is 'ready' (_maybeFlushPendingDemand). This
|
||||||
// not a nudge — dispatch it now rather than holding it;
|
// is what stops a re-plan from re-deferring a pump that's mid-sequence
|
||||||
// • otherwise a step larger than `planner.urgentDemandFraction` of the
|
// (which parked starting pumps at minimum flow → the staging bump).
|
||||||
// capacity envelope (default 25%) pre-empts; smaller nudges wait for
|
// • a stop (≤0) is always an emergency — never make the operator wait;
|
||||||
// the group to be 'ready' so they don't thrash the current ramp.
|
// • the first demand (no prior intent) must proceed or nothing ever runs;
|
||||||
_isUrgentDemand(demandQ, priorityList) {
|
// • a pressure excursion (opts.emergency, raised by handlePressureChange)
|
||||||
|
// pre-empts so rising discharge pressure is actioned immediately.
|
||||||
|
// Everything else returns false → defer.
|
||||||
|
_isEmergencyDemand(demandQ, opts = {}) {
|
||||||
if (!(demandQ > 0)) return true;
|
if (!(demandQ > 0)) return true;
|
||||||
if (this._lastDemand?.canonical == null) return true;
|
if (this._lastDemand?.canonical == null) return true;
|
||||||
if (this.mode !== this._lastDispatchedMode) return true;
|
return opts.emergency === true;
|
||||||
if (JSON.stringify(priorityList ?? null) !== this._lastPriorityKey) return true;
|
}
|
||||||
const dt = (typeof this.calcDynamicTotals === 'function' ? this.calcDynamicTotals() : this.dynamicTotals) || {};
|
|
||||||
const span = Number(dt?.flow?.max) || 0;
|
// Pressure-excursion detector for the emergency bypass. Returns true when
|
||||||
if (span <= 0) return true;
|
// the resolved header pressure breaches a configured safety threshold.
|
||||||
const frac = Math.abs(demandQ - this._lastDemand.canonical) / span;
|
// INERT BY DEFAULT: with no `planner.emergencyPressurePa` set, this always
|
||||||
const thr = Number(this.config?.planner?.urgentDemandFraction);
|
// returns false — the bypass mechanism is wired and tested but never fires
|
||||||
return frac >= (Number.isFinite(thr) ? thr : 0.25);
|
// until a real threshold is configured. (Rate-of-rise can be added here
|
||||||
|
// later behind its own config key without touching the call sites.)
|
||||||
|
_pressureEmergency() {
|
||||||
|
const absPa = Number(this.config?.planner?.emergencyPressurePa);
|
||||||
|
if (!Number.isFinite(absPa) || absPa <= 0) return false;
|
||||||
|
const p = this.operatingPoint?.headerDiffPa;
|
||||||
|
return Number.isFinite(p) && p >= absPa;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch a demand held by the movement gate, once the group has settled.
|
// Dispatch a demand held by the movement gate, once the group has settled.
|
||||||
@@ -474,7 +508,7 @@ class MachineGroup extends BaseDomain {
|
|||||||
return this.handleInput('parent', canonical);
|
return this.handleInput('parent', canonical);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _runDispatch(source, demand, powerCap, priorityList) {
|
async _runDispatch(source, demand, powerCap, priorityList, opts = {}) {
|
||||||
const demandQ = parseFloat(demand);
|
const demandQ = parseFloat(demand);
|
||||||
if (!Number.isFinite(demandQ)) {
|
if (!Number.isFinite(demandQ)) {
|
||||||
this.logger.error(`Invalid flow demand input: ${demand}.`);
|
this.logger.error(`Invalid flow demand input: ${demand}.`);
|
||||||
@@ -485,24 +519,25 @@ class MachineGroup extends BaseDomain {
|
|||||||
// keep a defensive check in case turnOff-state arrives some other way.
|
// keep a defensive check in case turnOff-state arrives some other way.
|
||||||
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
|
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
|
||||||
|
|
||||||
// Movement gate. If the group is still converging on its previous
|
// Rendezvous lock. While the group is still converging on its committed
|
||||||
// intent ('working') and this demand is NOT urgent, hold it instead of
|
// plan ('working'), an ordinary new setpoint is NOT applied — it is
|
||||||
// aborting the in-flight ramps. The held demand (latest wins) is
|
// remembered (latest wins) and dispatched sequentially once the group
|
||||||
// dispatched the moment the group reports 'ready'
|
// reports 'ready' (_maybeFlushPendingDemand, off handlePressureChange).
|
||||||
// (_maybeFlushPendingDemand, off handlePressureChange). This is what
|
// This keeps a re-plan from dropping the in-flight schedule and
|
||||||
// stops a fast-re-commanding parent from freezing pumps at 0 by
|
// re-deferring a pump that's mid-sequence — which parked starting pumps
|
||||||
// aborting every ramp before it can progress. Urgent demand (shutdown,
|
// at minimum flow (the staging bump). Only an EMERGENCY (stop, or a
|
||||||
// or a large step) still pre-empts and dispatches immediately.
|
// pressure excursion flagged via opts.emergency) pre-empts.
|
||||||
if (this.getMovementState() === 'working' && !this._isUrgentDemand(demandQ, priorityList)) {
|
if (this.getMovementState() === 'working' && !this._isEmergencyDemand(demandQ, opts)) {
|
||||||
this._pendingDemand = { source, demand: demandQ, powerCap, priorityList };
|
this._pendingDemand = { source, demand: demandQ, powerCap, priorityList };
|
||||||
this.logger.debug(`Demand ${demandQ.toFixed(3)} held — group 'working'; will dispatch when 'ready'.`);
|
this.logger.debug(`Demand ${demandQ.toFixed(3)} held — rendezvous locked ('working'); will dispatch when 'ready'.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._pendingDemand = null;
|
this._pendingDemand = null;
|
||||||
// Record the intent now driving the group, so a later same-magnitude
|
// Record the intent now driving the group, so a pressure-emergency
|
||||||
// demand in the same mode/priority is correctly seen as a nudge.
|
// re-dispatch can re-plan the same intent against the new envelope.
|
||||||
this._lastDispatchedMode = this.mode;
|
this._lastDispatchedMode = this.mode;
|
||||||
this._lastPriorityKey = JSON.stringify(priorityList ?? null);
|
this._lastPriorityKey = JSON.stringify(priorityList ?? null);
|
||||||
|
this._lastPriorityList = priorityList ?? null;
|
||||||
|
|
||||||
await this.abortActiveMovements('new demand received');
|
await this.abortActiveMovements('new demand received');
|
||||||
const dt = this.calcDynamicTotals();
|
const dt = this.calcDynamicTotals();
|
||||||
|
|||||||
@@ -112,6 +112,53 @@ Documented in `CONTRACT.md`; tested indirectly via `group-bep-cascade.integratio
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Example flow fan-out — `examples/02-Dashboard.json :: fn_status_split` (outputs: 18)
|
||||||
|
|
||||||
|
Delta-caches Port 0 then fans one msg per dashboard widget. Charts return the
|
||||||
|
whole msg as `null` (drop the output) when their source is missing — never
|
||||||
|
`{ payload: null }`. All ports covered by `test/integration/dashboard-fanout.integration.test.js`.
|
||||||
|
|
||||||
|
| # | Target widget | Topic / payload | Populated | Degraded (missing source) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0 | ui_txt_mode | string | ✔ State C | ✔ State A → mode string |
|
||||||
|
| 1 | ui_txt_flow | `'… m³/h'` | ✔ | ✔ State A → `—` |
|
||||||
|
| 2 | ui_txt_power | `'… kW'` | ✔ | ✔ → `—` |
|
||||||
|
| 3 | ui_txt_capacity | `'min – max m³/h'` | ✔ State B | ✔ → `—` |
|
||||||
|
| 4 | ui_txt_machines | `'nAct / nTot'` | ✔ | ✔ → `—` |
|
||||||
|
| 5 | ui_txt_bep (rel%) | `'… %'` | ✔ | ✔ null/undefined → `—` |
|
||||||
|
| 6 | ui_txt_eta | `'… %'` | ✔ | ✔ → `—` |
|
||||||
|
| 7 | ui_txt_eta_peak | `'… %'` | ✔ | ✔ → `—` |
|
||||||
|
| 8 | ui_txt_bep_abs | `'…'` (η pts, 3dp) | ✔ | ✔ → `—` |
|
||||||
|
| 9 | ui_txt_ncog | `'… %'` (sum/nAct) | ✔ | ✔ nAct=0/missing → `—` |
|
||||||
|
| 10 | ui_chart_flow | `{topic:'Flow', payload:number}` | ✔ | ✔ → null (drop) |
|
||||||
|
| 11 | ui_chart_flow (capacity) | `{topic:'Capacity', …}` | ✔ | ✔ → null |
|
||||||
|
| 12 | ui_chart_power | `{topic:'Power', …}` | ✔ | ✔ → null |
|
||||||
|
| 13 | ui_chart_bep | `{topic:'BEP rel %', ×100}` | ✔ | ✔ → null |
|
||||||
|
| 14 | ui_chart_eta | `{topic:'η (%)', ×100}` | ✔ | ✔ → null |
|
||||||
|
| 15 | ui_tpl_raw | `[{key,value}]` rows | ✔ | ✔ |
|
||||||
|
| 16 | ui_chart_qh (passthrough) | raw `msg.payload` | ✔ | ✔ |
|
||||||
|
| 17 | ui_chart_mgc_pctcap | `{topic:'% of capacity', payload:flow/capMax×100}` | ✔ State C | ✔ State A → null (drop) |
|
||||||
|
|
||||||
|
## Example flow fan-out — `examples/02-Dashboard.json :: fn_chart_pump_a/b/c` (outputs: 2 each)
|
||||||
|
|
||||||
|
Each per-pump fan-out delta-caches the pump's Port 0 then emits two chart msgs.
|
||||||
|
The ctrl output carries a **-1 OFF sentinel**: when the cached pump `state` is
|
||||||
|
`off` / `idle` / `maintenance` the pump is not running, so it plots `-1` (below
|
||||||
|
the 0–100 band) — a clear OFF rail distinct from a pump genuinely running at 0%.
|
||||||
|
`ui_chart_pumps_ctrl` has `ymin: "-5"` so the sentinel is visible. Charts return
|
||||||
|
the whole msg as `null` (drop the output) when their source is missing — never
|
||||||
|
`{ payload: null }`. All ports covered by
|
||||||
|
`test/integration/per-pump-ctrl-fanout.integration.test.js`.
|
||||||
|
|
||||||
|
| # | Target chart | Topic / payload | Populated | Degraded |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0 | ui_chart_per_pump_flow | `{topic:'Pump A/B/C', payload:flow m³/h}` | ✔ running state | ✔ no `flow.predicted.downstream.*` key → null (drop) |
|
||||||
|
| 1 | ui_chart_pumps_ctrl | `{topic:'Pump A/B/C', payload:ctrl%}`, or `payload:-1` when state ∈ {off,idle,maintenance} | ✔ running → +ctrl; ✔ off/idle/maintenance → -1 | ✔ no state + ctrl missing/NaN/null → null (drop); ✔ ctrl-only delta keeps cached OFF state |
|
||||||
|
|
||||||
|
`fn_chart_total` (outputs: 1) feeds the same flow chart with the group total
|
||||||
|
(`downstream_predicted_flow ?? atEquipment_predicted_flow`); returns `null` when
|
||||||
|
both are absent.
|
||||||
|
|
||||||
## Coverage gaps (open items)
|
## Coverage gaps (open items)
|
||||||
|
|
||||||
These are known holes flagged during the 2026-05-14 governance review; not yet
|
These are known holes flagged during the 2026-05-14 governance review; not yet
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Unit tests for the MGC movement state + dispatch-gate helpers
|
// Unit tests for the MGC movement state + rendezvous-lock helpers
|
||||||
// (getMovementState / _isUrgentDemand). Exercised via prototype.call with a
|
// (getMovementState / _isEmergencyDemand / _pressureEmergency). Exercised via
|
||||||
|
// prototype.call with a
|
||||||
// minimal fake `this` so no Node-RED runtime or full MachineGroup boot is
|
// minimal fake `this` so no Node-RED runtime or full MachineGroup boot is
|
||||||
// needed. See project rule .claude/rules/testing.md (basic = pure logic).
|
// needed. See project rule .claude/rules/testing.md (basic = pure logic).
|
||||||
|
|
||||||
@@ -40,38 +41,46 @@ test('movementState: working when the executor still has scheduled commands', ()
|
|||||||
assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working');
|
assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working');
|
||||||
});
|
});
|
||||||
|
|
||||||
function urgent(demandQ, {
|
// Rendezvous lock: only an EMERGENCY pre-empts an in-flight rendezvous; every
|
||||||
mode = 'optimalControl', lastMode = 'optimalControl',
|
// ordinary setpoint (any size, mode/priority change included) defers.
|
||||||
last = 10, priorityList = null, lastPriorityKey = 'null', span = 100, thr,
|
function emergency(demandQ, { last = 10, emergency = false } = {}) {
|
||||||
} = {}) {
|
return MachineGroup.prototype._isEmergencyDemand.call({
|
||||||
return MachineGroup.prototype._isUrgentDemand.call({
|
|
||||||
_lastDemand: last == null ? null : { canonical: last },
|
_lastDemand: last == null ? null : { canonical: last },
|
||||||
mode, _lastDispatchedMode: lastMode, _lastPriorityKey: lastPriorityKey,
|
}, demandQ, { emergency });
|
||||||
calcDynamicTotals: () => ({ flow: { max: span } }),
|
|
||||||
config: { planner: thr == null ? {} : { urgentDemandFraction: thr } },
|
|
||||||
}, demandQ, priorityList);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('urgent: a stop (≤0) always pre-empts', () => {
|
test('emergency: a stop (≤0) always pre-empts', () => {
|
||||||
assert.equal(urgent(0), true);
|
assert.equal(emergency(0), true);
|
||||||
assert.equal(urgent(-5), true);
|
assert.equal(emergency(-5), true);
|
||||||
});
|
});
|
||||||
test('urgent: the first demand (no prior) dispatches immediately', () => {
|
test('emergency: the first demand (no prior) dispatches immediately', () => {
|
||||||
assert.equal(urgent(50, { last: null }), true);
|
assert.equal(emergency(50, { last: null }), true);
|
||||||
});
|
});
|
||||||
test('urgent: a control-mode switch is a new intent', () => {
|
test('emergency: an explicit emergency flag pre-empts', () => {
|
||||||
assert.equal(urgent(10, { mode: 'priorityControl', lastMode: 'optimalControl' }), true);
|
assert.equal(emergency(60, { last: 10, emergency: true }), true);
|
||||||
});
|
});
|
||||||
test('urgent: a changed priority order is a new intent', () => {
|
test('emergency: an ordinary same-mode step defers (large or small)', () => {
|
||||||
assert.equal(urgent(10, { priorityList: ['eff', 'std'], lastPriorityKey: 'null' }), true);
|
assert.equal(emergency(12, { last: 10 }), false); // small nudge — defer
|
||||||
|
assert.equal(emergency(60, { last: 10 }), false); // large step — also defers now
|
||||||
});
|
});
|
||||||
test('urgent: a small same-mode nudge is held (not urgent)', () => {
|
|
||||||
assert.equal(urgent(12, { last: 10, span: 100 }), false); // 2% of span < 25%
|
// Pressure-excursion detector — inert until planner.emergencyPressurePa is set.
|
||||||
|
function pressureEmergency({ thr, headerPa } = {}) {
|
||||||
|
return MachineGroup.prototype._pressureEmergency.call({
|
||||||
|
config: { planner: thr == null ? {} : { emergencyPressurePa: thr } },
|
||||||
|
operatingPoint: { headerDiffPa: headerPa },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pressureEmergency: inert (false) when no threshold is configured', () => {
|
||||||
|
assert.equal(pressureEmergency({ headerPa: 999999 }), false);
|
||||||
});
|
});
|
||||||
test('urgent: a large same-mode step pre-empts', () => {
|
test('pressureEmergency: false when header is below the configured threshold', () => {
|
||||||
assert.equal(urgent(60, { last: 10, span: 100 }), true); // 50% of span ≥ 25%
|
assert.equal(pressureEmergency({ thr: 200000, headerPa: 150000 }), false);
|
||||||
});
|
});
|
||||||
test('urgent: threshold is configurable via planner.urgentDemandFraction', () => {
|
test('pressureEmergency: true when header breaches the configured threshold', () => {
|
||||||
assert.equal(urgent(15, { last: 10, span: 100, thr: 0.02 }), true); // 5% ≥ 2%
|
assert.equal(pressureEmergency({ thr: 200000, headerPa: 210000 }), true);
|
||||||
assert.equal(urgent(15, { last: 10, span: 100, thr: 0.5 }), false); // 5% < 50%
|
});
|
||||||
|
test('pressureEmergency: false when header pressure is unknown', () => {
|
||||||
|
assert.equal(pressureEmergency({ thr: 200000, headerPa: undefined }), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,34 +242,29 @@ test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar t
|
|||||||
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
|
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
|
||||||
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
|
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
|
||||||
|
|
||||||
// execsequence fires at 0 for ALL idle pumps (the ladder must start now).
|
// Just-in-time: the WHOLE startup (ladder + ramp) is delayed by (tStar −
|
||||||
|
// eta), so both execsequence and flowmovement fire at the same delayed
|
||||||
|
// tick. eta_A = 30 + 33.33 ≈ 63.33, eta_B = 40, eta_C = 130.
|
||||||
|
// A: round(130 − 63.33) = 67
|
||||||
|
// B: round(130 − 40) = 90
|
||||||
|
// C: round(130 − 130) = 0 (slowest — defines tStar, fires now)
|
||||||
|
const delays = { A: Math.round(130 - (30 + 100 / 3)), B: 90, C: 0 };
|
||||||
for (const id of ['A', 'B', 'C']) {
|
for (const id of ['A', 'B', 'C']) {
|
||||||
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
|
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
|
||||||
|
const flow = out.commands.find((c) => c.machineId === id && c.action === 'flowmovement');
|
||||||
assert.ok(exec, `${id} execsequence present`);
|
assert.ok(exec, `${id} execsequence present`);
|
||||||
assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`);
|
assert.ok(flow, `${id} flowmovement present`);
|
||||||
|
assert.equal(exec.fireAtTickN, delays[id], `${id} ladder delayed to land at tStar`);
|
||||||
|
assert.equal(flow.fireAtTickN, delays[id], `${id} flowmovement fires with the ladder`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// flowmovement gating — each pump's ramp must FINISH at tStar=130.
|
// Sanity: with the ladder delayed, each pump reaches `operational` only at
|
||||||
const flowA = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
|
// (delay + ladderS) and its ramp ends at the same wall-clock instant ≈ 130.
|
||||||
const flowB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
// A: 67 + 30 (op) + 33.33 ≈ 130.33
|
||||||
const flowC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
|
// B: 90 + 30 (op) + 10 = 130
|
||||||
|
// C: 0 + 30 (op) + 100 = 130
|
||||||
// A (medium): rampStart = 130 − 33.33 ≈ 96.67 → fireAtTickN = 97.
|
// No pump sits at `operational` (and minimum flow) before its ramp — that
|
||||||
assert.equal(flowA.fireAtTickN, Math.round(130 - 100 / 3));
|
// early min-flow was the staging bump this just-in-time start removes.
|
||||||
// B (fast): rampStart = 130 − 10 = 120 → fireAtTickN = 120.
|
|
||||||
assert.equal(flowB.fireAtTickN, 120);
|
|
||||||
// C (slow, defines tStar): rendezvousRampStart = 130 − 100 = 30 == ladderS,
|
|
||||||
// so no extra delay needed — fall back to fireAtTickN=0 and let
|
|
||||||
// the pump's delayedMove fire it naturally at warmup-end.
|
|
||||||
assert.equal(flowC.fireAtTickN, 0);
|
|
||||||
|
|
||||||
// Sanity: with these schedules, all three pumps' ramps end at the
|
|
||||||
// same wall-clock instant (within rounding).
|
|
||||||
// A: 97 + 100/3 ≈ 130.33
|
|
||||||
// B: 120 + 10 = 130
|
|
||||||
// C: 30 (delayedMove) + 100 = 130
|
|
||||||
// Max spread ≈ 0.33 s — far better than the per-eta spread of
|
|
||||||
// 130 − 40 = 90 s the planner would produce without this gating.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
|
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
|
||||||
|
|||||||
@@ -48,13 +48,26 @@ async function buildGroupWithPressure() {
|
|||||||
return { mgc, pumps };
|
return { mgc, pumps };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settle to 'ready' between demands. The rendezvous lock defers a new setpoint
|
||||||
|
// that arrives while the group is still 'working', so each sweep step must wait
|
||||||
|
// for the previous move to land before issuing (and reading) the next.
|
||||||
|
async function waitReady(mgc, timeoutMs = 6000) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeoutMs) {
|
||||||
|
if (mgc.getMovementState?.() === 'ready') return true;
|
||||||
|
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
|
||||||
|
await new Promise(r => setTimeout(r, 40));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function sweepDemand(mgc, demands_m3h) {
|
async function sweepDemand(mgc, demands_m3h) {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const Qd_m3h of demands_m3h) {
|
for (const Qd_m3h of demands_m3h) {
|
||||||
const Qd = Qd_m3h / 3600; // m3/h → m3/s
|
const Qd = Qd_m3h / 3600; // m3/h → m3/s
|
||||||
try { await mgc.handleInput('parent', Qd); }
|
try { await mgc.handleInput('parent', Qd); }
|
||||||
catch (e) { /* turnOff or no-combination paths are part of the contract */ }
|
catch (e) { /* turnOff or no-combination paths are part of the contract */ }
|
||||||
await new Promise(r => setTimeout(r, 30));
|
await waitReady(mgc);
|
||||||
const out = getOutput(mgc);
|
const out = getOutput(mgc);
|
||||||
rows.push({
|
rows.push({
|
||||||
demand: Qd_m3h,
|
demand: Qd_m3h,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function runFn(msgs) {
|
|||||||
return msgs.map(msg => fn_body(msg, context));
|
return msgs.map(msg => fn_body(msg, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indices into the 17-output return array. Kept here as the manifest contract
|
// Indices into the 18-output return array. Kept here as the manifest contract
|
||||||
// for this function — every test below references these names, never raw ints.
|
// for this function — every test below references these names, never raw ints.
|
||||||
const PORT = {
|
const PORT = {
|
||||||
text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3,
|
text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3,
|
||||||
@@ -31,6 +31,7 @@ const PORT = {
|
|||||||
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
|
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
|
||||||
chart_eta: 14,
|
chart_eta: 14,
|
||||||
raw_rows: 15, raw_passthrough: 16,
|
raw_rows: 15, raw_passthrough: 16,
|
||||||
|
chart_pctcap: 17,
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialMsg = {
|
const initialMsg = {
|
||||||
@@ -64,9 +65,9 @@ const postDemandMsg = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
test('manifest: function has exactly 17 outputs and wires array matches', () => {
|
test('manifest: function has exactly 18 outputs and wires array matches', () => {
|
||||||
assert.equal(fn.outputs, 17);
|
assert.equal(fn.outputs, 18);
|
||||||
assert.equal(fn.wires.length, 17);
|
assert.equal(fn.wires.length, 18);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
|
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
|
||||||
@@ -113,6 +114,16 @@ test('State C (post-demand): every text/chart output has real value', () => {
|
|||||||
assert.equal(out[PORT.chart_flow].payload, 200);
|
assert.equal(out[PORT.chart_flow].payload, 200);
|
||||||
assert.equal(out[PORT.chart_power].payload, 11.4);
|
assert.equal(out[PORT.chart_power].payload, 11.4);
|
||||||
assert.equal(out[PORT.chart_eta].payload, 62);
|
assert.equal(out[PORT.chart_eta].payload, 62);
|
||||||
|
// % of capacity = flow / flowCapacityMax × 100 = 200 / 450 × 100 ≈ 44.44.
|
||||||
|
assert.equal(out[PORT.chart_pctcap].topic, '% of capacity');
|
||||||
|
assert.ok(Math.abs(out[PORT.chart_pctcap].payload - (200 / 450) * 100) < 1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('% of capacity chart: drops msg when flow or capacity missing (no payload:null)', () => {
|
||||||
|
// State A: no flow + flowCapacityMax=0 → pctCap undefined → chart() returns
|
||||||
|
// null so the function node skips the output, never { payload: null }.
|
||||||
|
const [out] = runFn([initialMsg]);
|
||||||
|
assert.equal(out[PORT.chart_pctcap], null, 'chart_pctcap must drop msg when source missing');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
|
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/h
|
|||||||
|
|
||||||
/* ---- helpers ---- */
|
/* ---- helpers ---- */
|
||||||
|
|
||||||
|
// Settle the group to 'ready'. The rendezvous lock defers a setpoint arriving
|
||||||
|
// while the group is still 'working', so a full-MGC test must wait for each
|
||||||
|
// move to land before reading steady state or issuing the next demand.
|
||||||
|
async function waitReady(mgc, timeoutMs = 6000) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeoutMs) {
|
||||||
|
if (mgc.getMovementState?.() === 'ready') return true;
|
||||||
|
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
|
||||||
|
await new Promise(r => setTimeout(r, 40));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||||
|
|
||||||
function distortSeries(series, scale = 1, tilt = 0) {
|
function distortSeries(series, scale = 1, tilt = 0) {
|
||||||
@@ -414,6 +427,7 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
|||||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||||
}
|
}
|
||||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity);
|
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity);
|
||||||
|
await waitReady(mg); // rendezvous lock — let the move land before reading steady state
|
||||||
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||||
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||||
|
|
||||||
@@ -422,10 +436,12 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
|||||||
await m.handleInput('parent', 'execSequence', 'shutdown');
|
await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
await m.handleInput('parent', 'execSequence', 'startup');
|
await m.handleInput('parent', 'execSequence', 'startup');
|
||||||
}
|
}
|
||||||
|
await waitReady(mg); // ensure the group is settled so the next demand isn't deferred
|
||||||
|
|
||||||
// Run priorityControl
|
// Run priorityControl
|
||||||
mg.setMode('prioritycontrol');
|
mg.setMode('prioritycontrol');
|
||||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']);
|
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']);
|
||||||
|
await waitReady(mg);
|
||||||
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||||
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||||
|
|
||||||
|
|||||||
117
test/integration/per-pump-ctrl-fanout.integration.test.js
Normal file
117
test/integration/per-pump-ctrl-fanout.integration.test.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Output-coverage tests for examples/02-Dashboard.json :: fn_chart_pump_a/b/c.
|
||||||
|
// These per-pump fan-out functions feed two charts:
|
||||||
|
// output 0 → ui_chart_per_pump_flow (topic = 'Pump A/B/C', payload = flow m³/h)
|
||||||
|
// output 1 → ui_chart_pumps_ctrl (topic = 'Pump A/B/C', payload = ctrl %)
|
||||||
|
// The ctrl output carries a -1 OFF sentinel: when the pump is off / idle /
|
||||||
|
// maintenance it is not running, so we plot -1 (below the 0–100 band) to give
|
||||||
|
// the chart a clear OFF rail distinct from a pump genuinely running at 0%.
|
||||||
|
// Every output is exercised in populated AND degraded states per
|
||||||
|
// .claude/rules/output-coverage.md.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const flow = JSON.parse(fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
|
||||||
|
|
||||||
|
const PUMPS = [
|
||||||
|
{ id: 'fn_chart_pump_a', topic: 'Pump A' },
|
||||||
|
{ id: 'fn_chart_pump_b', topic: 'Pump B' },
|
||||||
|
{ id: 'fn_chart_pump_c', topic: 'Pump C' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FLOW = 0; // output index → ui_chart_per_pump_flow
|
||||||
|
const CTRL = 1; // output index → ui_chart_pumps_ctrl
|
||||||
|
|
||||||
|
// Each fan-out caches Port 0 deltas in context('c'). Build a fresh runner per
|
||||||
|
// test so state never leaks between cases.
|
||||||
|
function makeRunner(node) {
|
||||||
|
let store = {};
|
||||||
|
const context = { get: (k) => store[k], set: (k, v) => { store[k] = v; } };
|
||||||
|
const body = new Function('msg', 'context', node.func);
|
||||||
|
return (payload) => body({ payload }, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A populated downstream-flow key uses the 4-segment MeasurementContainer
|
||||||
|
// convention the function matches with find('flow.predicted.downstream.').
|
||||||
|
const flowKey = (id) => `flow.predicted.downstream.${id}`;
|
||||||
|
|
||||||
|
test('every per-pump fan-out has exactly 2 outputs wired to flow + ctrl charts', () => {
|
||||||
|
for (const { id } of PUMPS) {
|
||||||
|
const node = flow.find(n => n.id === id);
|
||||||
|
assert.ok(node, `${id} present in flow`);
|
||||||
|
assert.equal(node.outputs, 2, `${id} outputs`);
|
||||||
|
assert.equal(node.wires.length, 2, `${id} wires`);
|
||||||
|
assert.deepEqual(node.wires[FLOW], ['ui_chart_per_pump_flow'], `${id} flow wire`);
|
||||||
|
assert.deepEqual(node.wires[CTRL], ['ui_chart_pumps_ctrl'], `${id} ctrl wire`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ui_chart_pumps_ctrl ymin is -5 so the OFF sentinel (-1) is visible', () => {
|
||||||
|
const chart = flow.find(n => n.id === 'ui_chart_pumps_ctrl');
|
||||||
|
assert.ok(chart, 'ui_chart_pumps_ctrl present');
|
||||||
|
assert.equal(chart.ymin, '-5');
|
||||||
|
assert.equal(chart.ymax, '100');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { id, topic } of PUMPS) {
|
||||||
|
test(`${id}: populated running state → flow + ctrl carry real numbers`, () => {
|
||||||
|
const run = makeRunner(flow.find(n => n.id === id));
|
||||||
|
const out = run({ [flowKey(id)]: 478 / 3, ctrl: 72, state: 'operational' });
|
||||||
|
assert.deepEqual(out[FLOW], { topic, payload: 478 / 3 });
|
||||||
|
assert.deepEqual(out[CTRL], { topic, payload: 72 });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const offState of ['off', 'idle', 'maintenance']) {
|
||||||
|
test(`${id}: state '${offState}' → ctrl emits -1 sentinel (even if ctrl% is 0/stale)`, () => {
|
||||||
|
const run = makeRunner(flow.find(n => n.id === id));
|
||||||
|
// ctrl stale at 0 (or any residual) must be overridden by the sentinel.
|
||||||
|
const out = run({ [flowKey(id)]: 0, ctrl: 0, state: offState });
|
||||||
|
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test(`${id}: degraded — no state, ctrl missing → ctrl output is null (drop, never payload:null)`, () => {
|
||||||
|
const run = makeRunner(flow.find(n => n.id === id));
|
||||||
|
const out = run({ [flowKey(id)]: 50 });
|
||||||
|
assert.equal(out[CTRL], null, 'ctrl must drop when no state and no ctrl');
|
||||||
|
// flow still present.
|
||||||
|
assert.deepEqual(out[FLOW], { topic, payload: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${id}: degraded — no flow key → flow output is null (drop)`, () => {
|
||||||
|
const run = makeRunner(flow.find(n => n.id === id));
|
||||||
|
const out = run({ ctrl: 40, state: 'operational' });
|
||||||
|
assert.equal(out[FLOW], null, 'flow must drop when source key missing');
|
||||||
|
assert.deepEqual(out[CTRL], { topic, payload: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${id}: pre-first-tick — empty payload → both outputs null, no payload:null`, () => {
|
||||||
|
const run = makeRunner(flow.find(n => n.id === id));
|
||||||
|
const out = run({});
|
||||||
|
assert.equal(out[FLOW], null);
|
||||||
|
assert.equal(out[CTRL], null);
|
||||||
|
for (const m of out) {
|
||||||
|
if (m && Object.prototype.hasOwnProperty.call(m, 'payload')) {
|
||||||
|
assert.notEqual(m.payload, null, `${id} emitted { payload: null }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${id}: running ctrl with NaN/null ctrl value → ctrl drops (no payload:null)`, () => {
|
||||||
|
const run = makeRunner(flow.find(n => n.id === id));
|
||||||
|
assert.equal(run({ [flowKey(id)]: 10, ctrl: null, state: 'operational' })[CTRL], null);
|
||||||
|
assert.equal(run({ [flowKey(id)]: 10, ctrl: NaN, state: 'operational' })[CTRL], null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${id}: delta-cache holds last state so a ctrl-only delta still rails OFF`, () => {
|
||||||
|
// Realistic: pump first reports state:'off', then a later tick carries only
|
||||||
|
// a ctrl delta (no state). The cached 'off' must keep the sentinel engaged.
|
||||||
|
const run = makeRunner(flow.find(n => n.id === id));
|
||||||
|
run({ state: 'off', ctrl: 0 });
|
||||||
|
const out = run({ ctrl: 5 }); // ctrl-only delta; cached state still 'off'
|
||||||
|
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user