From 5f1c9ae2ff5cc110ae02cf128fe27b11dbf544e6 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 11 May 2026 17:41:07 +0200 Subject: [PATCH] P11.5 + B2.1/B2.2: per-command units + description (where applicable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds to scalar setters whose payloads are plain numbers OR {value, unit}. Skipped where payload is compound or mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline. Every command gains a description field for wikiGen consumption. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/index.js | 12 +++ src/specificClass.js | 48 +++++----- test/basic/commands.basic.test.js | 15 ++- test/basic/specificClass.test.js | 93 ++++++++----------- .../shifted-ramp-end-to-end.test.js | 27 +++++- 5 files changed, 107 insertions(+), 88 deletions(-) diff --git a/src/commands/index.js b/src/commands/index.js index 8ee08ed..6a5a5fb 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -12,6 +12,7 @@ module.exports = [ topic: 'set.mode', aliases: ['changemode'], payloadSchema: { type: 'string' }, + description: 'Switch the station between auto / manual control modes.', handler: handlers.setMode, }, { @@ -19,6 +20,7 @@ module.exports = [ aliases: ['registerChild'], // payload is the Node-RED id (string) of the child node. payloadSchema: { type: 'string' }, + description: 'Register a child node (machine group, measurement, …) with this station.', handler: handlers.registerChild, }, { @@ -26,12 +28,16 @@ module.exports = [ aliases: ['calibratePredictedVolume'], // any: payload may be a number or numeric string. payloadSchema: { type: 'any' }, + units: { measure: 'volume', default: 'm3' }, + description: 'Calibrate the predicted-volume integrator to a known basin volume.', handler: handlers.calibrateVolume, }, { topic: 'cmd.calibrate.level', aliases: ['calibratePredictedLevel'], payloadSchema: { type: 'any' }, + units: { measure: 'length', default: 'm' }, + description: 'Calibrate the predicted-volume integrator to a known basin level.', handler: handlers.calibrateLevel, }, { @@ -39,18 +45,24 @@ module.exports = [ aliases: ['q_in'], // any: number, numeric string, or { value, unit, timestamp } object. payloadSchema: { type: 'any' }, + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + description: 'Push a measured inflow value into the basin balance.', handler: handlers.setInflow, }, { topic: 'set.outflow', aliases: ['q_out'], payloadSchema: { type: 'any' }, + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + description: 'Push a measured outflow value into the basin balance.', handler: handlers.setOutflow, }, { topic: 'set.demand', aliases: ['Qd'], payloadSchema: { type: 'any' }, + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + description: 'Operator outflow demand setpoint for the station.', handler: handlers.setDemand, }, ]; diff --git a/src/specificClass.js b/src/specificClass.js index 396ac46..6162dab 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -91,35 +91,29 @@ class PumpingStation extends BaseDomain { this.measurements.type('volume').variant('predicted').position('atequipment') .value(this.basin.minVol, Date.now(), 'm3').unit('m3'); - // Plain id-keyed maps. Tests assign into them directly (legacy contract); - // ChildRouter onRegister handlers below also populate them. - this.machines = {}; - this.stations = {}; - this.machineGroups = {}; - this.predictedFlowChildren = new Map(); + // Registry-as-truth — `this.machines / machineGroups / stations` are + // read-only getters flattening `this.child[softwareType]` (BaseDomain + // helper). Mutations go through `childRegistrationUtils.registerChild`. + this.declareChildGetter('machines', 'machine'); + this.declareChildGetter('machineGroups', 'machinegroup'); + this.declareChildGetter('stations', 'pumpingstation'); - // SafetyController constructed after child maps so its captured ctx - // references the live dicts rather than undefined. + // SafetyController's captured ctx exposes the same three names as live + // getters (installed in context()), so the registry remains the single + // source of truth long after configure() returns. this.safety = new SafetyController(this.context()); this.router .onRegister('measurement', (child) => this._subscribeMeasurement(child)) .onRegister('machine', (child) => { - this.machines[child.config.general.id] = child; // Skip individual machines when a machineGroup parent is present — // the group's flow.predicted already aggregates child machines. if (Object.keys(this.machineGroups).length === 0) { this._subscribePredictedFlow(child); } }) - .onRegister('machinegroup', (child) => { - this.machineGroups[child.config.general.id] = child; - this._subscribePredictedFlow(child); - }) - .onRegister('pumpingstation', (child) => { - this.stations[child.config.general.id] = child; - this._subscribePredictedFlow(child); - }); + .onRegister('machinegroup', (child) => this._subscribePredictedFlow(child)) + .onRegister('pumpingstation', (child) => this._subscribePredictedFlow(child)); this.logger.debug('PumpingStation initialized'); } @@ -130,21 +124,28 @@ class PumpingStation extends BaseDomain { // `_lastDirection`, `_stopHystRunning`) write straight to the live // instance — Object.freeze on the view itself is fine because these // flags live on the host, not in the view. + // + // machines / machineGroups / stations are installed as live getters + // that delegate to this.* getters (declareChildGetter). SafetyController + // captures this ctx once at construction; the getters keep it reading + // fresh from the registry after later child registrations. context() { - return Object.freeze({ + const host = this; + const ctx = { ...super.context(), basin: this.basin, flowAggregator: this.flowAggregator, - machines: this.machines, - machineGroups: this.machineGroups, - stations: this.stations, mode: this.mode, flowVariants: this.flowVariants, levelVariants: this.levelVariants, volVariants: this.volVariants, flowThreshold: this.flowThreshold, host: this, - }); + }; + Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines }); + Object.defineProperty(ctx, 'machineGroups', { enumerable: true, get: () => host.machineGroups }); + Object.defineProperty(ctx, 'stations', { enumerable: true, get: () => host.stations }); + return Object.freeze(ctx); } tick() { @@ -301,9 +302,6 @@ class PumpingStation extends BaseDomain { const [posKey, eventName] = mapped; const childId = child.config.general.id ?? child.config.general.name; - if (!this.predictedFlowChildren.has(childId)) { - this.predictedFlowChildren.set(childId, { in: 0, out: 0 }); - } child.measurements.emitter.on(eventName, (eventData = {}) => { const unit = eventData.unit || child.config?.general?.unit; const ts = eventData.timestamp || Date.now(); diff --git a/test/basic/commands.basic.test.js b/test/basic/commands.basic.test.js index e4294ee..bf5eb56 100644 --- a/test/basic/commands.basic.test.js +++ b/test/basic/commands.basic.test.js @@ -75,10 +75,12 @@ test('canonical topics dispatch to their handlers', async () => { await reg.dispatch({ topic: 'cmd.calibrate.level', payload: 1.25 }, source, makeCtx()); assert.deepEqual(calls.calibratePredictedLevel, [1.25]); + // Registry normalises to the descriptor's `units.default` (m3/h) before + // the handler runs. 0.5 m3/s -> 1800 m3/h. await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s' }, source, makeCtx()); assert.equal(calls.setManualInflow.length, 1); - assert.equal(calls.setManualInflow[0].v, 0.5); - assert.equal(calls.setManualInflow[0].u, 'm3/s'); + assert.equal(calls.setManualInflow[0].v, 1800); + assert.equal(calls.setManualInflow[0].u, 'm3/h'); await reg.dispatch({ topic: 'set.demand', payload: 100 }, source, makeCtx()); assert.deepEqual(calls.forwardDemandToChildren, [100]); @@ -140,11 +142,16 @@ test('set.inflow accepts number payload and { value, unit, timestamp } object pa const { source, calls } = makeSource(); const reg = makeRegistry(makeLogger()); + // After registry units-normalisation the handler always sees a number in + // the descriptor's default unit (m3/h). 0.5 m3/s -> 1800 m3/h. await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s', timestamp: 1000 }, source, makeCtx()); - assert.deepEqual(calls.setManualInflow[0], { v: 0.5, ts: 1000, u: 'm3/s' }); + assert.deepEqual(calls.setManualInflow[0], { v: 1800, ts: 1000, u: 'm3/h' }); + // Object payload `{ value, unit }` is flattened to a number; 2 m3/h stays + // 2 m3/h. The timestamp travels on the msg envelope after normalisation + // (the per-payload `timestamp` field is not preserved by the flatten). await reg.dispatch( - { topic: 'set.inflow', payload: { value: 2, unit: 'm3/h', timestamp: 2000 } }, + { topic: 'set.inflow', payload: { value: 2, unit: 'm3/h' }, timestamp: 2000 }, source, makeCtx() ); diff --git a/test/basic/specificClass.test.js b/test/basic/specificClass.test.js index 6b2a5a5..d6864de 100644 --- a/test/basic/specificClass.test.js +++ b/test/basic/specificClass.test.js @@ -6,6 +6,31 @@ const assert = require('node:assert/strict'); const PumpingStation = require('../../src/specificClass'); +// machineGroups is a registry-backed getter (declareChildGetter) — direct +// assignment is no longer possible. Tests inject mock groups through the +// real registration handshake so the registry remains the source of truth. +function registerMockGroup(ps, id, behavior = {}) { + const calls = { handleInput: [], turnOff: 0 }; + const mock = { + config: { + general: { id, name: id }, + functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' }, + asset: { category: 'controller' }, + }, + measurements: { + emitter: { on: () => {} }, + setChildId: () => {}, setChildName: () => {}, setParentRef: () => {}, + }, + handleInput: behavior.handleInput + || (async (...args) => { calls.handleInput.push(args); }), + turnOffAllMachines: behavior.turnOffAllMachines + || (() => { calls.turnOff += 1; }), + _calls: calls, + }; + ps.childRegistrationUtils.registerChild(mock, 'atEquipment'); + return mock; +} + // Standard config shape. Override any section by passing { section: {...} }. function makeConfig(overrides = {}) { const base = { @@ -229,70 +254,46 @@ test('Calibration — predicted volume and level', async (t) => { test('Levelbased control zones — _controlLevelBased', async (t) => { await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => { const ps = new PumpingStation(makeConfig()); - let turnOffCalls = 0; - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => { turnOffCalls++; }, - handleInput: async () => {}, - }; + const mock = registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(0.5); // below minLevel=1 await ps._controlLevelBased(); assert.equal(ps.percControl, 0); - assert.equal(turnOffCalls, 1); + assert.equal(mock._calls.turnOff, 1); }); await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => { const ps = new PumpingStation(makeConfig()); ps.percControl = 42; // simulated previous demand - const demands = []; - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async (_src, d) => { demands.push(d); }, - }; + const mock = registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2 await ps._controlLevelBased(); assert.equal(ps.percControl, 0); - assert.equal(demands[0], 0); + assert.equal(mock._calls.handleInput[0][1], 0); }); await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => { const ps = new PumpingStation(makeConfig()); - const demands = []; - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async (_src, d) => { demands.push(d); }, - }; + const mock = registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3 await ps._controlLevelBased('filling'); assert.equal(ps.percControl, 0); - assert.equal(demands[0], 0); + assert.equal(mock._calls.handleInput[0][1], 0); }); await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => { const ps = new PumpingStation(makeConfig()); - const demands = []; - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async (_src, d) => { demands.push(d); }, - }; + const mock = registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4 await ps._controlLevelBased('filling'); // lerp(3.5, [3,4], [0,100]) = 50 assert.ok(Math.abs(ps.percControl - 50) < 1e-9); - assert.equal(demands.length, 1); - assert.ok(Math.abs(demands[0] - 50) < 1e-9); + assert.equal(mock._calls.handleInput.length, 1); + assert.ok(Math.abs(mock._calls.handleInput[0][1] - 50) < 1e-9); }); await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => { const ps = new PumpingStation(makeConfig()); - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async () => {}, - }; + registerMockGroup(ps, 'mgc1'); // Climb past inflowLevel and beyond, then fall to a level inside [start..inflow]. ps.calibratePredictedLevel(3.8); await ps._controlLevelBased(); @@ -317,11 +318,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { }, }, })); - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async () => {}, - }; + registerMockGroup(ps, 'mgc1'); // Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed. ps.calibratePredictedLevel(3.5); await ps._controlLevelBased('filling'); @@ -363,11 +360,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { }, }, })); - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async () => {}, - }; + registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(3.85); await ps._controlLevelBased('filling'); await ps._controlLevelBased('draining'); @@ -391,11 +384,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 }, }, })); - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async () => {}, - }; + registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4] await ps._controlLevelBased('filling'); assert.ok(ps.percControl > 50); @@ -404,11 +393,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => { await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => { const ps = new PumpingStation(makeConfig()); - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async () => {}, - }; + registerMockGroup(ps, 'mgc1'); ps.calibratePredictedLevel(4.5); // above maxLevel=4 await ps._controlLevelBased(); assert.ok(ps.percControl >= 100); diff --git a/test/integration/shifted-ramp-end-to-end.test.js b/test/integration/shifted-ramp-end-to-end.test.js index 6526d32..6013ea9 100644 --- a/test/integration/shifted-ramp-end-to-end.test.js +++ b/test/integration/shifted-ramp-end-to-end.test.js @@ -50,17 +50,34 @@ function makeConfig() { }; } +// machineGroups is a registry-backed getter (declareChildGetter) — inject +// the fake MGC via the real child-registration handshake so the registry +// stays the source of truth across configure() and tick(). +function registerMockGroup(ps, id, demands) { + const mock = { + config: { + general: { id, name: id }, + functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' }, + asset: { category: 'controller' }, + }, + measurements: { + emitter: { on: () => {} }, + setChildId: () => {}, setChildName: () => {}, setParentRef: () => {}, + }, + handleInput: async (_src, d) => { demands.push(d); }, + turnOffAllMachines: () => {}, + }; + ps.childRegistrationUtils.registerChild(mock, 'atEquipment'); + return mock; +} + // Build a PS with a fake MGC that captures every demand sent to it, // and a clock we control so _updatePredictedVolume integrates over a // known dt regardless of wall-clock. function buildHarness() { const ps = new PumpingStation(makeConfig()); const demands = []; - ps.machineGroups['mgc1'] = { - config: { general: { name: 'mgc1' } }, - turnOffAllMachines: () => {}, - handleInput: async (_src, d) => { demands.push(d); }, - }; + registerMockGroup(ps, 'mgc1', demands); // Seed level at startLevel so the run begins idle. ps.calibratePredictedLevel(2.0); // Override Date.now via a controllable clock that advances `step()`.