Files
machineGroupControl/src/specificClass.js
znetsixe c982c9bef7 refactor(units): route _canonicalToOutputFlow + setDemand through UnitPolicy.convert
Drop the direct convert() import — both call sites now go through
this.unitPolicy.convert. setDemand keeps its try/catch around the
absolute-flow branch (legitimate Bucket-2 case: % vs flow demux
prevents declaring `units:` on the dispatcher). Matches the
contract direction in .claude/refactor/CONTRACTS.md §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:43:44 +02:00

465 lines
23 KiB
JavaScript

// MachineGroup — S88 Unit orchestrator coordinating rotatingMachine children.
//
// All real work lives in the concern modules under src/{groupOps,totals,
// combinatorics,optimizer,efficiency,dispatch,control}. This file stitches
// them together: child-event routing, demand serialization, mode selection,
// and the per-mode dispatch switch.
//
// Operator demand is always passed in here as a canonical m³/s number. The
// set.demand handler resolves units (%, m³/h, l/s, etc.) before calling
// handleInput, so this orchestrator has no scaling state and no unit logic.
'use strict';
const { BaseDomain, UnitPolicy, POSITIONS, interpolation } = require('generalFunctions');
const GroupOperatingPoint = require('./groupOps/groupOperatingPoint');
const groupCurves = require('./groupOps/groupCurves');
const TotalsCalculator = require('./totals/totalsCalculator');
const { validPumpCombinations } = require('./combinatorics/pumpCombinations');
const optimizer = require('./optimizer');
const GroupEfficiency = require('./efficiency/groupEfficiency');
const control = require('./control/strategies');
const io = require('./io/output');
const DemandDispatcher = require('./dispatch/demandDispatcher');
const { buildProfile } = require('./movement/machineProfile');
const movementScheduler = require('./movement/movementScheduler');
const MovementExecutor = require('./movement/movementExecutor');
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
// Canonical mode names (camelCase). The dispatcher already lowercases for its
// switch, but we normalise at setMode so this.mode is always in the canonical
// form — keeps allowedActions/allowedSources lookups (which key on the
// canonical form) honest. Module-level so tests can import without spinning
// up a full MachineGroup instance.
const ALLOWED_MODES = ['optimalControl', 'priorityControl', 'maintenance'];
function _normaliseMode(input) {
const lc = String(input || '').toLowerCase();
return ALLOWED_MODES.find((m) => m.toLowerCase() === lc) || null;
}
class MachineGroup extends BaseDomain {
static name = 'machineGroupControl';
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
requireUnitForTypes: ['pressure', 'flow', 'power', 'temperature'],
});
configure() {
this.interpolation = new interpolation();
// Plain id-keyed maps so tests + tight-loop iteration stay readable.
// The router populates them via the onRegister handlers below; legacy
// tests still write directly (matches the pumpingStation pattern).
this.machines = {};
// Persisted flows may have stored the mode in lowercase (legacy editor
// behaviour); normalise at construction so allow-list lookups against
// the schema's camelCase keys work consistently. Fallback to
// optimalControl if the persisted value is missing/garbage so a typo
// doesn't quietly disable dispatch.
this.mode = _normaliseMode(this.config.mode.current) || 'optimalControl';
this.absDistFromPeak = 0;
this.relDistFromPeak = 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 } };
// Latest-wins demand gate. Awaiting handleInput resolves when THIS
// call's dispatch settles (LatestWinsGate.fireAndWait); a parked
// call that is later superseded resolves with { superseded: true }.
this._demandDispatcher = new DemandDispatcher(
{ logger: this.logger },
(payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList),
);
this._shutdownInFlight = new Set();
// Tick-driven executor for the movement schedule produced by the
// planner. MGC owns the wall-clock setInterval that calls tick();
// the executor itself is pure (testable without timers).
this.movementExecutor = new MovementExecutor({
logger: this.logger,
fireCommand: (cmd) => this._fireSchedulerCommand(cmd),
});
this._executorTimer = null;
this._executorIntervalMs = 1000;
this.operatingPoint = new GroupOperatingPoint({
measurements: this.measurements,
machines: this.machines,
unitPolicy: this.unitPolicy,
logger: this.logger,
});
this.totals = new TotalsCalculator({
machines: this.machines,
unitPolicy: this.unitPolicy,
logger: this.logger,
operatingPoint: this.operatingPoint,
isMachineActive: (id) => this.isMachineActive(id),
});
this.efficiency = new GroupEfficiency({
logger: this.logger,
interpolation: this.interpolation,
measurements: this.measurements,
machines: this.machines,
});
this.router
.onRegister('machine', (child) => {
const id = child.config.general.id;
if (this.machines[id]) {
this.logger.warn(`Machine ${id} is already registered.`);
return;
}
this.machines[id] = child;
})
.onMeasurement('machine', { type: 'pressure', position: POSITIONS.DOWNSTREAM }, () => this.handlePressureChange())
.onMeasurement('machine', { type: 'pressure', position: 'differential' }, () => this.handlePressureChange())
.onPrediction('machine', { type: 'flow', position: POSITIONS.DOWNSTREAM }, () => this.handlePressureChange())
.onRegister('measurement', (child) => {
const position = child.config?.functionality?.positionVsParent || child.config?.general?.positionVsParent;
const measurementType = child.config?.asset?.type;
if (!measurementType || !position) {
this.logger.warn(`Measurement child ${child.config?.general?.id} missing asset.type or positionVsParent — skipping`);
return;
}
const eventName = `${measurementType}.measured.${String(position).toLowerCase()}`;
child.measurements.emitter.on(eventName, (eventData = {}) => {
this.measurements
.type(measurementType).variant('measured').position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
if (measurementType === 'pressure') this.handlePressureChange();
});
});
this.logger.info('MachineGroup initialized.');
}
context() {
return Object.freeze({
...super.context(),
mgc: this,
machines: this.machines,
groupCurves,
readChildMeasurement: (m, t, v, p, u) => this.operatingPoint.readChild(m, t, v, p, u),
POSITIONS,
});
}
// ── Surface kept for tests + commands ──────────────────────────────
// Mirror of rotatingMachine/src/specificClass.js:329-339 — same pattern,
// mode/source allow-lists live in this.config.mode (loaded from the
// schema as Set instances). Anything not declared in the schema is
// dropped silently with a warn-level log.
isValidActionForMode(action, mode) {
const ok = !!this.config?.mode?.allowedActions?.[mode]?.has?.(action);
if (ok) this.logger.debug(`action '${action}' allowed in mode '${mode}'`);
else this.logger.warn(`action '${action}' not allowed in mode '${mode}'`);
return ok;
}
isValidSourceForMode(source, mode) {
const ok = !!this.config?.mode?.allowedSources?.[mode]?.has?.(source);
if (ok) this.logger.debug(`source '${source}' allowed in mode '${mode}'`);
else this.logger.warn(`source '${source}' not allowed in mode '${mode}'`);
return ok;
}
setMode(mode) {
const canonical = _normaliseMode(mode);
if (!canonical) {
this.logger.warn(`Invalid mode '${mode}'. Allowed: ${ALLOWED_MODES.join(', ')}`);
return;
}
this.mode = canonical;
this.notifyOutputChanged();
}
isMachineActive(id) {
const s = this.machines[id]?.state?.getCurrentState?.();
return ACTIVE_STATES.has(s);
}
equalizePressure() { this.operatingPoint.equalize(); }
calcAbsoluteTotals() { return (this.absoluteTotals = this.totals.calcAbsoluteTotals()); }
calcDynamicTotals() { return (this.dynamicTotals = this.totals.calcDynamicTotals()); }
activeTotals() { return this.totals.activeTotals(); }
calcGroupEfficiency(machines) { return this.efficiency.calcGroupEfficiency(machines); }
calcDistanceBEP(eff, max, min) {
const d = this.efficiency.calcDistanceBEP(eff, max, min);
this.absDistFromPeak = d.absDistFromPeak;
this.relDistFromPeak = d.relDistFromPeak;
return d;
}
validPumpCombinations(machines, Qd, powerCap = Infinity) {
return validPumpCombinations(machines, Qd, this.context(), powerCap);
}
calcBestCombination(combinations, Qd) {
return optimizer.calcBestCombination(combinations, Qd, this.context());
}
calcBestCombinationBEPGravitation(combinations, Qd, method = 'BEP-Gravitation-Directional') {
return optimizer.calcBestCombinationBEPGravitation(combinations, Qd, this.context(), method);
}
handlePressureChange() {
this.operatingPoint.equalize();
const totals = this.calcDynamicTotals();
const fUnit = this.unitPolicy.canonical.flow;
const pUnit = this.unitPolicy.canonical.power;
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totals.flow.act, fUnit);
// Mirror live aggregate onto DOWNSTREAM — PS subscribes here for the
// outflow signal. See preserve-tests/ps-mgc-flow-contract regression.
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.DOWNSTREAM, totals.flow.act, fUnit);
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totals.power.act, pUnit);
const { maxEfficiency, lowestEfficiency } = this.efficiency.calcGroupEfficiency(this.machines);
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue() ?? null;
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
this.notifyOutputChanged();
}
async abortActiveMovements(reason = 'new demand') {
await Promise.all(Object.values(this.machines).map(async machine => {
const state = machine.state?.getCurrentState?.();
if (state !== 'accelerating' && state !== 'decelerating') return;
this.logger.warn(`Force-aborting in-flight movement on ${machine.config.general.id} (state=${state}) due to: ${reason}.`);
if (typeof machine.abortMovement === 'function') await machine.abortMovement(reason);
}));
}
async _optimalControl(Qd, powerCap = Infinity) {
if (Object.keys(this.machines).length === 0) {
this.logger.warn('No machines registered. Cannot execute optimal control.');
return;
}
this.operatingPoint.equalize();
const dt = this.dynamicTotals;
const machineStates = Object.entries(this.machines).reduce((acc, [id, m]) => {
acc[id] = m.state.getCurrentState();
return acc;
}, {});
if (Qd <= 0) { await this.turnOffAllMachines(); return; }
if (Qd < dt.flow.min) Qd = dt.flow.min;
else if (Qd > dt.flow.max) Qd = dt.flow.max;
const combinations = this.validPumpCombinations(this.machines, Qd, powerCap);
if (!combinations || combinations.length === 0) {
this.logger.warn(`Demand: ${Qd.toFixed(2)} -> No valid combination found (empty set).`);
return;
}
const method = this.config.optimization?.method || 'BEP-Gravitation-Directional';
const ctx = this.context();
let bestResult;
if (method === 'NCog') {
bestResult = optimizer.calcBestCombination(combinations, Qd, ctx);
} else if (method === 'BEP-Gravitation' || method === 'BEP-Gravitation-Directional') {
bestResult = optimizer.calcBestCombinationBEPGravitation(combinations, Qd, ctx, method);
} else {
this.logger.warn(`Unknown optimization method '${method}', falling back to BEP-Gravitation-Directional.`);
bestResult = optimizer.calcBestCombinationBEPGravitation(combinations, Qd, ctx, 'BEP-Gravitation-Directional');
}
if (bestResult.bestCombination === null) {
this.logger.warn(`Demand: ${Qd.toFixed(2)} -> No valid combination found => not updating control.`);
return;
}
// INTENT lands on AT_EQUIPMENT only; DOWNSTREAM is the live aggregate.
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestPower, this.unitPolicy.canonical.power);
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, bestResult.bestFlow, this.unitPolicy.canonical.flow);
// Hydraulic efficiency η = (Q·ΔP)/P_shaft — a dimensionless 0..1
// ratio in the same scale as each child rotatingMachine's `cog`.
// Keeps `calcDistanceBEP(eff, maxEfficiency, lowestEfficiency)` in
// handlePressureChange comparing apples to apples.
const dP = this.operatingPoint.headerDiffPa;
if (Number.isFinite(dP) && dP > 0 && bestResult.bestPower > 0) {
this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
.value((bestResult.bestFlow * dP) / bestResult.bestPower);
}
this.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
const distribution = bestResult.bestCombination.map((it) => ({ machineId: String(it.machineId), flow: it.flow }));
await this._dispatchFlowDistribution(distribution);
}
// Shared dispatch path used by every control strategy. Takes a flow
// distribution {machineId, flow}[] and routes it through the planner
// and executor. Same-time-landing (rendezvous) is the default and can
// be turned off via config.planner.useRendezvous, in which case every
// command fires at tick 0 (legacy fire-and-forget behaviour, like the
// pre-planner equalFlowControl).
async _dispatchFlowDistribution(distribution) {
const profiles = Object.values(this.machines).map((m) => buildProfile(m));
const headerPa = Number.isFinite(this.operatingPoint.headerDiffPa) ? this.operatingPoint.headerDiffPa : 0;
const useRendezvous = this.config?.planner?.useRendezvous !== false; // default true
const schedule = movementScheduler.plan(profiles, distribution, headerPa, { tickS: 1, useRendezvous });
this.movementExecutor.replan(schedule);
// AWAIT the first tick to preserve the race-favouring behaviour
// of the original code. The new move's full chain (residue
// handler → operational → ramp) settles before _runDispatch
// returns; the in-flight shutdown sequence's for-loop runs on
// other microtasks but its invalid-transition exits truncate it.
await this.movementExecutor.tick();
this._ensureExecutorTimer();
if (this.logger?.debug) {
this.logger.debug(`MGC planner: ${schedule.commands.length} commands queued, tStar=${schedule.tStarS.toFixed(1)}s, rendezvous=${useRendezvous}`);
}
}
// Dispatch one scheduled command to the appropriate child. Returns
// synchronously — the underlying handleInput is fire-and-forget from
// the executor's perspective, mirroring the existing optimal-control
// behaviour where commands are scheduled, not awaited.
_fireSchedulerCommand(cmd) {
const machine = this.machines[cmd.machineId];
if (!machine) {
this.logger?.warn?.(`Scheduler fired ${cmd.action} for unknown machine ${cmd.machineId}`);
return undefined;
}
const handle = typeof machine.handleInput === 'function' ? machine.handleInput.bind(machine) : null;
if (!handle) return undefined;
if (cmd.action === 'execsequence') {
return Promise.resolve(handle('parent', 'execsequence', cmd.sequence))
.catch((e) => this.logger?.error?.(`execsequence ${cmd.sequence} on ${cmd.machineId} failed: ${e?.message || e}`));
}
if (cmd.action === 'flowmovement') {
const outFlow = this._canonicalToOutputFlow(cmd.flow);
return Promise.resolve(handle('parent', 'flowmovement', outFlow))
.catch((e) => this.logger?.error?.(`flowmovement on ${cmd.machineId} failed: ${e?.message || e}`));
}
return undefined;
}
// Wall-clock driver for the executor. Auto-stops when there's nothing
// pending so we don't burn a forever-running setInterval.
_ensureExecutorTimer() {
if (this._executorTimer) return;
this._executorTimer = setInterval(() => {
this.movementExecutor.tick();
if (this.movementExecutor.pending() === 0) {
clearInterval(this._executorTimer);
this._executorTimer = null;
}
}, this._executorIntervalMs);
// Unref so the timer doesn't keep Node-RED alive on shutdown.
if (typeof this._executorTimer.unref === 'function') this._executorTimer.unref();
}
// Stop the executor's wall-clock driver. Called from teardown paths.
_stopExecutorTimer() {
if (this._executorTimer) {
clearInterval(this._executorTimer);
this._executorTimer = null;
}
}
// Returns when THIS call's dispatch settles. If overwritten by a later
// handleInput() while parked behind an in-flight dispatch, resolves
// with the LatestWinsGate.SUPERSEDED sentinel ({ superseded: true }).
async handleInput(source, demand, powerCap = Infinity, priorityList = null) {
return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList });
}
// Operator-style entry point: accepts a (value, unit) pair and resolves
// to canonical m³/s before delegating to handleInput. Single source of
// truth for the unit math shared by the set.demand command handler and
// by parent nodes (e.g. pumpingStation level-based control) that hold a
// direct reference to this specificClass and need to push a % demand
// without re-implementing the interpolation. Negative value is the
// stop-all signal regardless of unit.
async setDemand(value, unit = '%') {
const v = Number(value);
if (!Number.isFinite(v)) {
this.logger?.error?.(`setDemand: invalid value '${value}'`);
return undefined;
}
if (v < 0) {
await this.turnOffAllMachines();
return undefined;
}
let canonical;
if (unit === '%') {
const dt = this.calcDynamicTotals();
canonical = this.interpolation.interpolate_lin_single_point(
v, 0, 100, dt.flow.min, dt.flow.max);
} else {
try {
canonical = this.unitPolicy.convert(v, unit, 'm3/s', 'setDemand absolute flow');
} catch (err) {
this.logger?.error?.(`setDemand: cannot convert ${v} ${unit} -> m3/s: ${err?.message || err}`);
return undefined;
}
}
return this.handleInput('parent', canonical);
}
async _runDispatch(source, demand, powerCap, priorityList) {
const demandQ = parseFloat(demand);
if (!Number.isFinite(demandQ)) {
this.logger.error(`Invalid flow demand input: ${demand}.`);
return;
}
// Demand is canonical m³/s (the handler has already resolved units).
// The handler routes negatives directly to turnOffAllMachines, but
// keep a defensive check in case turnOff-state arrives some other way.
if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
await this.abortActiveMovements('new demand received');
const dt = this.calcDynamicTotals();
// Clamp against the current-pressure envelope.
let demandQout = demandQ;
if (demandQout < dt.flow.min) demandQout = dt.flow.min;
else if (demandQout > dt.flow.max) demandQout = dt.flow.max;
// Normalize for the switch — schema enum values use camelCase
// (optimalControl, priorityControl) while legacy callers send
// lowercase. Accept both rather than silently falling through.
const ctx = { mgc: this };
switch (String(this.mode || '').toLowerCase()) {
case 'prioritycontrol': await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break;
case 'optimalcontrol': await this._optimalControl(demandQout, powerCap); break;
default: this.logger.warn(`${this.mode} is not a valid mode.`);
}
const { maxEfficiency, lowestEfficiency } = this.efficiency.calcGroupEfficiency(this.machines);
const eff = this.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue();
this.calcDistanceBEP(eff, maxEfficiency, lowestEfficiency);
this.notifyOutputChanged();
}
async turnOffAllMachines() {
// Cancel any parked demand — turnOff is latest user intent so a
// pending fireAndWait must not re-engage pumps post-shutdown.
this._demandDispatcher.cancelPending();
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
if (this._shutdownInFlight.has(id)) return;
if (this.isMachineActive(id)) {
this._shutdownInFlight.add(id);
try { await machine.handleInput('parent', 'execsequence', 'shutdown'); }
finally { this._shutdownInFlight.delete(id); }
}
}));
const fUnit = this.unitPolicy.canonical.flow;
const pUnit = this.unitPolicy.canonical.power;
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.DOWNSTREAM, 0, fUnit);
this.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, 0, fUnit);
this.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, 0, pUnit);
this.notifyOutputChanged();
}
_canonicalToOutputFlow(value) {
return this.unitPolicy.convert(
value,
this.unitPolicy.canonical.flow,
this.unitPolicy.output.flow,
'canonical->output flow',
);
}
getOutput() { return io.getOutput(this); }
getStatusBadge() { return io.getStatusBadge(this); }
}
module.exports = MachineGroup;
// Module-level helpers exposed for unit tests.
module.exports._normaliseMode = _normaliseMode;
module.exports.ALLOWED_MODES = ALLOWED_MODES;