Some checks failed
CI / lint-and-test (push) Has been cancelled
Submodule bumps land the deadlock fix (state.js residue unpark + MGC optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis. - Renames examples/pumpingstation-3pumps-dashboard → pumpingstation-complete-example with regenerated flow.json. New dashboard groups, demand-broadcast wiring, S88 placement rule applied, ui-chart trend-split and link-channel naming follow .claude/rules/node-red-flow-layout.md. - New cross-node test harness under test/: end-to-end-pumpingstation drives PS + MGC + 3 pumps + physics simulator end-to-end and verifies the ~5/15 min cycle. - Adds Grafana provisioning dashboards (pumping-station.json) and a helper sync-example.sh script for export/import to live Node-RED. - Docker entrypoint + settings + compose tweaks for the persistent user dir layout used by the demo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
5.0 KiB
JavaScript
117 lines
5.0 KiB
JavaScript
// Trace recorder — hooks into every emitter and timer-driven path on a
|
|
// wired plant and records ALL events into a flat list with timestamps.
|
|
//
|
|
// Captures:
|
|
// - Per-pump state transitions (state.emitter on 'state-change' or via
|
|
// polling getCurrentState() before/after each tick).
|
|
// - Per-pump pressure events (measurements.emitter on
|
|
// 'pressure.measured.{upstream,downstream,differential}').
|
|
// - Per-pump flow / power / ctrl events (predicted variants).
|
|
// - MGC dynamic totals (after each calcDynamicTotals).
|
|
// - PS percControl + level + volume + safetyState (after each tick).
|
|
// - MGC bestCombination (instrument by wrapping optimalControl).
|
|
// - Pump operating points: individual predictFlow.currentF and
|
|
// groupPredictFlow.currentF (per tick, post-equalization).
|
|
|
|
const POSITIONS = ['upstream', 'downstream', 'differential'];
|
|
|
|
function attachRecorder({ ps, mgc, pumps }) {
|
|
const events = [];
|
|
const push = (kind, data) => events.push({ t: Date.now(), kind, ...data });
|
|
|
|
// --- pump-level: pressure events ---
|
|
for (const pump of pumps) {
|
|
const id = pump.config.general.id;
|
|
for (const pos of POSITIONS) {
|
|
const ev = `pressure.measured.${pos}`;
|
|
pump.measurements.emitter.on(ev, (e) => push('pump.pressure', {
|
|
pump: id, pos, value: e?.value, unit: e?.unit,
|
|
}));
|
|
}
|
|
// flow / power predicted (rotatingMachine emits these on state changes
|
|
// and movement updates).
|
|
pump.measurements.emitter.on('flow.predicted.downstream', (e) => push('pump.flow.predicted', {
|
|
pump: id, value: e?.value, unit: e?.unit,
|
|
}));
|
|
pump.measurements.emitter.on('power.predicted.atequipment', (e) => push('pump.power.predicted', {
|
|
pump: id, value: e?.value, unit: e?.unit,
|
|
}));
|
|
pump.measurements.emitter.on('ctrl.predicted.atequipment', (e) => push('pump.ctrl.predicted', {
|
|
pump: id, value: e?.value, unit: e?.unit,
|
|
}));
|
|
}
|
|
|
|
// --- MGC bestCombination: wrap optimalControl ---
|
|
const origOptimal = mgc.optimalControl.bind(mgc);
|
|
mgc.optimalControl = async function (Qd, powerCap = Infinity) {
|
|
push('mgc.optimalControl.in', { Qd, powerCap });
|
|
const before = snapshotMachineState(pumps);
|
|
const result = await origOptimal(Qd, powerCap);
|
|
const after = snapshotMachineState(pumps);
|
|
push('mgc.optimalControl.out', {
|
|
Qd,
|
|
headerDiffPa: pumps[0]?.groupPredictFlow?.currentF,
|
|
indivDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.predictFlow?.currentF])),
|
|
groupDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.groupPredictFlow?.currentF])),
|
|
// capture state before/after to spot transitions caused by this optimal
|
|
stateBefore: before, stateAfter: after,
|
|
});
|
|
return result;
|
|
};
|
|
|
|
return { events, push };
|
|
}
|
|
|
|
function snapshotMachineState(pumps) {
|
|
return Object.fromEntries(pumps.map(p => [
|
|
p.config.general.id,
|
|
p.state?.getCurrentState?.() ?? '?'
|
|
]));
|
|
}
|
|
|
|
function snapshotFull(ps, mgc, pumps) {
|
|
const level = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
|
const volume = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
|
return {
|
|
psLevel: round3(level),
|
|
psVolume: round3(volume),
|
|
psPercControl: round3(ps.percControl),
|
|
psSafety: ps.safetyControllerActive,
|
|
psDirection: ps.state?.direction,
|
|
psNetFlow_m3h: round3((ps.state?.netFlow ?? 0) * 3600),
|
|
pumps: Object.fromEntries(pumps.map(p => {
|
|
const id = p.config.general.id;
|
|
const flowPred = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h');
|
|
const powerPred = p.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW');
|
|
const ctrlPred = p.measurements.type('ctrl').variant('predicted').position('atEquipment').getCurrentValue();
|
|
const upPred = p.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue('mbar');
|
|
const dnPred = p.measurements.type('pressure').variant('measured').position('downstream').getCurrentValue('mbar');
|
|
return [id, {
|
|
state: p.state?.getCurrentState?.(),
|
|
ctrl_pct: round3(ctrlPred),
|
|
flow_m3h: round3(flowPred),
|
|
power_kW: round3(powerPred),
|
|
pUp_mbar: round3(upPred),
|
|
pDn_mbar: round3(dnPred),
|
|
indivDiff_mbar: round3((p.predictFlow?.currentF ?? 0) / 100),
|
|
groupDiff_mbar: round3((p.groupPredictFlow?.currentF ?? 0) / 100),
|
|
NCog: round3(p.NCog),
|
|
groupNCog: round3(p.groupNCog),
|
|
}];
|
|
})),
|
|
mgc: {
|
|
scaling: mgc.scaling,
|
|
mode: mgc.mode,
|
|
dynamicMin_m3h: round3((mgc.dynamicTotals?.flow?.min ?? 0) * 3600),
|
|
dynamicMax_m3h: round3((mgc.dynamicTotals?.flow?.max ?? 0) * 3600),
|
|
},
|
|
};
|
|
}
|
|
|
|
function round3(v) {
|
|
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
|
|
return Math.round(v * 1000) / 1000;
|
|
}
|
|
|
|
module.exports = { attachRecorder, snapshotFull, snapshotMachineState };
|