feat(mgc): demand telemetry + movement gate (demand debounce)
- Movement gate: hold non-urgent demand while the group is 'working' (mid-ramp/sequencing) and flush it once 'ready', instead of aborting in-flight ramps on every incoming demand — which could freeze pumps at 0. Urgent demand (stop, mode/priority change, large step) still pre-empts. - getMovementState()/_isUrgentDemand()/_maybeFlushPendingDemand() helpers. - Demand telemetry: emit demandFlow (m³/h) and demandPct (0..100 of envelope) resolved by the last dispatch; omitted before the first demand (degraded). - Capacity envelope now emitted in output flow unit (m³/h) not raw m³/s. - Manifest + populated/degraded tests for the new outputs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,10 +26,13 @@ Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by
|
||||
| `scaling` | `mgc.scaling` | string ∈ {`absolute`, `normalized`} or undefined | commands.basic.test.js | dashboard-fanout (undefined → raw-rows shows '—') |
|
||||
| `absDistFromPeak` | `groupEfficiency.calcDistanceFromPeak` (specificClass.js:132) | number ≥ 0 (η-points) | bep-distance-demand-sweep, group-bep-cascade, groupEfficiency.basic | groupEfficiency.basic test 7 (undefined when current = null) |
|
||||
| `relDistFromPeak` | `groupEfficiency.calcRelativeDistanceFromPeak` | number ∈ [0,1] **OR `undefined`** for degenerate (homogeneous pumps) | bep-distance-demand-sweep, group-bep-cascade | groupEfficiency.basic tests 5/6/7 (undefined cases), dashboard-fanout test 11 (undefined → '—' display) |
|
||||
| `flowCapacityMax` | `mgc.dynamicTotals.flow.max` (totalsCalculator) | number m³/s ≥ 0 | totalsCalculator.basic, dashboard-fanout (post-setup) | absent until first equalize; dashboard-fanout (state A) |
|
||||
| `flowCapacityMin` | `mgc.dynamicTotals.flow.min` | number m³/s ≥ 0 | totalsCalculator.basic | same as above |
|
||||
| `flowCapacityMax` | `mgc.dynamicTotals.flow.max` (totalsCalculator), **converted to `unitPolicy.output.flow` (m³/h)** in output.js:62 | number m³/h ≥ 0; `0` when envelope unresolved (Infinity/NaN) | totalsCalculator.basic, dashboard-fanout (post-setup), demand-telemetry.basic | absent until first equalize; dashboard-fanout (state A); demand-telemetry (Infinity → 0) |
|
||||
| `flowCapacityMin` | `mgc.dynamicTotals.flow.min`, **converted to output flow unit (m³/h)** | number m³/h ≥ 0; `0` when unresolved | totalsCalculator.basic, demand-telemetry.basic | same as above |
|
||||
| `demandFlow` | `mgc._lastDemand.clamped` (set in `_runDispatch`, output.js:62) | number, canonical m³/s clamped to envelope, converted to `unitPolicy.output.flow` | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand); turnOff → 0 |
|
||||
| `demandPct` | derived `(clamped − flow.min)/(flow.max − flow.min)·100` (output.js:62) | number ∈ [0,100], `0` when capacity span ≤ 0 | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand) |
|
||||
| `machineCount` | `Object.keys(mgc.machines).length` | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | n/a — always reflects current registration count |
|
||||
| `machineCountActive` | filtered count excluding `off`/`maintenance` states | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | dashboard-fanout (state A: 0 active) |
|
||||
| `movementState` | `mgc.getMovementState()` (specificClass) — `'working'` while any child is ramping/sequencing or the executor has pending commands, else `'ready'` | string `'working'`\|`'ready'`, never null | movement-gate.basic (working: accelerating/warmingup/delayedMove/moveTimeLeft/executor-pending) | movement-gate.basic (ready: no machines, all settled) |
|
||||
|
||||
### Conditional pressure-header fields (emitted only when equalize resolved a positive ΔP)
|
||||
|
||||
|
||||
83
test/basic/demand-telemetry.basic.test.js
Normal file
83
test/basic/demand-telemetry.basic.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { getOutput } = require('../../src/io/output.js');
|
||||
const MachineGroup = require('../../src/specificClass.js');
|
||||
|
||||
// Real declared unit policy so the m³/s → m³/h conversion is the production one.
|
||||
const unitPolicy = MachineGroup.unitPolicy;
|
||||
|
||||
// Minimal MGC stand-in exposing exactly the surface getOutput reads. The
|
||||
// measurement loop is short-circuited with an empty type list so the test
|
||||
// isolates the demand telemetry without needing curves / CoolProp.
|
||||
function mockMgc(overrides = {}) {
|
||||
return {
|
||||
measurements: { getTypes: () => [] },
|
||||
unitPolicy,
|
||||
mode: 'optimalControl',
|
||||
scaling: 'absolute',
|
||||
absDistFromPeak: 0,
|
||||
relDistFromPeak: 0,
|
||||
dynamicTotals: { flow: { min: 0.05, max: 0.25 } }, // m³/s
|
||||
machines: {},
|
||||
operatingPoint: {},
|
||||
_lastDemand: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('demandFlow + demandPct emitted once a demand is resolved', () => {
|
||||
// Demand resolved to 0.15 m³/s inside a 0.05..0.25 envelope → midpoint = 50%.
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.15, clamped: 0.15 } }));
|
||||
|
||||
// m³/s → m³/h is ×3600. 0.15 m³/s = 540 m³/h.
|
||||
assert.equal(out.demandFlow, 540);
|
||||
assert.ok(Math.abs(out.demandPct - 50) < 1e-9, `expected ~50%, got ${out.demandPct}`);
|
||||
});
|
||||
|
||||
test('demandPct reflects the clamped setpoint, not the raw request', () => {
|
||||
// Operator asked for 0.40 m³/s but the envelope caps at 0.25 → 100%.
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.40, clamped: 0.25 } }));
|
||||
assert.equal(out.demandFlow, 900); // 0.25 m³/s = 900 m³/h
|
||||
assert.equal(out.demandPct, 100);
|
||||
});
|
||||
|
||||
test('demandPct is 0 (never NaN) when the capacity span is zero', () => {
|
||||
const out = getOutput(mockMgc({
|
||||
dynamicTotals: { flow: { min: 0.1, max: 0.1 } },
|
||||
_lastDemand: { canonical: 0.1, clamped: 0.1 },
|
||||
}));
|
||||
assert.equal(out.demandPct, 0);
|
||||
assert.ok(Number.isFinite(out.demandFlow));
|
||||
});
|
||||
|
||||
test('turnOff demand (0) emits a zero setpoint, not absent', () => {
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0, clamped: 0 } }));
|
||||
assert.equal(out.demandFlow, 0);
|
||||
assert.equal(out.demandPct, 0);
|
||||
});
|
||||
|
||||
test('demand telemetry is absent before the first demand (degraded state)', () => {
|
||||
const out = getOutput(mockMgc({ _lastDemand: null }));
|
||||
assert.ok(!('demandFlow' in out), 'demandFlow must be absent pre-first-demand');
|
||||
assert.ok(!('demandPct' in out), 'demandPct must be absent pre-first-demand');
|
||||
// The always-on capacity fields are still present, converted to the output
|
||||
// flow unit (m³/h): 0.05 m³/s → 180, 0.25 m³/s → 900.
|
||||
assert.equal(out.flowCapacityMin, 180);
|
||||
assert.equal(out.flowCapacityMax, 900);
|
||||
});
|
||||
|
||||
test('flow capacity is emitted in the output unit (m³/h), matching the flow series', () => {
|
||||
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: 0.1, max: 0.3 } } }));
|
||||
assert.equal(out.flowCapacityMin, 360); // 0.1 m³/s × 3600
|
||||
assert.equal(out.flowCapacityMax, 1080); // 0.3 m³/s × 3600
|
||||
});
|
||||
|
||||
test('flow capacity falls back to 0 when the envelope is unresolved (Infinity)', () => {
|
||||
// Pre-first-equalize: dynamicTotals seeds min=Infinity, max=0.
|
||||
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: Infinity, max: 0 } } }));
|
||||
assert.equal(out.flowCapacityMin, 0);
|
||||
assert.equal(out.flowCapacityMax, 0);
|
||||
});
|
||||
77
test/basic/movement-gate.basic.test.js
Normal file
77
test/basic/movement-gate.basic.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// Unit tests for the MGC movement state + dispatch-gate helpers
|
||||
// (getMovementState / _isUrgentDemand). Exercised via prototype.call with a
|
||||
// minimal fake `this` so no Node-RED runtime or full MachineGroup boot is
|
||||
// needed. See project rule .claude/rules/testing.md (basic = pure logic).
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
|
||||
function machine(state, { delayedMove = null, moveTimeLeft = 0 } = {}) {
|
||||
return { state: { getCurrentState: () => state, delayedMove, getMoveTimeLeft: () => moveTimeLeft } };
|
||||
}
|
||||
function movementStateOf(machines, pending = 0) {
|
||||
return MachineGroup.prototype.getMovementState.call({
|
||||
machines,
|
||||
movementExecutor: { pending: () => pending },
|
||||
});
|
||||
}
|
||||
|
||||
test('movementState: ready when no machines are registered', () => {
|
||||
assert.equal(movementStateOf({}), 'ready');
|
||||
});
|
||||
test('movementState: ready when every machine is settled and nothing is pending', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational'), b: machine('idle') }), 'ready');
|
||||
});
|
||||
test('movementState: working while a machine is mid-ramp', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational'), b: machine('accelerating') }), 'working');
|
||||
});
|
||||
test('movementState: working during a start/stop sequence step', () => {
|
||||
assert.equal(movementStateOf({ a: machine('warmingup') }), 'working');
|
||||
});
|
||||
test('movementState: working when a setpoint is queued (delayedMove)', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational', { delayedMove: 50 }) }), 'working');
|
||||
});
|
||||
test('movementState: working while move time remains', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational', { moveTimeLeft: 1.2 }) }), 'working');
|
||||
});
|
||||
test('movementState: working when the executor still has scheduled commands', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working');
|
||||
});
|
||||
|
||||
function urgent(demandQ, {
|
||||
mode = 'optimalControl', lastMode = 'optimalControl',
|
||||
last = 10, priorityList = null, lastPriorityKey = 'null', span = 100, thr,
|
||||
} = {}) {
|
||||
return MachineGroup.prototype._isUrgentDemand.call({
|
||||
_lastDemand: last == null ? null : { canonical: last },
|
||||
mode, _lastDispatchedMode: lastMode, _lastPriorityKey: lastPriorityKey,
|
||||
calcDynamicTotals: () => ({ flow: { max: span } }),
|
||||
config: { planner: thr == null ? {} : { urgentDemandFraction: thr } },
|
||||
}, demandQ, priorityList);
|
||||
}
|
||||
|
||||
test('urgent: a stop (≤0) always pre-empts', () => {
|
||||
assert.equal(urgent(0), true);
|
||||
assert.equal(urgent(-5), true);
|
||||
});
|
||||
test('urgent: the first demand (no prior) dispatches immediately', () => {
|
||||
assert.equal(urgent(50, { last: null }), true);
|
||||
});
|
||||
test('urgent: a control-mode switch is a new intent', () => {
|
||||
assert.equal(urgent(10, { mode: 'priorityControl', lastMode: 'optimalControl' }), true);
|
||||
});
|
||||
test('urgent: a changed priority order is a new intent', () => {
|
||||
assert.equal(urgent(10, { priorityList: ['eff', 'std'], lastPriorityKey: 'null' }), true);
|
||||
});
|
||||
test('urgent: a small same-mode nudge is held (not urgent)', () => {
|
||||
assert.equal(urgent(12, { last: 10, span: 100 }), false); // 2% of span < 25%
|
||||
});
|
||||
test('urgent: a large same-mode step pre-empts', () => {
|
||||
assert.equal(urgent(60, { last: 10, span: 100 }), true); // 50% of span ≥ 25%
|
||||
});
|
||||
test('urgent: threshold is configurable via planner.urgentDemandFraction', () => {
|
||||
assert.equal(urgent(15, { last: 10, span: 100, thr: 0.02 }), true); // 5% ≥ 2%
|
||||
assert.equal(urgent(15, { last: 10, span: 100, thr: 0.5 }), false); // 5% < 50%
|
||||
});
|
||||
Reference in New Issue
Block a user