Compare commits

...

2 Commits

Author SHA1 Message Date
znetsixe
e058fe9245 P5 wave 2: convert rotatingMachine to BaseDomain + extract helper modules
specificClass.js: 1760 → 400 lines.
  Machine extends BaseDomain. configure() wires curves + predictors +
  drift + pressure + state bindings + measurement handlers + flow
  controller. ChildRouter handles pressure/flow/power/temperature
  measurement events; custom registerChild override preserves the
  dedup + virtual-vs-real pressure tracking the integration tests
  pin.

  Added small host-aware helper modules to fit the 400-line cap:
    src/prediction/predictionMath.js   (calcFlow/Power/Ctrl)
    src/prediction/efficiencyMath.js   (calcCog/EfficiencyCurve/etc.)
    src/pressure/pressureSelector.js   (getMeasuredPressure source preference)
    src/state/sequenceController.js    (executeSequence/setpoint/wait helpers)
    src/measurement/childRegistrar.js  (custom registerChild path)
    src/drift/healthRefresh.js         (drift status update wrappers)
    src/io/output.js                   (buildOutput + buildStatusBadge)

  unitPolicy: live UnitPolicy methods .canonical()/.output()/.curve()
  bridged to legacy property-path readers via a frozen view object —
  same pattern as MGC. See OPEN_QUESTIONS.md.

nodeClass.js: 433 → 61 lines.
  Extends BaseNodeAdapter. tickInterval=null (event-driven on state +
  measurement events). buildDomainConfig stamps the rotatingMachine
  state + errorMetrics slices on the domain config so configure()
  builds them from there.

5 tests adjusted (4 nodeClass-config, 1 error-paths) — pre-refactor
they pinned private methods (_loadConfig, _setupSpecificClass,
_attachInputHandler, _updateNodeStatus) that no longer exist. New
versions drive the public BaseNodeAdapter surface or call extracted
io/state-machine helpers directly. See OPEN_QUESTIONS.md 2026-05-10
"private nodeClass tests" for the deferred rewrite plan.

196 / 196 tests pass (basic 110 + integration ~80 + edge ~6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:00:34 +02:00
znetsixe
c5bb375dd0 P5 wave 1: extract rotatingMachine concerns into focused modules
src/curves/         loader + normalizer (with cross-pressure anomaly
                      detection) + reverseCurve helper
  src/prediction/     predictors (predictFlow/Power/Ctrl) +
                      groupPredictors (lazy group-scope views) +
                      OperatingPoint (pressure-driven prediction setpoints)
  src/drift/          DriftAssessor (per-metric drift) + PredictionHealth
                      (composes flow/power/pressure into HealthStatus +
                      confidence sibling — see OPEN_QUESTIONS 2026-05-10)
  src/pressure/       VirtualPressureChildren (dashboard-sim) +
                      PressureInitialization (real-vs-virtual tracking) +
                      PressureRouter (dispatches by position)
  src/state/          stateBindings (state.emitter listener helper) +
                      isOperationalState
  src/measurement/    measurementHandlers (dispatcher for flow/power/temp/pressure)
  src/flow/           flowController (handleInput body — execSequence,
                      execMovement, flowMovement, emergencystop)
  src/display/        workingCurves (showWorkingCurves + showCoG admin)
  src/commands/       canonical names: set.mode, cmd.startup/shutdown/estop,
                      set.setpoint, set.flow-setpoint,
                      data.simulate-measurement, query.curves, query.cog,
                      child.register. execSequence demuxes by payload.action
                      to canonical cmd.* handlers.
  CONTRACT.md         inputs/outputs/events/children surface

110 basic tests pass (100 new + 10 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P5 wave 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:38:45 +02:00
46 changed files with 4082 additions and 2291 deletions

94
CONTRACT.md Normal file
View File

@@ -0,0 +1,94 @@
# rotatingMachine — Contract
Hand-maintained for Phase 5; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 100 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `set.mode` | `setMode` | `string` — one of the allowed mode names | Calls `source.setMode(payload)`. |
| `cmd.startup` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'startup')`. |
| `cmd.shutdown` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'shutdown')`. |
| `cmd.estop` | `emergencystop` | `{ source?: string, action?: string }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'emergencystop')`. |
| `execSequence` | — (legacy umbrella) | `{ source, action, parameter }` with `action ∈ {'startup','shutdown'}` | Content-based router: forwards to `cmd.startup` / `cmd.shutdown` handler based on `payload.action`. Unknown action logs `warn` and is dropped. Whole topic is legacy — prefer the canonical `cmd.*` topics. |
| `set.setpoint` | `execMovement` | `{ source, action, setpoint }` — setpoint coerced to `Number` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'execMovement', Number(payload.setpoint))`. |
| `set.flow-setpoint` | `flowMovement` | `{ source, action, setpoint }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'flowMovement', Number(payload.setpoint))`. |
| `data.simulate-measurement` | `simulateMeasurement` | `{ type, position?, value, unit, timestamp? }``type ∈ {pressure, flow, temperature, power}`; `position` defaults to `'atEquipment'` | Validated dispatch: rejects non-finite `value`, unsupported `type`, missing `unit`, or unit that fails `isUnitValidForType`. Pressure routes via `updateSimulatedMeasurement(type, position, value, ctx)`; flow/temperature/power route via `updateMeasured<Type>(value, position, ctx)`. The injected `childId/childName = 'dashboard-sim'` marks the source. |
| `query.curves` | `showWorkingCurves` | none | Calls `source.showWorkingCurves()` and replies on **Port 0** with `{ topic: 'showWorkingCurves', payload: <result> }` via `ctx.send`. |
| `query.cog` | `CoG` | none | Calls `source.showCoG()` and replies on **Port 0** with `{ topic: 'showCoG', payload: <result> }`. |
| `child.register` | `registerChild` | `string` — child Node-RED id; `msg.positionVsParent` carries position | Resolves child via `RED.nodes.getNode(payload)` and registers it through `childRegistrationUtils.registerChild(child.source, msg.positionVsParent)`. Unknown ids log `warn`. |
Aliases log a one-time deprecation warning the first time they fire.
### `execSequence` demux
The pre-refactor topic `execSequence` carried `{ source, action, parameter }`
where `action` selected the verb (`startup` or `shutdown`). The command
registry does not natively dispatch by payload content, so `execSequence`
keeps its own descriptor whose handler **forwards directly** to the
canonical `cmd.startup` / `cmd.shutdown` handler based on
`payload.action`. The deprecation warning fires once. Future-Phase-7
removal of `execSequence` is a behavioural change — callers must migrate
to `cmd.startup` / `cmd.shutdown`.
## Outputs (msg.topic on Port 0/1/2)
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
`outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed
(only changed fields are emitted). On `query.curves` / `query.cog` the
node additionally emits `{ topic: 'showWorkingCurves' | 'showCoG',
payload: <result> }` as a synchronous reply on Port 0.
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
`'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'registerChild', payload: <node.id>, positionVsParent }` to
the upstream parent (typically a `machineGroupControl` or
`pumpingStation`). `positionVsParent` defaults to `'atEquipment'`.
## Events emitted by `source.measurements.emitter`
The `MeasurementContainer` fires `<type>.<variant>.<position>` whenever
the corresponding series receives a new value. Parents subscribe via the
generic `child.measurements.emitter.on(eventName, ...)` handshake.
rotatingMachine publishes:
- `flow.predicted.atequipment`, `flow.predicted.downstream`,
`flow.predicted.max`, `flow.predicted.min` — predicted operating point.
- `power.predicted.atequipment` — predicted shaft power.
- `temperature.measured.atequipment` — ambient/process temperature.
- `atmPressure.measured.atequipment` — barometric reference.
- `pressure.measured.upstream`, `pressure.measured.downstream`,
`pressure.measured.differential` — when pressure children register or
`data.simulate-measurement type=pressure` runs.
- `flow.measured.<position>`, `power.measured.atequipment`,
`temperature.measured.<position>` — when sensor children register or
the `data.simulate-measurement` topic supplies values.
Position labels are normalised to lowercase in the event name. The exact
set is data-driven by which children register and what they publish.
## Events emitted by `source.state.emitter`
- `positionChange` — fires when the position percentage changes (per
movement tick). Data: `{ position, state, mode, timestamp }`.
- `stateChange` — fires on transitions of the operating state machine
(`idle → starting → warmingup → operational → accelerating →
decelerating → stopping → coolingdown → idle`, plus `off`,
`maintenance`). Data: the new state string.
## Children registered by this node
rotatingMachine accepts `measurement` children through the
`childRegistrationUtils` handshake. Children typically have
`asset.type ∈ {pressure, flow, power, temperature}`. The machine
subscribes to the matching `<asset.type>.measured.<positionVsParent>`
event and mirrors the value into its own `MeasurementContainer`.
Two **virtual** children are reserved by the `data.simulate-measurement`
topic: incoming simulated values are tagged with
`childId/childName = 'dashboard-sim'` so dashboard-driven inputs are
distinguishable from real sensor children in downstream telemetry.
Position labels accepted from children are `upstream`, `downstream`,
`atEquipment` (and case variants — normalised internally).

150
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,150 @@
'use strict';
// Handler functions for rotatingMachine commands. Each handler receives:
// source: the domain (specificClass) instance — exposes setMode, handleInput,
// updateMeasured*, updateSimulatedMeasurement, isUnitValidForType,
// showWorkingCurves, showCoG, childRegistrationUtils, logger.
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Pure functions: validation that goes beyond the registry's typeof-check
// ladder lives here. Reply messages (query.*) use ctx.send when available.
const SUPPORTED_SIM_TYPES = new Set(['pressure', 'flow', 'temperature', 'power']);
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
function _send(ctx, ports) {
if (typeof ctx?.send === 'function') ctx.send(ports);
}
exports.setMode = (source, msg) => {
source.setMode(msg.payload);
};
// Canonical execution handlers. The legacy execSequence demuxer below
// forwards to these directly so behaviour is identical.
exports.startup = async (source, msg) => {
const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'startup');
};
exports.shutdown = async (source, msg) => {
const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'shutdown');
};
exports.estop = async (source, msg) => {
const p = msg.payload || {};
// Legacy emergencystop carried { source, action } — action defaults to
// 'emergencystop' when only source is supplied via the canonical topic.
await source.handleInput(p.source ?? 'parent', p.action ?? 'emergencystop');
};
// Content-based alias router: legacy `execSequence` carried payload.action in
// {'startup','shutdown'}. We dispatch back into the canonical handler so the
// behaviour and logs are identical regardless of which topic was used.
exports.execSequenceAlias = async (source, msg, ctx) => {
const log = _logger(source, ctx);
const action = msg?.payload?.action;
if (action === 'startup') return exports.startup(source, msg, ctx);
if (action === 'shutdown') return exports.shutdown(source, msg, ctx);
log?.warn?.(`execSequence: unsupported action '${action}'`);
};
exports.setSetpoint = async (source, msg) => {
const p = msg.payload || {};
const action = p.action ?? 'execMovement';
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
};
exports.setFlowSetpoint = async (source, msg) => {
const p = msg.payload || {};
const action = p.action ?? 'flowMovement';
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
};
exports.simulateMeasurement = (source, msg, ctx) => {
const log = _logger(source, ctx);
const payload = msg.payload || {};
const type = String(payload.type || '').toLowerCase();
const position = payload.position || 'atEquipment';
const value = Number(payload.value);
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
const context = {
timestamp: payload.timestamp || Date.now(),
unit,
childName: 'dashboard-sim',
childId: 'dashboard-sim',
};
if (!Number.isFinite(value)) {
log?.warn?.('simulateMeasurement payload.value must be a finite number');
return;
}
if (!SUPPORTED_SIM_TYPES.has(type)) {
log?.warn?.(`Unsupported simulateMeasurement type: ${type}`);
return;
}
if (!unit) {
log?.warn?.('simulateMeasurement payload.unit is required');
return;
}
if (typeof source.isUnitValidForType === 'function' &&
!source.isUnitValidForType(type, unit)) {
log?.warn?.(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
return;
}
_dispatchSimulated(source, type, position, value, context);
};
function _dispatchSimulated(source, type, position, value, context) {
switch (type) {
case 'pressure':
if (typeof source.updateSimulatedMeasurement === 'function') {
source.updateSimulatedMeasurement(type, position, value, context);
} else {
source.updateMeasuredPressure(value, position, context);
}
return;
case 'flow':
source.updateMeasuredFlow(value, position, context);
return;
case 'temperature':
source.updateMeasuredTemperature(value, position, context);
return;
case 'power':
source.updateMeasuredPower(value, position, context);
return;
}
}
exports.queryCurves = (source, msg, ctx) => {
const reply = Object.assign({}, msg, {
topic: 'showWorkingCurves',
payload: source.showWorkingCurves(),
});
_send(ctx, [reply, null, null]);
};
exports.queryCog = (source, msg, ctx) => {
const reply = Object.assign({}, msg, {
topic: 'showCoG',
payload: source.showCoG(),
});
_send(ctx, [reply, null, null]);
};
exports.registerChild = (source, msg, ctx) => {
const log = _logger(source, ctx);
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj || !childObj.source) {
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};

85
src/commands/index.js Normal file
View File

@@ -0,0 +1,85 @@
'use strict';
// rotatingMachine command registry. Consumed by BaseNodeAdapter via
// `static commands = require('./commands')`. Each descriptor maps a
// canonical msg.topic to its handler; legacy names are listed under
// `aliases` and emit a one-time deprecation warning at runtime.
//
// `execSequence` is special: the legacy payload carried `{source, action,
// parameter}` where `action` selected the canonical verb (startup /
// shutdown). The registry does not natively dispatch by payload content,
// so we keep `execSequence` as its own descriptor whose handler routes to
// the canonical `cmd.startup` / `cmd.shutdown` handler. Behaviour matches
// the canonical topics exactly; the deprecation warning still fires once.
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
handler: handlers.setMode,
},
{
topic: 'cmd.startup',
payloadSchema: { type: 'any' },
handler: handlers.startup,
},
{
topic: 'cmd.shutdown',
payloadSchema: { type: 'any' },
handler: handlers.shutdown,
},
{
topic: 'cmd.estop',
aliases: ['emergencystop'],
payloadSchema: { type: 'any' },
handler: handlers.estop,
},
{
// Legacy umbrella topic. Content-based demux inside the handler routes
// to the canonical startup / shutdown logic. Emits the registry's
// one-time deprecation warning the first time it fires.
topic: 'execSequence',
payloadSchema: { type: 'object' },
handler: handlers.execSequenceAlias,
_legacy: true,
},
{
topic: 'set.setpoint',
aliases: ['execMovement'],
payloadSchema: { type: 'object' },
handler: handlers.setSetpoint,
},
{
topic: 'set.flow-setpoint',
aliases: ['flowMovement'],
payloadSchema: { type: 'object' },
handler: handlers.setFlowSetpoint,
},
{
topic: 'data.simulate-measurement',
aliases: ['simulateMeasurement'],
payloadSchema: { type: 'object' },
handler: handlers.simulateMeasurement,
},
{
topic: 'query.curves',
aliases: ['showWorkingCurves'],
payloadSchema: { type: 'any' },
handler: handlers.queryCurves,
},
{
topic: 'query.cog',
aliases: ['CoG'],
payloadSchema: { type: 'any' },
handler: handlers.queryCog,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'string' },
handler: handlers.registerChild,
},
];

19
src/curves/curveLoader.js Normal file
View File

@@ -0,0 +1,19 @@
const { loadCurve } = require('generalFunctions');
/**
* Resolve a raw curve dataset by model name. Pure wrapper around
* generalFunctions.loadCurve so the constructor doesn't have to encode the
* "no model"/"model not found" error states inline.
*/
function loadModelCurve(model) {
if (!model) {
return { rawCurve: null, error: 'Model not specified' };
}
const raw = loadCurve(model);
if (!raw) {
return { rawCurve: null, error: `Curve not found for model ${model}` };
}
return { rawCurve: raw, error: null };
}
module.exports = { loadModelCurve };

View File

@@ -0,0 +1,117 @@
const { convert } = require('generalFunctions');
/**
* Strict numeric unit conversion. Mirrors specificClass._convertUnitValue
* so the curve normalizer is testable without a Machine instance.
*/
function convertUnitValue(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`${contextLabel}: value '${value}' is not finite`);
}
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
return convert(numeric).from(fromUnit).to(toUnit);
}
/**
* Convert one curve section (nq or np) from supplied units to canonical
* units. Logs a warning when the per-pressure median y jumps by more than
* 3x relative to the previous pressure level — that almost always means the
* curve file is corrupt (mixed units, swapped rows) and the predict module
* would otherwise silently produce nonsense values.
*/
function normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName, logger) {
const normalized = {};
let prevMedianY = null;
for (const [pressureKey, pair] of Object.entries(section || {})) {
const canonicalPressure = convertUnitValue(
Number(pressureKey),
fromPressureUnit,
toPressureUnit,
`${sectionName} pressure axis`
);
const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : [];
const yArray = Array.isArray(pair?.y)
? pair.y.map((v) => convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`))
: [];
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
}
const sortedY = [...yArray].sort((a, b) => a - b);
const medianY = sortedY[Math.floor(sortedY.length / 2)];
if (prevMedianY != null && prevMedianY > 0) {
const ratio = medianY / prevMedianY;
if (ratio > 3 || ratio < 0.33) {
const msg = `Curve anomaly in ${sectionName} at pressure ${pressureKey}: median y=${medianY.toFixed(2)} ` +
`deviates ${ratio.toFixed(1)}x from adjacent level (${prevMedianY.toFixed(2)}). Check curve data.`;
if (logger && typeof logger.warn === 'function') {
logger.warn(msg);
}
}
}
prevMedianY = medianY;
normalized[String(canonicalPressure)] = { x: xArray, y: yArray };
}
return normalized;
}
/**
* Normalize a raw machine curve ({nq, np}) into canonical SI units, using
* the unit declarations on the supplied UnitPolicy. `unitPolicy.curve` is
* the source unit map; `unitPolicy.canonical(type)` gives the target.
*/
function normalizeMachineCurve(rawCurve, unitPolicy, logger) {
if (!rawCurve || typeof rawCurve !== 'object' || !rawCurve.nq || !rawCurve.np) {
throw new Error('Machine curve is missing required nq/np sections.');
}
const curveUnits = readCurveUnits(unitPolicy);
const canonicalFlow = readCanonical(unitPolicy, 'flow');
const canonicalPower = readCanonical(unitPolicy, 'power');
const canonicalPressure = readCanonical(unitPolicy, 'pressure');
return {
nq: normalizeCurveSection(
rawCurve.nq,
curveUnits.flow,
canonicalFlow,
curveUnits.pressure,
canonicalPressure,
'nq',
logger
),
np: normalizeCurveSection(
rawCurve.np,
curveUnits.power,
canonicalPower,
curveUnits.pressure,
canonicalPressure,
'np',
logger
),
};
}
// UnitPolicy stores curve units as a frozen object on `_curve`, exposed via
// `curve(type)`. Accept either the live UnitPolicy or a plain {curve, canonical}
// bag so the normalizer can also be driven from raw config fixtures in tests.
function readCurveUnits(unitPolicy) {
if (!unitPolicy) return {};
if (typeof unitPolicy.curve === 'function') {
return {
flow: unitPolicy.curve('flow'),
power: unitPolicy.curve('power'),
pressure: unitPolicy.curve('pressure'),
};
}
return unitPolicy.curve || {};
}
function readCanonical(unitPolicy, type) {
if (!unitPolicy) return null;
if (typeof unitPolicy.canonical === 'function') return unitPolicy.canonical(type);
return (unitPolicy.canonical || {})[type] || null;
}
module.exports = { normalizeMachineCurve, normalizeCurveSection, convertUnitValue };

View File

@@ -0,0 +1,17 @@
/**
* Swap x and y of every pressure-keyed section so a forward "ctrl -> flow"
* curve becomes a reverse "flow -> ctrl" curve. Used to build predictCtrl
* from the same nq data feeding predictFlow.
*/
function reverseCurve(curveSection) {
const reversed = {};
for (const [pressure, values] of Object.entries(curveSection || {})) {
reversed[pressure] = {
x: [...values.y],
y: [...values.x],
};
}
return reversed;
}
module.exports = { reverseCurve };

View File

@@ -0,0 +1,61 @@
/**
* Read-only snapshots of the active machine curves and the centre-of-gravity
* statistics. These back the rotatingMachine admin endpoints used by the
* editor (`/rotatingMachine/working-curves`, `/rotatingMachine/cog`).
*
* Both functions accept a single `predictors` argument — an object describing
* the current curve state. By taking everything via that one parameter the
* helpers stay pure and trivially testable with a plain fixture; the host
* just passes itself (or a slim adapter) in.
*
* Expected shape of `predictors`:
* {
* hasCurve: boolean,
* predictFlow, predictPower, // generalFunctions/predict instances
* getCurrentCurves(): { powerCurve, flowCurve },
* calcCog(): { cog, cogIndex, NCog, minEfficiency },
* cog, cogIndex, NCog,
* minEfficiency,
* currentEfficiencyCurve,
* absDistFromPeak, relDistFromPeak,
* }
*/
const NO_CURVE_ERROR = 'No curve data available';
function showCoG(predictors) {
if (!predictors || !predictors.hasCurve) {
return { error: NO_CURVE_ERROR, cog: 0, NCog: 0, cogIndex: 0 };
}
const { cog, cogIndex, NCog, minEfficiency } = predictors.calcCog();
return {
cog,
cogIndex,
NCog,
NCogPercent: Math.round(NCog * 100 * 100) / 100,
minEfficiency,
currentEfficiencyCurve: predictors.currentEfficiencyCurve,
absDistFromPeak: predictors.absDistFromPeak,
relDistFromPeak: predictors.relDistFromPeak,
};
}
function showWorkingCurves(predictors) {
if (!predictors || !predictors.hasCurve) {
return { error: NO_CURVE_ERROR };
}
const { powerCurve, flowCurve } = predictors.getCurrentCurves();
return {
powerCurve,
flowCurve,
cog: predictors.cog,
cogIndex: predictors.cogIndex,
NCog: predictors.NCog,
minEfficiency: predictors.minEfficiency,
currentEfficiencyCurve: predictors.currentEfficiencyCurve,
absDistFromPeak: predictors.absDistFromPeak,
relDistFromPeak: predictors.relDistFromPeak,
};
}
module.exports = { showWorkingCurves, showCoG };

135
src/drift/driftAssessor.js Normal file
View File

@@ -0,0 +1,135 @@
'use strict';
/**
* DriftAssessor — extracted from rotatingMachine specificClass.
*
* Wraps the generalFunctions errorMetrics into a per-metric drift
* pipeline (flow / power). Holds the latest drift objects so
* predictionHealth can reuse them; the host node still mirrors them
* onto its own fields for output compatibility.
*/
class DriftAssessor {
/**
* @param {object} ctx
* - errorMetrics: assessPoint(metricId, predicted, measured, opts) + assessDrift(...)
* - measurements: MeasurementContainer (for assessDrift history pulls)
* - driftProfiles: { flow, power, ... }
* - resolveProcessRange(metricId, predicted, measured) -> { processMin, processMax }
* - measurementPositionForMetric(metricId) -> string
* - logger: { warn, debug, ... }
*/
constructor(ctx = {}) {
this.errorMetrics = ctx.errorMetrics;
this.measurements = ctx.measurements;
this.driftProfiles = ctx.driftProfiles || {};
this.resolveProcessRange = ctx.resolveProcessRange;
this.measurementPositionForMetric = ctx.measurementPositionForMetric;
this.logger = ctx.logger || { warn() {}, debug() {} };
this.latest = { flow: null, power: null };
}
/**
* Compute drift for a metric given a freshly-arrived measured value.
* Returns the drift object (or null on error / non-finite inputs).
*/
updateMetricDrift(metricId, measuredValue, context = {}) {
const position = this._positionForMetric(metricId);
const predictedValue = this._getPredicted(metricId, position);
const measured = Number(measuredValue);
if (!Number.isFinite(predictedValue) || !Number.isFinite(measured)) return null;
const { processMin, processMax } = this._processRange(metricId, predictedValue, measured);
const timestamp = Number(context.timestamp || Date.now());
const profile = this.driftProfiles[metricId] || {};
try {
const drift = this.errorMetrics.assessPoint(metricId, predictedValue, measured, {
...profile,
processMin,
processMax,
predictedTimestamp: timestamp,
measuredTimestamp: timestamp,
});
if (drift && drift.valid) this.latest[metricId] = drift;
return drift;
} catch (err) {
this.logger.warn(`Drift update failed for metric '${metricId}': ${err.message}`);
return null;
}
}
/**
* Pull stored predicted/measured series and run a full drift assessment.
*/
assessDrift(measurement, processMin, processMax) {
const metricId = String(measurement || '').toLowerCase();
const position = this._positionForMetric(metricId);
const predicted = this.measurements
?.type(metricId).variant('predicted').position(position).getAllValues();
const measured = this.measurements
?.type(metricId).variant('measured').position(position).getAllValues();
if (!predicted?.values || !measured?.values) return null;
return this.errorMetrics.assessDrift(
predicted.values,
measured.values,
processMin,
processMax,
{
metricId,
predictedTimestamps: predicted.timestamps,
measuredTimestamps: measured.timestamps,
...(this.driftProfiles[metricId] || {}),
},
);
}
/**
* Pure helper: reduce a confidence figure by drift severity and push
* matching flag strings. Returns the updated confidence.
*/
applyDriftPenalty(drift, confidence, flags, prefix) {
if (!drift || !drift.valid || !Number.isFinite(drift.nrmse)) return confidence;
if (drift.immediateLevel >= 3) {
confidence -= 0.3;
flags.push(`${prefix}_high_immediate_drift`);
} else if (drift.immediateLevel === 2) {
confidence -= 0.2;
flags.push(`${prefix}_medium_immediate_drift`);
} else if (drift.immediateLevel === 1) {
confidence -= 0.1;
flags.push(`${prefix}_low_immediate_drift`);
}
if (drift.longTermLevel >= 2) {
confidence -= 0.1;
flags.push(`${prefix}_long_term_drift`);
}
return confidence;
}
_positionForMetric(metricId) {
if (typeof this.measurementPositionForMetric === 'function') {
return this.measurementPositionForMetric(metricId);
}
return metricId === 'flow' ? 'downstream' : 'atEquipment';
}
_processRange(metricId, predicted, measured) {
if (typeof this.resolveProcessRange === 'function') {
return this.resolveProcessRange(metricId, predicted, measured);
}
const lo = Math.min(predicted, measured);
const hi = Math.max(predicted, measured);
return { processMin: lo, processMax: hi > lo ? hi : lo + 1 };
}
_getPredicted(metricId, position) {
return Number(
this.measurements
?.type(metricId).variant('predicted').position(position).getCurrentValue(),
);
}
}
module.exports = DriftAssessor;

View File

@@ -0,0 +1,45 @@
/**
* Composes the per-tick pressure-drift status + the PredictionHealth
* shape used by the orchestrator. Lives separately from
* DriftAssessor/PredictionHealth so the orchestrator only calls one
* function per refresh.
*/
'use strict';
const PredictionHealth = require('./predictionHealth');
function updatePressureDriftStatus(host) {
const status = host.getPressureInitializationStatus();
const flags = [];
let level = 0;
if (!status.initialized) { level = 2; flags.push('no_pressure_input'); }
else if (!status.hasDifferential) { level = 1; flags.push('single_side_pressure'); }
if (status.hasDifferential) {
const diff = Number(host._getPreferredPressureValue('downstream')) - Number(host._getPreferredPressureValue('upstream'));
if (Number.isFinite(diff) && diff < 0) { level = Math.max(level, 3); flags.push('negative_pressure_differential'); }
}
host.pressureDrift = { level, source: status.source, flags: flags.length ? flags : ['nominal'] };
return host.pressureDrift;
}
function updatePredictionHealth(host) {
const pressureDrift = updatePressureDriftStatus(host);
const helper = new PredictionHealth({
getPressureInitializationStatus: () => host.getPressureInitializationStatus(),
isOperational: () => host._isOperationalState(),
applyDriftPenalty: (d, c, f, p) => host._applyDriftPenalty(d, c, f, p),
resolveSetpointBounds: () => host._resolveSetpointBounds(),
getCurrentPosition: () => host.state?.getCurrentPosition?.(),
});
const { health, confidence } = helper.evaluate({ flow: host.flowDrift, power: host.powerDrift, pressure: pressureDrift });
const quality = confidence >= 0.8 ? 'high' : confidence >= 0.55 ? 'medium' : confidence >= 0.3 ? 'low' : 'invalid';
host.predictionHealth = {
quality, confidence,
pressureSource: health.source ?? pressureDrift.source ?? null,
flags: Array.isArray(health.flags) && health.flags.length ? [...health.flags] : ['nominal'],
};
return host.predictionHealth;
}
module.exports = { updatePressureDriftStatus, updatePredictionHealth };

View File

@@ -0,0 +1,132 @@
'use strict';
const { HealthStatus } = require('generalFunctions');
/**
* PredictionHealth — composes per-metric drift snapshots + pressure
* initialization status into a single HealthStatus plus a numeric
* confidence figure.
*
* Per OPEN_QUESTIONS.md 2026-05-10: HealthStatus carries the standard
* five fields; `confidence` is returned as a sibling on the result.
*/
class PredictionHealth {
/**
* @param {object} ctx
* - getPressureInitializationStatus() -> { initialized, hasDifferential, source, ... }
* - isOperational() -> boolean
* - applyDriftPenalty(drift, confidence, flags, prefix) -> confidence (from DriftAssessor)
* - resolveSetpointBounds?() -> { min, max }
* - getCurrentPosition?() -> number
*/
constructor(ctx = {}) {
this.getPressureInitializationStatus = ctx.getPressureInitializationStatus;
this.isOperational = ctx.isOperational || (() => true);
this.applyDriftPenalty = ctx.applyDriftPenalty || ((_d, c) => c);
this.resolveSetpointBounds = ctx.resolveSetpointBounds;
this.getCurrentPosition = ctx.getCurrentPosition;
}
/**
* @param {object} driftSnapshots — { flow, power, pressure }
* pressure: { level, flags, source } (already-assessed pressure-drift status)
* @returns {{ health: object, confidence: number }}
* health is a frozen HealthStatus shape; confidence ∈ [0,1].
*/
evaluate(driftSnapshots = {}) {
const pressureDrift = driftSnapshots.pressure || { level: 0, flags: [], source: null };
const status = this._safePressureStatus();
const flags = Array.isArray(pressureDrift.flags) ? [...pressureDrift.flags] : [];
let confidence = this._baseConfidenceFromSource(status.source);
if (!this.isOperational()) {
confidence = 0;
flags.push('not_operational');
}
confidence = this._penaltyForPressureDriftLevel(pressureDrift.level, confidence);
confidence = this._penaltyForCurveEdge(confidence, flags);
confidence = this.applyDriftPenalty(driftSnapshots.flow, confidence, flags, 'flow');
confidence = this.applyDriftPenalty(driftSnapshots.power, confidence, flags, 'power');
confidence = Math.max(0, Math.min(1, confidence));
const dedupedFlags = flags.length ? Array.from(new Set(flags)) : ['nominal'];
const worstLevel = this._worstLevelFromSnapshots(pressureDrift, driftSnapshots, dedupedFlags);
const hasNonNominal = dedupedFlags.some((f) => f !== 'nominal');
const effectiveLevel = hasNonNominal ? Math.max(1, worstLevel) : worstLevel;
const sourceTag = pressureDrift.source ?? status.source ?? null;
const health = effectiveLevel === 0
? HealthStatus.ok(this._qualityLabel(confidence), sourceTag)
: HealthStatus.degraded(
effectiveLevel,
dedupedFlags,
this._qualityLabel(confidence),
sourceTag,
);
return { health, confidence };
}
_safePressureStatus() {
if (typeof this.getPressureInitializationStatus !== 'function') {
return { initialized: false, hasDifferential: false, source: null };
}
return this.getPressureInitializationStatus() || { source: null };
}
_baseConfidenceFromSource(source) {
if (source === 'differential') return 0.9;
if (source === 'upstream' || source === 'downstream') return 0.55;
return 0.2;
}
_penaltyForPressureDriftLevel(level, confidence) {
if (level >= 3) return confidence - 0.35;
if (level === 2) return confidence - 0.2;
if (level === 1) return confidence - 0.1;
return confidence;
}
_penaltyForCurveEdge(confidence, flags) {
if (typeof this.getCurrentPosition !== 'function' || typeof this.resolveSetpointBounds !== 'function') {
return confidence;
}
const cur = Number(this.getCurrentPosition());
const bounds = this.resolveSetpointBounds() || {};
const { min, max } = bounds;
if (Number.isFinite(cur) && Number.isFinite(min) && Number.isFinite(max) && max > min) {
const span = max - min;
const edgeDist = Math.min(Math.abs(cur - min), Math.abs(max - cur));
if (edgeDist < span * 0.05) {
flags.push('near_curve_edge');
return confidence - 0.1;
}
}
return confidence;
}
_worstLevelFromSnapshots(pressureDrift, snaps, flags) {
let worst = Number.isFinite(pressureDrift.level) ? pressureDrift.level : 0;
for (const id of ['flow', 'power']) {
const d = snaps[id];
if (!d || !d.valid) continue;
const lvl = Math.max(d.immediateLevel || 0, d.longTermLevel || 0);
if (lvl > worst) worst = lvl;
}
if (flags.includes('not_operational') && worst < 2) worst = 2;
return Math.max(0, Math.min(3, worst));
}
_qualityLabel(confidence) {
if (confidence >= 0.8) return 'high';
if (confidence >= 0.55) return 'medium';
if (confidence >= 0.3) return 'low';
return 'invalid';
}
}
module.exports = PredictionHealth;

View File

@@ -0,0 +1,85 @@
/**
* Dispatches inbound control actions (execSequence / execMovement /
* flowMovement / emergencyStop / enter|exitMaintenance / statusCheck)
* to the state machine and motion helpers on the host.
*
* Behaviour mirrors the original specificClass.handleInput exactly:
* - actions are lower-cased
* - mode/source gating runs first
* - flow-setpoints are unit-converted (output -> canonical) before
* calcCtrl + setpoint
* - thrown errors are caught + logged (no re-throw) so a misbehaving
* parent never crashes the FSM
*/
class FlowController {
constructor(ctx) {
if (!ctx || !ctx.host) {
throw new Error('FlowController: ctx.host is required');
}
this.host = ctx.host;
this.logger = ctx.logger || ctx.host.logger;
}
async handle(source, action, parameter) {
const host = this.host;
if (typeof action !== 'string') {
this.logger.error('Action must be string');
return;
}
action = action.toLowerCase();
if (!host.isValidActionForMode(action, host.currentMode)) return;
if (!host.isValidSourceForMode(source, host.currentMode)) return;
this.logger.info(
`Handling input from source '${source}' with action '${action}' in mode '${host.currentMode}'.`,
);
try {
switch (action) {
case 'execsequence':
return await host.executeSequence(parameter);
case 'execmovement':
return await host.setpoint(parameter);
case 'entermaintenance':
case 'exitmaintenance':
return await host.executeSequence(parameter);
case 'flowmovement': {
const canonicalFlowSetpoint = host._convertUnitValue(
parameter,
host.unitPolicy.output.flow,
host.unitPolicy.canonical.flow,
'flowmovement setpoint',
);
const pos = host.calcCtrl(canonicalFlowSetpoint);
return await host.setpoint(pos);
}
case 'emergencystop':
this.logger.warn(`Emergency stop activated by '${source}'.`);
return await host.executeSequence('emergencystop');
case 'statuscheck':
this.logger.info(
`Status Check: Mode = '${host.currentMode}', Source = '${source}'.`,
);
break;
default:
this.logger.warn(`Action '${action}' is not implemented.`);
break;
}
this.logger.debug(`Action '${action}' successfully executed`);
return { status: true, feedback: `Action '${action}' successfully executed.` };
} catch (error) {
this.logger.error(`Error handling input: ${error}`);
}
}
}
module.exports = FlowController;

90
src/io/output.js Normal file
View File

@@ -0,0 +1,90 @@
/**
* Snapshot builders for rotatingMachine Port 0 output + Node-RED status
* badge. Behaviour preserved verbatim from the pre-refactor surface so
* dashboards and downstream consumers (formatMsg, status loops) keep
* working.
*/
'use strict';
const { statusBadge } = require('generalFunctions');
const STATE_SYMBOLS = {
off: '⬛', idle: '⏸️', operational: '⏵️',
starting: '⏯️', warmingup: '🔄', accelerating: '⏩',
stopping: '⏹️', coolingdown: '❄️',
decelerating: '⏪', maintenance: '🔧',
};
const FILL = {
off: 'red', idle: 'blue',
operational: 'green', warmingup: 'green',
starting: 'yellow', accelerating: 'yellow', stopping: 'yellow',
coolingdown: 'yellow', decelerating: 'yellow', maintenance: 'grey',
};
const SHOW_METRICS = new Set(['operational', 'warmingup', 'accelerating', 'decelerating']);
function buildOutput(host) {
const o = host.measurements.getFlattenedOutput({ requestedUnits: host.unitPolicyView.output });
o.state = host.state.getCurrentState();
o.runtime = host.state.getRunTimeHours();
o.ctrl = host.state.getCurrentPosition();
o.moveTimeleft = host.state.getMoveTimeLeft();
o.mode = host.currentMode;
o.cog = host.cog; o.NCog = host.NCog;
o.NCogPercent = Math.round(host.NCog * 100 * 100) / 100;
o.maintenanceTime = host.state.getMaintenanceTimeHours();
if (host.flowDrift != null) {
const f = host.flowDrift;
o.flowNrmse = f.nrmse;
o.flowLongterNRMSD = f.longTermNRMSD;
o.flowLongTermNRMSD = f.longTermNRMSD;
o.flowImmediateLevel = f.immediateLevel;
o.flowLongTermLevel = f.longTermLevel;
o.flowDriftValid = f.valid;
}
if (host.powerDrift != null) {
const p = host.powerDrift;
o.powerNrmse = p.nrmse;
o.powerLongTermNRMSD = p.longTermNRMSD;
o.powerImmediateLevel = p.immediateLevel;
o.powerLongTermLevel = p.longTermLevel;
o.powerDriftValid = p.valid;
}
o.pressureDriftLevel = host.pressureDrift.level;
o.pressureDriftSource = host.pressureDrift.source;
o.pressureDriftFlags = host.pressureDrift.flags;
o.predictionQuality = host.predictionHealth.quality;
o.predictionConfidence = Math.round(host.predictionHealth.confidence * 1000) / 1000;
o.predictionPressureSource = host.predictionHealth.pressureSource;
o.predictionFlags = host.predictionHealth.flags;
o.effDistFromPeak = host.absDistFromPeak;
o.effRelDistFromPeak = host.relDistFromPeak;
return o;
}
function buildStatusBadge(host) {
try {
const stateName = host.state?.getCurrentState?.() ?? 'unknown';
const needsPressure = SHOW_METRICS.has(stateName);
const ps = host.pressureInit?.getStatus?.() ?? { initialized: true };
if (needsPressure && !ps.initialized) {
return statusBadge.text(`${host.currentMode}: pressure not initialized`, { fill: 'yellow', shape: 'ring' });
}
const symbol = STATE_SYMBOLS[stateName] || '❔';
const fill = FILL[stateName] || 'grey';
const parts = [`${host.currentMode}: ${symbol}`];
if (SHOW_METRICS.has(stateName)) {
const fu = host.unitPolicyView.output.flow || 'm3/h';
const flow = Math.round(host.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(fu) ?? 0);
const power = Math.round(host.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW') ?? 0);
const pos = Math.round((host.state?.getCurrentPosition?.() ?? 0) * 100) / 100;
parts.push(`${pos}%`, `💨${flow}${fu}`, `${power}kW`);
}
return statusBadge.compose(parts, { fill, shape: 'dot' });
} catch (err) {
host.logger?.error?.(`getStatusBadge: ${err.message}`);
return statusBadge.error('Status Error');
}
}
module.exports = { buildOutput, buildStatusBadge };

View File

@@ -0,0 +1,47 @@
/**
* registerChild adapter for rotatingMachine. Custom because:
* - virtual + real pressure children share the upstream/downstream
* position slots; real ones must be tracked for the preference order
* - re-registration of the same child must dedup the emitter listener
* - non-measurement softwareTypes are no-ops (Machine has no children
* other than measurement nodes today)
*/
'use strict';
function registerMeasurementChild(host, child, softwareType) {
const swType = softwareType || child?.config?.functionality?.softwareType || 'measurement';
host.logger.debug(`Setting up child event for softwaretype ${swType}`);
if (swType !== 'measurement') return;
const position = String(child.config.functionality.positionVsParent || 'atEquipment').toLowerCase();
const measurementType = child.config.asset.type;
const childId = child.config?.general?.id || `${measurementType}-${position}-unknown`;
const isVirtual = Object.values(host.virtualPressureChildIds).includes(childId);
if (measurementType === 'pressure' && !isVirtual) host.realPressureChildIds[position]?.add(childId);
const eventName = `${measurementType}.measured.${position}`;
const key = `${childId}:${eventName}`;
const existing = host.childMeasurementListeners.get(key);
if (existing) {
if (typeof existing.emitter.off === 'function') existing.emitter.off(existing.eventName, existing.handler);
else if (typeof existing.emitter.removeListener === 'function') existing.emitter.removeListener(existing.eventName, existing.handler);
}
const handler = (eventData) => {
host.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
host._callMeasurementHandler(measurementType, eventData.value, position, eventData);
};
child.measurements.emitter.on(eventName, handler);
host.childMeasurementListeners.set(key, { emitter: child.measurements.emitter, eventName, handler });
}
function detachAllListeners(host) {
if (!host.childMeasurementListeners) return;
for (const [, e] of host.childMeasurementListeners) {
if (typeof e.emitter?.off === 'function') e.emitter.off(e.eventName, e.handler);
else if (typeof e.emitter?.removeListener === 'function') e.emitter.removeListener(e.eventName, e.handler);
}
host.childMeasurementListeners.clear();
}
module.exports = { registerMeasurementChild, detachAllListeners };

View File

@@ -0,0 +1,181 @@
/**
* Centralised measurement update routing for rotatingMachine.
*
* Wraps the four measurement types coming from child measurement nodes
* (flow / power / temperature / pressure) and dispatches each to the
* appropriate handler. Pressure is delegated to the host's pressureRouter
* (built in P5.4); the other three are normalised + written + drift-tracked
* here.
*
* The handlers reach back into the host for `_resolveMeasurementUnit`,
* `_updateMetricDrift`, `_updatePredictionHealth`, `updatePosition` and the
* measurements container. Behaviour is preserved 1:1 from the original
* specificClass methods.
*/
class MeasurementHandlers {
constructor(ctx) {
if (!ctx || !ctx.host) {
throw new Error('MeasurementHandlers: ctx.host is required');
}
this.host = ctx.host;
this.logger = ctx.logger || ctx.host.logger;
}
/**
* Single entry point used by child-measurement event listeners.
* Unknown types warn and fall back to a no-op position refresh so a
* mis-configured child can't silently break the FSM tick.
*/
dispatch(measurementType, value, position, context = {}) {
switch (measurementType) {
case 'pressure':
return this.host.updateMeasuredPressure(value, position, context);
case 'flow':
return this.updateMeasuredFlow(value, position, context);
case 'power':
return this.updateMeasuredPower(value, position, context);
case 'temperature':
return this.updateMeasuredTemperature(value, position, context);
default:
this.logger.warn(`No handler for measurement type: ${measurementType}`);
return this.host.updatePosition();
}
}
updateMeasuredTemperature(value, position, context = {}) {
const host = this.host;
this.logger.debug(
`Temperature update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`,
);
let unit;
try {
unit = host._resolveMeasurementUnit('temperature', context.unit);
} catch (error) {
this.logger.warn(`Rejected temperature update: ${error.message}`);
return;
}
host.measurements
.type('temperature')
.variant('measured')
.position(position || 'atEquipment')
.child(context.childId)
.value(value, context.timestamp, unit);
}
updateMeasuredFlow(value, position, context = {}) {
const host = this.host;
if (!host._isOperationalState()) {
this.logger.warn(`Machine not operational, skipping flow update from ${context.childName || 'unknown'}`);
return;
}
this.logger.debug(`Flow update: ${value} at ${position} from ${context.childName || 'child'}`);
let unit;
try {
unit = host._resolveMeasurementUnit('flow', context.unit);
} catch (error) {
this.logger.warn(`Rejected flow update: ${error.message}`);
return;
}
host.measurements
.type('flow').variant('measured').position(position).child(context.childId)
.value(value, context.timestamp, unit);
if (host.predictFlow) {
const canonical = host.unitPolicy.canonical.flow;
const predicted = host.predictFlow.outputY || 0;
host.measurements.type('flow').variant('predicted').position('downstream')
.value(predicted, Date.now(), canonical);
host.measurements.type('flow').variant('predicted').position('atEquipment')
.value(predicted, Date.now(), canonical);
}
const measuredCanonical = host.measurements
.type('flow').variant('measured').position(position)
.getCurrentValue(host.unitPolicy.canonical.flow);
host._updateMetricDrift('flow', measuredCanonical, context);
host._updatePredictionHealth();
}
updateMeasuredPower(value, position, context = {}) {
const host = this.host;
if (!host._isOperationalState()) {
this.logger.warn(`Machine not operational, skipping power update from ${context.childName || 'unknown'}`);
return;
}
this.logger.debug(`Power update: ${value} at ${position} from ${context.childName || 'child'}`);
let unit;
try {
unit = host._resolveMeasurementUnit('power', context.unit);
} catch (error) {
this.logger.warn(`Rejected power update: ${error.message}`);
return;
}
host.measurements
.type('power').variant('measured').position(position).child(context.childId)
.value(value, context.timestamp, unit);
if (host.predictPower) {
host.measurements.type('power').variant('predicted').position('atEquipment')
.value(host.predictPower.outputY || 0, Date.now(), host.unitPolicy.canonical.power);
}
const measuredCanonical = host.measurements
.type('power').variant('measured').position(position)
.getCurrentValue(host.unitPolicy.canonical.power);
host._updateMetricDrift('power', measuredCanonical, context);
host._updatePredictionHealth();
}
/** Reconcile a measured-flow reading with the existing up/downstream slots. */
handleMeasuredFlow() {
const host = this.host;
const diff = host.measurements.type('flow').variant('measured').difference();
if (diff != null) {
if (diff.value < 0.001) { this.logger.debug(`Flow match: ${diff.value}`); return diff.value; }
this.logger.error('Something wrong with down or upstream flow measurement. Bailing out!');
return null;
}
const up = host.measurements.type('flow').variant('measured').position('upstream').getCurrentValue();
if (up != null) { this.logger.warn('Only upstream flow is present. Using it but results may be incomplete!'); return up; }
const dn = host.measurements.type('flow').variant('measured').position('downstream').getCurrentValue();
if (dn != null) { this.logger.warn('Only downstream flow is present. Using it but results may be incomplete!'); return dn; }
this.logger.error('No upstream or downstream flow measurement. Bailing out!');
return null;
}
handleMeasuredPower() {
const power = this.host.measurements.type('power').variant('measured').position('atEquipment').getCurrentValue();
if (power != null) { this.logger.debug(`Measured power: ${power}`); return power; }
this.logger.error('No measured power found. Bailing out!');
return null;
}
/** Route a dashboard-sim pressure write to its virtual child; route any
* other simulated measurement type through the normal handler dispatch. */
updateSimulatedMeasurement(type, position, value, context = {}) {
const host = this.host;
const t = String(type || '').toLowerCase();
const pos = String(position || 'atEquipment').toLowerCase();
if (t !== 'pressure') { return this.dispatch(t, value, pos, context); }
if (!host.virtualPressureChildIds[pos]) {
this.logger.warn(`Unsupported simulated pressure position '${pos}'`);
return;
}
const child = host.virtualPressureChildren[pos];
if (!child?.measurements) {
this.logger.error(`Virtual pressure child '${pos}' is missing`);
return;
}
let unit;
try { unit = host._resolveMeasurementUnit('pressure', context.unit); }
catch (err) { this.logger.warn(`Rejected simulated pressure measurement: ${err.message}`); return; }
child.measurements.type('pressure').variant('measured').position(pos)
.value(value, context.timestamp || Date.now(), unit);
}
}
module.exports = MeasurementHandlers;

View File

@@ -1,433 +1,61 @@
/** 'use strict';
* node class.js
*
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
*/
const { outputUtils, configManager, convert } = require('generalFunctions');
const Specific = require("./specificClass");
class nodeClass { const { BaseNodeAdapter, convert } = require('generalFunctions');
/** const Machine = require('./specificClass');
* Create a Node. const commands = require('./commands');
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
// Preserve RED reference for HTTP endpoints if needed // Event-driven: state + measurement events drive recomputes via the
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status // domain emitter. No tick loop. Status badge polled every second.
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed class nodeClass extends BaseNodeAdapter {
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED static DomainClass = Machine;
this.source = null; // Will hold the specific class instance static commands = commands;
this.config = null; // Will hold the merged configuration static tickInterval = null;
this._pressureInitWarned = false; static statusInterval = 1000;
// Load default & UI config buildDomainConfig(uiConfig) {
this._loadConfig(uiConfig,this.node); const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
// Stash extras on the Machine class so its constructor (called by
// Instantiate core class // BaseNodeAdapter via DomainClass) picks them up alongside the
this._setupSpecificClass(uiConfig); // machineConfig. Single-threaded JS makes the hand-off race-free.
Machine._pendingExtras = {
// Wire up event and lifecycle handlers stateConfig: {
this._bindEvents(); general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
this._registerChild(); movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode },
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
/**
* Load and merge default config with user-defined settings.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_loadConfig(uiConfig,node) {
const cfgMgr = new configManager();
const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
const curveUnits = {
pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'),
flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'),
power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'),
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
};
// Build config: base sections + rotatingMachine-specific domain config
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
flowNumber: uiConfig.flowNumber
});
// Override asset with rotatingMachine-specific fields
this.config.asset = {
...this.config.asset,
uuid: resolvedAssetUuid,
tagCode: resolvedAssetTagCode,
tagNumber: uiConfig.assetTagNumber || null,
unit: flowUnit,
curveUnits
};
// Ensure general unit uses resolved flow unit
this.config.general.unit = flowUnit;
// Utility for formatting outputs
this._output = new outputUtils();
}
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
const raw = typeof candidate === 'string' ? candidate.trim() : '';
const fallback = String(fallbackUnit || '').trim();
if (!raw) {
return fallback;
}
try {
const desc = convert().describe(raw);
if (expectedMeasure && desc.measure !== expectedMeasure) {
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
}
return raw;
} catch (error) {
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
return fallback;
}
}
_resolveControlUnitOrFallback(candidate, fallback = '%') {
const raw = typeof candidate === 'string' ? candidate.trim() : '';
return raw || fallback;
}
/**
* Instantiate the core Measurement logic and store as source.
*/
_setupSpecificClass(uiConfig) {
const machineConfig = this.config;
// need extra state for this
const stateConfig = {
general: {
logging: {
enabled: machineConfig.general.logging.enabled,
logLevel: machineConfig.general.logging.logLevel
}
},
movement: {
speed: Number(uiConfig.speed),
mode: uiConfig.movementMode
},
time: { time: {
starting: Number(uiConfig.startup), starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup),
warmingup: Number(uiConfig.warmup), stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown),
stopping: Number(uiConfig.shutdown), },
coolingdown: Number(uiConfig.cooldown) },
} errorMetricsConfig: {},
}; };
return {
this.source = new Specific(machineConfig, stateConfig); asset: {
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
//store in node tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
this.node.source = this.source; // Store the source in the node instance for easy access tagNumber: uiConfig.assetTagNumber || null,
unit: flowUnit,
} curveUnits: {
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
/** flow: _resolveUnit(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit),
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES power: _resolveUnit(uiConfig.curvePowerUnit, 'power', 'kW'),
*/ control: (typeof uiConfig.curveControlUnit === 'string' && uiConfig.curveControlUnit.trim()) || '%',
_bindEvents() { },
},
} general: { unit: flowUnit },
flowNumber: uiConfig.flowNumber,
_updateNodeStatus() { };
const m = this.source;
try {
const mode = m.currentMode;
const state = m.state.getCurrentState();
const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
const pressureStatus = typeof m.getPressureInitializationStatus === "function"
? m.getPressureInitializationStatus()
: { initialized: true };
if (requiresPressurePrediction && !pressureStatus.initialized) {
if (!this._pressureInitWarned) {
this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure.");
this._pressureInitWarned = true;
}
return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` };
}
if (pressureStatus.initialized) {
this._pressureInitWarned = false;
}
const flowUnit = m?.config?.general?.unit || 'm3/h';
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue(flowUnit));
const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW'));
let symbolState;
switch(state){
case "off":
symbolState = "⬛";
break;
case "idle":
symbolState = "⏸️";
break;
case "operational":
symbolState = "⏵️";
break;
case "starting":
symbolState = "⏯️";
break;
case "warmingup":
symbolState = "🔄";
break;
case "accelerating":
symbolState = "⏩";
break;
case "stopping":
symbolState = "⏹️";
break;
case "coolingdown":
symbolState = "❄️";
break;
case "decelerating":
symbolState = "⏪";
break;
case "maintenance":
symbolState = "🔧";
break;
}
const position = m.state.getCurrentPosition();
const roundedPosition = Math.round(position * 100) / 100;
let status;
switch (state) {
case "off":
status = { fill: "red", shape: "dot", text: `${mode}: OFF` };
break;
case "idle":
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "operational":
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
case "starting":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "warmingup":
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
case "accelerating":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
case "stopping":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "coolingdown":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
break;
case "decelerating":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
break;
default:
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
}
return status;
} catch (error) {
this.node.error("Error in updateNodeStatus: " + error.message);
return { fill: "red", shape: "ring", text: "Status Error" };
}
}
/**
* Register this node as a child upstream and downstream.
* Delayed to avoid Node-RED startup race conditions.
*/
_registerChild() {
setTimeout(() => {
this.node.send([
null,
null,
{ topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
]);
}, 100);
}
/**
* Start the periodic tick loop.
*/
_startTickLoop() {
this._startupTimeout = setTimeout(() => {
this._startupTimeout = null;
this._tickInterval = setInterval(() => this._tick(), 1000);
// Update node status on nodered screen every second
this._statusInterval = setInterval(() => {
const status = this._updateNodeStatus();
this.node.status(status);
}, 1000);
}, 1000);
}
/**
* Execute a single tick: update measurement, format and send outputs.
*/
_tick() {
//this.source.tick();
const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
// Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg, null]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on('input', async (msg, send, done) => {
const m = this.source;
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
try {
switch(msg.topic) {
case 'registerChild': {
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
this.node.warn(`registerChild failed: child '${childId}' not found or has no source`);
break;
}
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
break;
}
case 'setMode':
m.setMode(msg.payload);
break;
case 'execSequence': {
const { source, action, parameter } = msg.payload;
await m.handleInput(source, action, parameter);
break;
}
case 'execMovement': {
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
await m.handleInput(mvSource, mvAction, Number(setpoint));
break;
}
case 'flowMovement': {
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
await m.handleInput(fmSource, fmAction, Number(fmSetpoint));
break;
}
case 'emergencystop': {
const { source: esSource, action: esAction } = msg.payload;
await m.handleInput(esSource, esAction);
break;
}
case 'simulateMeasurement':
{
const payload = msg.payload || {};
const type = String(payload.type || '').toLowerCase();
const position = payload.position || 'atEquipment';
const value = Number(payload.value);
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
const supportedTypes = new Set(['pressure', 'flow', 'temperature', 'power']);
const context = {
timestamp: payload.timestamp || Date.now(),
unit,
childName: 'dashboard-sim',
childId: 'dashboard-sim',
};
if (!Number.isFinite(value)) {
this.node.warn('simulateMeasurement payload.value must be a finite number');
break;
}
if (!supportedTypes.has(type)) {
this.node.warn(`Unsupported simulateMeasurement type: ${type}`);
break;
}
if (!unit) {
this.node.warn('simulateMeasurement payload.unit is required');
break;
}
if (typeof m.isUnitValidForType === 'function' && !m.isUnitValidForType(type, unit)) {
this.node.warn(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
break;
}
switch (type) {
case 'pressure':
if (typeof m.updateSimulatedMeasurement === "function") {
m.updateSimulatedMeasurement(type, position, value, context);
} else {
m.updateMeasuredPressure(value, position, context);
}
break;
case 'flow':
m.updateMeasuredFlow(value, position, context);
break;
case 'temperature':
m.updateMeasuredTemperature(value, position, context);
break;
case 'power':
m.updateMeasuredPower(value, position, context);
break;
}
}
break;
case 'showWorkingCurves':
nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]);
break;
case 'CoG':
nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]);
break;
}
if (typeof done === 'function') done();
} catch (error) {
if (typeof done === 'function') {
done(error);
} else {
this.node.error(error, msg);
}
}
});
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearTimeout(this._startupTimeout);
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.node.status({}); // clear node status badge
// Clean up child measurement listeners
const m = this.source;
if (m?.childMeasurementListeners) {
for (const [, entry] of m.childMeasurementListeners) {
if (typeof entry.emitter?.off === 'function') {
entry.emitter.off(entry.eventName, entry.handler);
} else if (typeof entry.emitter?.removeListener === 'function') {
entry.emitter.removeListener(entry.eventName, entry.handler);
}
}
m.childMeasurementListeners.clear();
}
// Clean up state emitter listeners
if (m?.state?.emitter) {
m.state.emitter.removeAllListeners();
}
if (typeof done === 'function') done();
});
} }
} }
function _resolveUnit(candidate, expectedMeasure, fallback) {
const raw = typeof candidate === 'string' ? candidate.trim() : '';
const fb = String(fallback || '').trim();
if (!raw) return fb;
try {
const desc = convert().describe(raw);
if (expectedMeasure && desc.measure !== expectedMeasure) return fb;
return raw;
} catch (_) { return fb; }
}
module.exports = nodeClass; module.exports = nodeClass;

View File

@@ -0,0 +1,111 @@
/**
* Efficiency / CoG math for rotatingMachine. Kept as host-aware
* helpers so the orchestrator stays a thin stitch. `host` is the
* Machine instance; the helpers read its predictors + measurements
* container and update the legacy fields (cog, NCog, currentEfficiencyCurve,
* absDistFromPeak, relDistFromPeak) on it in place — matching the
* pre-refactor surface tests assert on.
*/
const { gravity, coolprop } = require('generalFunctions');
function calcEfficiencyCurve(powerCurve, flowCurve) {
const efficiencyCurve = [];
let peak = 0; let peakIndex = 0; let minEfficiency = Infinity;
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
}
powerCurve.y.forEach((power, i) => {
const flow = flowCurve.y[i];
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
efficiencyCurve.push(eff);
if (eff > peak) { peak = eff; peakIndex = i; }
if (eff < minEfficiency) minEfficiency = eff;
});
if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
return { efficiencyCurve, peak, peakIndex, minEfficiency };
}
function calcCog(host) {
if (!host.hasCurve || !host.predictFlow || !host.predictPower) {
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
}
const { powerCurve, flowCurve } = getCurrentCurves(host);
const { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve);
const yMin = host.predictFlow.currentFxyYMin;
const yMax = host.predictFlow.currentFxyYMax;
const NCog = (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
host.currentEfficiencyCurve = efficiencyCurve;
host.cog = peak;
host.cogIndex = peakIndex;
host.NCog = NCog;
host.minEfficiency = minEfficiency;
return { cog: peak, cogIndex: peakIndex, NCog, minEfficiency };
}
function getCurrentCurves(host) {
if (!host.hasCurve || !host.predictPower || !host.predictFlow) {
return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
}
return {
powerCurve: host.predictPower.currentFxyCurve[host.predictPower.currentF],
flowCurve: host.predictFlow.currentFxyCurve[host.predictFlow.currentF],
};
}
function getCompleteCurve(host) {
if (!host.hasCurve || !host.predictPower || !host.predictFlow) return { powerCurve: null, flowCurve: null };
return { powerCurve: host.predictPower.inputCurveData, flowCurve: host.predictFlow.inputCurveData };
}
function calcDistanceFromPeak(currentEfficiency, peakEfficiency) {
return Math.abs(currentEfficiency - peakEfficiency);
}
function calcRelativeDistanceFromPeak(host, currentEfficiency, maxEfficiency, minEfficiency) {
if (currentEfficiency != null && maxEfficiency !== minEfficiency) {
return host.interpolation.interpolate_lin_single_point(currentEfficiency, maxEfficiency, minEfficiency, 0, 1);
}
return 1;
}
function calcDistanceBEP(host, efficiency, maxEfficiency, minEfficiency) {
host.absDistFromPeak = calcDistanceFromPeak(efficiency, maxEfficiency);
host.relDistFromPeak = calcRelativeDistanceFromPeak(host, efficiency, maxEfficiency, minEfficiency);
return { absDistFromPeak: host.absDistFromPeak, relDistFromPeak: host.relDistFromPeak };
}
function calcEfficiency(host, power, flow, variant) {
const pressureDiff = host.measurements.type('pressure').variant('measured').difference({ unit: 'Pa' });
const g = gravity.getStandardGravity();
const temp = host.measurements.type('temperature').variant('measured').position('atEquipment').getCurrentValue('K');
const atm = host.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa');
let rho = null;
try { rho = coolprop.PropsSI('D', 'T', temp, 'P', atm, 'WasteWater'); }
catch (e) { host.logger.warn(`CoolProp density lookup failed: ${e.message}. Using fallback density.`); rho = 1000; }
const flowM3s = host.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
const powerW = host.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
host.logger.debug(`Flow : ${flowM3s} power: ${powerW}`);
if (power > 0 && flow > 0) {
host.measurements.type('efficiency').variant(variant).position('atEquipment').value(flow / power);
host.measurements.type('specificEnergyConsumption').variant(variant).position('atEquipment').value(power / flow);
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
const diffPa = Number(pressureDiff.value);
const head = (Number.isFinite(rho) && rho > 0) ? diffPa / (rho * g) : null;
const hydraulicPowerW = diffPa * flowM3s;
if (Number.isFinite(head)) host.measurements.type('pumpHead').variant(variant).position('atEquipment').value(head, Date.now(), 'm');
host.measurements.type('hydraulicPower').variant(variant).position('atEquipment').value(hydraulicPowerW, Date.now(), 'W');
host.measurements.type('nHydraulicEfficiency').variant(variant).position('atEquipment').value(hydraulicPowerW / powerW);
}
}
return host.measurements.type('efficiency').variant(variant).position('atEquipment').getCurrentValue();
}
module.exports = {
calcCog, calcEfficiencyCurve, calcEfficiency, calcDistanceBEP,
calcDistanceFromPeak, calcRelativeDistanceFromPeak,
getCurrentCurves, getCompleteCurve,
};

View File

@@ -0,0 +1,23 @@
const { predict } = require('generalFunctions');
/**
* Build group-scope predicts that share input curves (and splines) with the
* individual ones via Predict.shareInputsFrom. They maintain independent
* operating-point state so an MGC parent can evaluate every pump curve at
* one shared manifold differential without disturbing the pump's own
* sensor-driven outputs.
*
* Returns null when the source predictors are absent (curve load failed).
*/
function buildGroupPredictors(predictors) {
if (!predictors || !predictors.predictFlow || !predictors.predictPower || !predictors.predictCtrl) {
return null;
}
return {
groupPredictFlow: new predict({ shareInputsFrom: predictors.predictFlow }),
groupPredictPower: new predict({ shareInputsFrom: predictors.predictPower }),
groupPredictCtrl: new predict({ shareInputsFrom: predictors.predictCtrl }),
};
}
module.exports = { buildGroupPredictors };

View File

@@ -0,0 +1,82 @@
/**
* Pure operating-point helper. Centralises the "set the working pressure
* and read a derived value" pattern used by both the pump's own pressure
* stream and the MGC group-scope evaluation. Does NOT touch the parent
* Machine's measurements or pressure-routing — that stays in specificClass.
*
* `individual` is the {predictFlow, predictPower, predictCtrl} set from
* buildPredictors(). `group` is the optional set from buildGroupPredictors()
* (may be null when no MGC parent is active).
*/
class OperatingPoint {
constructor(individual, group = null) {
this._individual = individual || null;
this._group = group || null;
this._scope = 'individual';
}
setGroupPredictors(group) {
this._group = group || null;
}
useIndividual() {
this._scope = 'individual';
return this;
}
useGroup() {
this._scope = 'group';
return this;
}
setIndividual(pressureDiff) {
if (!this._individual) return false;
if (!Number.isFinite(pressureDiff)) return false;
this._individual.predictFlow.fDimension = pressureDiff;
this._individual.predictPower.fDimension = pressureDiff;
this._individual.predictCtrl.fDimension = pressureDiff;
return true;
}
setGroup(pressureDiff) {
if (!this._group) return false;
if (!Number.isFinite(pressureDiff)) return false;
this._group.groupPredictFlow.fDimension = pressureDiff;
this._group.groupPredictPower.fDimension = pressureDiff;
this._group.groupPredictCtrl.fDimension = pressureDiff;
return true;
}
_activeFlow() {
return this._scope === 'group' ? this._group?.groupPredictFlow : this._individual?.predictFlow;
}
_activePower() {
return this._scope === 'group' ? this._group?.groupPredictPower : this._individual?.predictPower;
}
_activeCtrl() {
return this._scope === 'group' ? this._group?.groupPredictCtrl : this._individual?.predictCtrl;
}
flowFor(ctrl) {
const p = this._activeFlow();
if (!p) return null;
p.currentX = ctrl;
return p.y(ctrl);
}
powerFor(ctrl) {
const p = this._activePower();
if (!p) return null;
p.currentX = ctrl;
return p.y(ctrl);
}
ctrlFor(flow) {
const p = this._activeCtrl();
if (!p) return null;
p.currentX = flow;
return p.y(flow);
}
}
module.exports = OperatingPoint;

View File

@@ -0,0 +1,71 @@
/**
* Curve-driven prediction math kept as host-aware helpers so the
* specificClass orchestrator stays slim. Every helper mirrors a method
* from the pre-refactor Machine class one-to-one — behaviour is
* preserved verbatim including the "no curve → log + 0" fallback shape
* and the operational-state guard.
*/
function calcFlow(host, x) {
const u = host.unitPolicyView.canonical.flow;
if (host.hasCurve) {
if (!host._isOperationalState()) {
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
host.logger.debug('Machine is not operational. Setting predicted flow to 0.');
return 0;
}
const cFlow = Math.max(0, host.predictFlow.y(x));
host.measurements.type('flow').variant('predicted').position('downstream').value(cFlow, Date.now(), u);
host.measurements.type('flow').variant('predicted').position('atEquipment').value(cFlow, Date.now(), u);
return cFlow;
}
host.logger.warn('No curve data available for flow calculation. Returning 0.');
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
return 0;
}
function calcPower(host, x) {
const u = host.unitPolicyView.canonical.power;
if (host.hasCurve) {
if (!host._isOperationalState()) {
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
host.logger.debug('Machine is not operational. Setting predicted power to 0.');
return 0;
}
const cPower = Math.max(0, host.predictPower.y(x));
host.measurements.type('power').variant('predicted').position('atEquipment').value(cPower, Date.now(), u);
return cPower;
}
host.logger.warn('No curve data available for power calculation. Returning 0.');
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
return 0;
}
function inputFlowCalcPower(host, flow) {
if (host.hasCurve) {
host.predictCtrl.currentX = flow;
const cCtrl = host.predictCtrl.y(flow);
host.predictPower.currentX = cCtrl;
return host.predictPower.y(cCtrl);
}
host.logger.warn('No curve data available for power calculation. Returning 0.');
host.measurements.type('power').variant('predicted').position('atEquipment')
.value(0, Date.now(), host.unitPolicyView.canonical.power);
return 0;
}
function calcCtrl(host, x) {
if (host.hasCurve) {
host.predictCtrl.currentX = x;
const cCtrl = host.predictCtrl.y(x);
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(cCtrl);
return cCtrl;
}
host.logger.warn('No curve data available for control calculation. Returning 0.');
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(0, Date.now());
return 0;
}
module.exports = { calcFlow, calcPower, inputFlowCalcPower, calcCtrl };

View File

@@ -0,0 +1,25 @@
const { predict } = require('generalFunctions');
const { reverseCurve } = require('../curves/reverseCurve');
/**
* Build the three individual-scope predict instances that drive a single
* pump's flow/power/ctrl outputs from its own pressure measurements.
* predictFlow: ctrl -> flow (from machineCurve.nq)
* predictPower: ctrl -> power (from machineCurve.np)
* predictCtrl: flow -> ctrl (from reversed machineCurve.nq)
*
* The reverse is built here rather than in the caller so the predictors
* folder owns the full "what is needed to predict" knowledge.
*/
function buildPredictors(machineCurve) {
if (!machineCurve || !machineCurve.nq || !machineCurve.np) {
throw new Error('buildPredictors: machineCurve.nq and .np are required');
}
return {
predictFlow: new predict({ curve: machineCurve.nq }),
predictPower: new predict({ curve: machineCurve.np }),
predictCtrl: new predict({ curve: reverseCurve(machineCurve.nq) }),
};
}
module.exports = { buildPredictors };

View File

@@ -0,0 +1,100 @@
'use strict';
/**
* PressureInitialization — tracks real pressure children per position
* and reports the overall pressure-input status (initialized, has
* differential, preferred source).
*
* Extracted from rotatingMachine specificClass.getPressureInitializationStatus
* + the realPressureChildIds set tracking.
*/
class PressureInitialization {
/**
* @param {object} ctx
* - measurements: MeasurementContainer
* - virtualPressureChildIds: { upstream, downstream }
* - realPressureChildIds?: { upstream: Set<string>, downstream: Set<string> }
* - logger
*/
constructor(ctx = {}) {
this.measurements = ctx.measurements;
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
this.realPressureChildIds = ctx.realPressureChildIds || {
upstream: new Set(),
downstream: new Set(),
};
this.logger = ctx.logger || { warn() {}, debug() {} };
}
registerReal(position, childId) {
const pos = this._normPosition(position);
if (!this.realPressureChildIds[pos]) this.realPressureChildIds[pos] = new Set();
this.realPressureChildIds[pos].add(childId);
}
unregisterReal(position, childId) {
const pos = this._normPosition(position);
if (this.realPressureChildIds[pos]) this.realPressureChildIds[pos].delete(childId);
}
/**
* @returns {{ hasUpstream, hasDownstream, hasDifferential, initialized, source }}
* source ∈ 'differential' | 'upstream' | 'downstream' | null.
* Matches the original getPressureInitializationStatus() shape.
*/
getStatus() {
const upstream = this._getPreferred('upstream');
const downstream = this._getPreferred('downstream');
const hasUpstream = upstream != null;
const hasDownstream = downstream != null;
const hasDifferential = hasUpstream && hasDownstream;
let source = null;
if (hasDifferential) source = 'differential';
else if (hasDownstream) source = 'downstream';
else if (hasUpstream) source = 'upstream';
return {
hasUpstream,
hasDownstream,
hasDifferential,
initialized: hasUpstream || hasDownstream,
source,
};
}
/**
* Get the preferred pressure value at a position. Real children win
* over virtual; final fallback is the bare (position-only) container slot.
*/
getPreferredValue(position) {
return this._getPreferred(this._normPosition(position));
}
_getPreferred(position) {
const realIds = Array.from(this.realPressureChildIds[position] || []);
for (const id of realIds) {
const v = this._readChild(position, id);
if (v != null) return v;
}
const virtualId = this.virtualPressureChildIds[position];
if (virtualId) {
const v = this._readChild(position, virtualId);
if (v != null) return v;
}
return this.measurements
?.type('pressure').variant('measured').position(position).getCurrentValue();
}
_readChild(position, childId) {
return this.measurements
?.type('pressure').variant('measured').position(position).child(childId).getCurrentValue();
}
_normPosition(position) {
return String(position || '').toLowerCase();
}
}
module.exports = PressureInitialization;

View File

@@ -0,0 +1,80 @@
'use strict';
/**
* PressureRouter — routes a measured pressure value into the right
* MeasurementContainer slot and triggers downstream side-effects
* (position recompute + drift/health refresh) only when the source
* is a real child (not a dashboard-sim virtual one).
*
* Extracted from rotatingMachine specificClass.updateMeasuredPressure.
*/
class PressureRouter {
/**
* @param {object} ctx
* - measurements: MeasurementContainer
* - virtualPressureChildIds: { upstream, downstream }
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
* - updatePosition?(): called after a real-source write
* - refreshDrift?(): called after a real-source write (e.g. _updatePressureDriftStatus)
* - refreshHealth?(): called after a real-source write (e.g. _updatePredictionHealth)
* - getPressure?(): optional, returns the current preferred pressure (for logging)
* - logger
*/
constructor(ctx = {}) {
this.measurements = ctx.measurements;
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u);
this.updatePosition = ctx.updatePosition;
this.refreshDrift = ctx.refreshDrift;
this.refreshHealth = ctx.refreshHealth;
this.getPressure = ctx.getPressure;
this.logger = ctx.logger || { warn() {}, debug() {} };
}
/**
* Route a measured pressure to the right container slot.
* @returns {boolean} true on successful write, false on rejection.
*/
route(position, value, context = {}) {
const pos = String(position || '').toLowerCase();
const childId = context.childId;
let unit;
try {
unit = this.resolveMeasurementUnit('pressure', context.unit);
} catch (err) {
this.logger.warn(`Rejected pressure update: ${err.message}`);
return false;
}
this.measurements
?.type('pressure').variant('measured').position(pos).child(childId)
.value(value, context.timestamp, unit);
const isVirtual = this._isVirtual(childId);
this.logger.debug(`Pressure routed: ${value} ${unit} at ${pos} from ${context.childName || 'child'} (${childId || 'unknown-id'}) virtual=${isVirtual}`);
if (!isVirtual) {
if (typeof this.updatePosition === 'function') this.updatePosition();
if (typeof this.refreshDrift === 'function') this.refreshDrift();
if (typeof this.refreshHealth === 'function') this.refreshHealth();
}
if (typeof this.getPressure === 'function') {
const p = this.getPressure();
this.logger.debug(`Using pressure: ${p} for calculations`);
}
return true;
}
_isVirtual(childId) {
if (childId == null) return false;
for (const id of Object.values(this.virtualPressureChildIds)) {
if (id === childId) return true;
}
return false;
}
}
module.exports = PressureRouter;

View File

@@ -0,0 +1,52 @@
/**
* Resolves the working pressure for prediction and pushes it onto
* predictFlow/predictPower/predictCtrl.fDimension. After every push the
* CoG, efficiency, and distance-from-BEP are recomputed so downstream
* state stays consistent — exactly what the pre-refactor
* getMeasuredPressure() did.
*/
const eff = require('../prediction/efficiencyMath');
function getMeasuredPressure(host) {
if (!host.hasCurve || !host.predictFlow || !host.predictPower || !host.predictCtrl) {
host.logger.error('No valid curve available to calculate prediction using last known pressure');
return 0;
}
const up = host._getPreferredPressureValue('upstream');
const dn = host._getPreferredPressureValue('downstream');
const applyDiff = (diff) => {
host.predictFlow.fDimension = diff;
host.predictPower.fDimension = diff;
host.predictCtrl.fDimension = diff;
const { cog, minEfficiency } = eff.calcCog(host);
const efficiency = eff.calcEfficiency(host, host.predictPower.outputY, host.predictFlow.outputY, 'predicted');
eff.calcDistanceBEP(host, efficiency, cog, minEfficiency);
};
if (up != null && dn != null) {
const diff = dn - up;
host.logger.debug(`Pressure differential: ${diff}`);
applyDiff(diff);
return diff;
}
if (dn != null) {
host.logger.warn(`Using downstream pressure only for prediction: ${dn}. Prediction accuracy is degraded; inject upstream pressure too.`);
applyDiff(dn);
return dn;
}
if (up != null) {
host.logger.warn(`Using upstream pressure only for prediction: ${up}. Prediction accuracy is degraded; inject downstream pressure too.`);
applyDiff(up);
return up;
}
host.logger.error('No valid pressure measurements available to calculate prediction using last known pressure');
applyDiff(0);
const fu = host.unitPolicyView.canonical.flow;
host.measurements.type('flow').variant('predicted').position('max').value(host.predictFlow.currentFxyYMax, Date.now(), fu);
host.measurements.type('flow').variant('predicted').position('min').value(host.predictFlow.currentFxyYMin, Date.now(), fu);
return 0;
}
module.exports = { getMeasuredPressure };

View File

@@ -0,0 +1,92 @@
'use strict';
const { MeasurementContainer } = require('generalFunctions');
/**
* VirtualPressureChildren — builds two dashboard-sim children backed
* by their own MeasurementContainer (upstream + downstream). Children
* are signed as belonging to a parent machine via `setParentRef`.
*
* Extracted from rotatingMachine specificClass._initVirtualPressureChildren.
*/
const DEFAULT_IDS = {
upstream: 'dashboard-sim-upstream',
downstream: 'dashboard-sim-downstream',
};
class VirtualPressureChildren {
/**
* @param {object} opts
* - logger: pass-through to MeasurementContainer
* - unitPolicy: { canonical, output }
* - parentRef: object to use as parent for setParentRef (optional)
* - ids: override the default { upstream, downstream } id pair (optional)
*/
constructor({ logger, unitPolicy, parentRef = null, ids = DEFAULT_IDS } = {}) {
this.logger = logger || { warn() {}, debug() {} };
this.unitPolicy = unitPolicy;
this.parentRef = parentRef;
this.ids = { ...DEFAULT_IDS, ...(ids || {}) };
}
/**
* @returns {{ upstream: VirtualChild, downstream: VirtualChild }}
* Each child = { config: { general, functionality, asset }, measurements }.
*/
build() {
return {
upstream: this._createChild('upstream'),
downstream: this._createChild('downstream'),
};
}
_createChild(position) {
const id = this.ids[position];
const name = `dashboard-sim-${position}`;
const measurements = new MeasurementContainer({
autoConvert: true,
defaultUnits: this._unitMap('output'),
preferredUnits: this._unitMap('output'),
canonicalUnits: this.unitPolicy?.canonical,
storeCanonical: true,
strictUnitValidation: true,
throwOnInvalidUnit: true,
requireUnitForTypes: ['pressure'],
}, this.logger);
if (typeof measurements.setChildId === 'function') measurements.setChildId(id);
if (typeof measurements.setChildName === 'function') measurements.setChildName(name);
if (this.parentRef && typeof measurements.setParentRef === 'function') {
measurements.setParentRef(this.parentRef);
}
return {
config: {
general: { id, name },
functionality: {
softwareType: 'measurement',
positionVsParent: position,
},
asset: {
type: 'pressure',
unit: this.unitPolicy?.output?.pressure,
},
},
measurements,
};
}
_unitMap(section) {
const src = this.unitPolicy?.[section] || {};
return {
pressure: src.pressure,
flow: src.flow,
power: src.power,
temperature: src.temperature,
};
}
}
VirtualPressureChildren.DEFAULT_IDS = DEFAULT_IDS;
module.exports = VirtualPressureChildren;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
/**
* Sequence + setpoint orchestration. Pre-refactor lived inline on
* Machine; extracted so the orchestrator stays focused. All behaviour
* is preserved verbatim including the interruptible-shutdown abort
* dance and the operational-state ramp-to-zero before shutdown.
*/
function resolveSetpointBounds(host) {
const stateMin = Number(host.state?.movementManager?.minPosition);
const stateMax = Number(host.state?.movementManager?.maxPosition);
const curveMin = Number(host.predictFlow?.currentFxyXMin);
const curveMax = Number(host.predictFlow?.currentFxyXMax);
const minCands = [stateMin, curveMin].filter(Number.isFinite);
const maxCands = [stateMax, curveMax].filter(Number.isFinite);
const fbMin = Number.isFinite(stateMin) ? stateMin : 0;
const fbMax = Number.isFinite(stateMax) ? stateMax : 100;
let min = minCands.length ? Math.max(...minCands) : fbMin;
let max = maxCands.length ? Math.min(...maxCands) : fbMax;
if (min > max) {
host.logger.warn(`Invalid setpoint bounds detected (min=${min}, max=${max}). Falling back to movement bounds.`);
min = fbMin; max = fbMax;
}
return { min, max };
}
async function setpoint(host, target) {
try {
if (!Number.isFinite(target)) { host.logger.error('Invalid setpoint: Setpoint must be a finite number.'); return; }
const { min, max } = resolveSetpointBounds(host);
const constrained = Math.min(Math.max(target, min), max);
if (constrained !== target) host.logger.warn(`Requested setpoint ${target} constrained to ${constrained} (min=${min}, max=${max})`);
host.logger.info(`Setting setpoint to ${constrained}. Current position: ${host.state.getCurrentPosition()}`);
await host.state.moveTo(constrained);
} catch (e) { host.logger.error(`Error setting setpoint: ${e}`); }
}
function waitForOperational(host, timeoutMs = 2000) {
if (host.state.getCurrentState() === 'operational') return Promise.resolve('operational');
return new Promise((resolve) => {
let done = false;
const timer = setTimeout(() => {
if (done) return;
done = true;
host.state.emitter.off('stateChange', onChange);
resolve(host.state.getCurrentState());
}, timeoutMs);
const onChange = (newState) => {
if (done) return;
if (newState === 'operational') {
done = true; clearTimeout(timer);
host.state.emitter.off('stateChange', onChange);
resolve('operational');
}
};
host.state.emitter.on('stateChange', onChange);
});
}
async function executeSequence(host, rawName) {
const name = typeof rawName === 'string' ? rawName.toLowerCase() : rawName;
const sequence = host.config.sequences[name];
if (!sequence || sequence.size === 0) {
host.logger.warn(`Sequence '${name}' not defined.`);
return;
}
const interruptible = new Set(['shutdown', 'emergencystop']);
if (interruptible.has(name)) host.state.delayedMove = null;
const current = host.state.getCurrentState();
if (interruptible.has(name) && (current === 'accelerating' || current === 'decelerating')) {
host.logger.warn(`Sequence '${name}' requested during '${current}'. Aborting active movement.`);
host.state.abortCurrentMovement(`${name} sequence requested`, { returnToOperational: true });
await waitForOperational(host, 2000);
}
if (host.state.getCurrentState() === 'operational' && name === 'shutdown') {
host.logger.info(`Machine will ramp down to position 0 before performing ${name} sequence`);
await setpoint(host, 0);
}
host.logger.info(` --------- Executing sequence: ${name} -------------`);
for (const s of sequence) {
try { await host.state.transitionToState(s); }
catch (e) { host.logger.error(`Error during sequence '${name}': ${e}`); break; }
}
host.updatePosition();
}
module.exports = { setpoint, executeSequence, resolveSetpointBounds, waitForOperational };

View File

@@ -0,0 +1,58 @@
/**
* Thin adapter over the generalFunctions state machine emitter.
* Holds no state of its own — exposes bind/unbind and the
* shared definition of which states count as "operational" for
* downstream measurement processing.
*/
const OPERATIONAL_STATES = [
'operational',
'accelerating',
'decelerating',
'warmingup',
];
/**
* Attaches positionChange / stateChange listeners to a state machine.
* Returns an idempotent teardown function. Both handlers are required —
* the bindings encode the lifecycle contract between the FSM and the
* specificClass orchestrator, so leaving one half wired is a bug.
*/
function bindStateEvents(ctx) {
if (!ctx || !ctx.state || !ctx.state.emitter) {
throw new Error('bindStateEvents: ctx.state.emitter is required');
}
const { state, onPositionChange, onStateChange } = ctx;
if (typeof onPositionChange !== 'function' || typeof onStateChange !== 'function') {
throw new Error('bindStateEvents: onPositionChange and onStateChange handlers are required');
}
state.emitter.on('positionChange', onPositionChange);
state.emitter.on('stateChange', onStateChange);
let removed = false;
return function teardown() {
if (removed) return;
removed = true;
state.emitter.off('positionChange', onPositionChange);
state.emitter.off('stateChange', onStateChange);
};
}
/**
* True when the FSM is in a state that should accept measurement
* updates and recompute predictions. Pure helper, accepts the state
* machine instance so callers can pass a fake in tests.
*/
function isOperationalState(stateInstance) {
if (!stateInstance || typeof stateInstance.getCurrentState !== 'function') {
return false;
}
return OPERATIONAL_STATES.includes(stateInstance.getCurrentState());
}
module.exports = {
bindStateEvents,
isOperationalState,
OPERATIONAL_STATES,
};

View File

@@ -0,0 +1,275 @@
// Basic tests for the rotatingMachine commands registry.
// Run with: node --test test/basic/commands.basic.test.js
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry } = require('generalFunctions');
const commands = require('../../src/commands');
// --- helpers ---------------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(String(m)),
error: (m) => calls.error.push(String(m)),
info: (m) => calls.info.push(String(m)),
debug: (m) => calls.debug.push(String(m)),
};
}
function makeSource({ name = 'rm-1', unitValid = true } = {}) {
const calls = {
setMode: [],
handleInput: [],
registerChild: [],
sim: [],
updatePressure: [],
updateFlow: [],
updateTemp: [],
updatePower: [],
showWorkingCurves: 0,
showCoG: 0,
};
const source = {
logger: makeLogger(),
config: { general: { name } },
setMode: (m) => calls.setMode.push(m),
handleInput: async (src, action, parameter) => {
calls.handleInput.push({ src, action, parameter });
},
isUnitValidForType: () => unitValid,
updateSimulatedMeasurement: (type, position, value, ctx) =>
calls.sim.push({ type, position, value, ctx }),
updateMeasuredPressure: (v, p, c) => calls.updatePressure.push({ v, p, c }),
updateMeasuredFlow: (v, p, c) => calls.updateFlow.push({ v, p, c }),
updateMeasuredTemperature: (v, p, c) => calls.updateTemp.push({ v, p, c }),
updateMeasuredPower: (v, p, c) => calls.updatePower.push({ v, p, c }),
showWorkingCurves: () => { calls.showWorkingCurves++; return { curves: 'mock' }; },
showCoG: () => { calls.showCoG++; return { cog: 'mock' }; },
childRegistrationUtils: {
registerChild: (childSource, position) =>
calls.registerChild.push({ childSource, position }),
},
};
return { source, calls };
}
function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) {
return {
logger,
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
node: {},
send: sendSpy || (() => {}),
};
}
function makeRegistry(logger) {
return createRegistry(commands, { logger });
}
// --- tests -----------------------------------------------------------------
test('canonical topics dispatch to their handlers', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.mode', payload: 'GUI' }, source, makeCtx());
assert.deepEqual(calls.setMode, ['GUI']);
await reg.dispatch(
{ topic: 'cmd.startup', payload: { source: 'GUI' } }, source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'startup' });
await reg.dispatch(
{ topic: 'cmd.shutdown', payload: { source: 'GUI' } }, source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
await reg.dispatch(
{ topic: 'cmd.estop', payload: { source: 'GUI', action: 'emergencystop' } }, source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'emergencystop', parameter: undefined });
await reg.dispatch(
{ topic: 'set.setpoint', payload: { source: 'GUI', action: 'execMovement', setpoint: '75' } },
source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execMovement', parameter: 75 });
await reg.dispatch(
{ topic: 'set.flow-setpoint', payload: { source: 'GUI', action: 'flowMovement', setpoint: '12' } },
source, makeCtx());
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'flowMovement', parameter: 12 });
});
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch({ topic: 'setMode', payload: 'GUI' }, source, makeCtx({ logger: ctxLogger }));
await reg.dispatch({ topic: 'setMode', payload: 'virtualControl' }, source, makeCtx({ logger: ctxLogger }));
assert.deepEqual(calls.setMode, ['GUI', 'virtualControl']);
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
assert.equal(warns.length, 1);
await reg.dispatch({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } },
source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'emergencystop' is deprecated"));
assert.equal(warns.length, 1);
await reg.dispatch({ topic: 'execMovement', payload: { source: 'GUI', action: 'execMovement', setpoint: 50 } },
source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'execMovement' is deprecated"));
assert.equal(warns.length, 1);
await reg.dispatch({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 5 } },
source, makeCtx({ logger: ctxLogger }));
warns = ctxLogger.calls.warn.filter((m) => m.includes("'flowMovement' is deprecated"));
assert.equal(warns.length, 1);
});
test('execSequence with payload.action=startup reaches cmd.startup handler', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } },
source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'startup' });
// Registry logs the legacy-topic deprecation (no canonical alias, but
// the demux handler accepts both startup/shutdown actions).
});
test('execSequence with payload.action=shutdown reaches cmd.shutdown handler', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'execSequence', payload: { source: 'GUI', action: 'shutdown' } },
source, makeCtx());
assert.equal(calls.handleInput.length, 1);
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
});
test('execSequence with unknown action logs warn and does not call handleInput', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'execSequence', payload: { source: 'GUI', action: 'frobnicate' } },
source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.handleInput.length, 0);
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('execSequence') && m.includes('frobnicate')),
`expected warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`);
});
test('data.simulate-measurement happy path dispatches to the right updater', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.simulate-measurement',
payload: { type: 'pressure', position: 'upstream', value: 1013, unit: 'mbar' } },
source, makeCtx());
assert.equal(calls.sim.length, 1);
assert.equal(calls.sim[0].type, 'pressure');
assert.equal(calls.sim[0].value, 1013);
await reg.dispatch(
{ topic: 'data.simulate-measurement',
payload: { type: 'flow', value: 30, unit: 'm3/h' } },
source, makeCtx());
assert.equal(calls.updateFlow.length, 1);
});
test('data.simulate-measurement validation: bad type / missing unit / non-finite value', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
// unsupported type
await reg.dispatch(
{ topic: 'data.simulate-measurement', payload: { type: 'voltage', value: 1, unit: 'V' } },
source, makeCtx({ logger: ctxLogger }));
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('Unsupported simulateMeasurement type: voltage')));
// missing unit
await reg.dispatch(
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 1013 } },
source, makeCtx({ logger: ctxLogger }));
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('unit is required')));
// non-finite value
await reg.dispatch(
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 'abc', unit: 'mbar' } },
source, makeCtx({ logger: ctxLogger }));
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('must be a finite number')));
// nothing was forwarded to the source
assert.equal(calls.sim.length, 0);
assert.equal(calls.updateFlow.length, 0);
assert.equal(calls.updatePressure.length, 0);
});
test('query.curves and query.cog reply on Port 0 via ctx.send', async () => {
const { source, calls } = makeSource();
const sent = [];
const ctx = makeCtx({ sendSpy: (ports) => sent.push(ports) });
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'query.curves' }, source, ctx);
await reg.dispatch({ topic: 'query.cog' }, source, ctx);
assert.equal(calls.showWorkingCurves, 1);
assert.equal(calls.showCoG, 1);
assert.equal(sent.length, 2);
// First port carries the reply; Ports 1 & 2 are null.
assert.equal(sent[0][0].topic, 'showWorkingCurves');
assert.deepEqual(sent[0][0].payload, { curves: 'mock' });
assert.equal(sent[0][1], null);
assert.equal(sent[0][2], null);
assert.equal(sent[1][0].topic, 'showCoG');
assert.deepEqual(sent[1][0].payload, { cog: 'mock' });
});
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
const { source, calls } = makeSource();
const child = { id: 'm-1', source: { tag: 'm-domain' } };
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'child.register', payload: 'm-1', positionVsParent: 'upstream' },
source,
makeCtx({ child })
);
assert.equal(calls.registerChild.length, 1);
assert.equal(calls.registerChild[0].childSource, child.source);
assert.equal(calls.registerChild[0].position, 'upstream');
});
test('child.register with unknown id logs warn and does not throw', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await assert.doesNotReject(() =>
reg.dispatch(
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
source,
makeCtx({ logger: ctxLogger })
)
);
assert.equal(calls.registerChild.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});

View File

@@ -0,0 +1,30 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadModelCurve } = require('../../src/curves/curveLoader');
test('curveLoader: valid model returns rawCurve and null error', () => {
const result = loadModelCurve('hidrostal-H05K-S03R');
assert.equal(result.error, null);
assert.ok(result.rawCurve);
assert.ok(result.rawCurve.np);
assert.ok(result.rawCurve.nq);
});
test('curveLoader: missing model returns Model not specified', () => {
const result = loadModelCurve('');
assert.equal(result.rawCurve, null);
assert.equal(result.error, 'Model not specified');
});
test('curveLoader: undefined model returns Model not specified', () => {
const result = loadModelCurve(undefined);
assert.equal(result.rawCurve, null);
assert.equal(result.error, 'Model not specified');
});
test('curveLoader: unknown model returns Curve not found error', () => {
const result = loadModelCurve('this-model-does-not-exist');
assert.equal(result.rawCurve, null);
assert.match(result.error, /Curve not found for model/);
});

View File

@@ -0,0 +1,88 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { UnitPolicy } = require('generalFunctions');
const {
normalizeMachineCurve,
normalizeCurveSection,
convertUnitValue,
} = require('../../src/curves/curveNormalizer');
function makePolicy() {
return UnitPolicy.declare({
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
curve: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
});
}
function captureLogger() {
const warns = [];
return {
warn: (m) => warns.push(m),
warns,
};
}
test('normalizeMachineCurve: rejects raw without nq/np', () => {
const policy = makePolicy();
assert.throws(() => normalizeMachineCurve(null, policy), /missing required nq\/np/);
assert.throws(() => normalizeMachineCurve({ nq: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
assert.throws(() => normalizeMachineCurve({ np: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
});
test('normalizeMachineCurve: converts pressure mbar -> Pa and flow m3/h -> m3/s', () => {
const policy = makePolicy();
const raw = {
nq: {
1000: { x: [0, 100], y: [0, 3600] }, // 3600 m3/h = 1 m3/s
},
np: {
1000: { x: [0, 100], y: [0, 1] }, // 1 kW = 1000 W
},
};
const out = normalizeMachineCurve(raw, policy);
// 1000 mbar = 100000 Pa
const pressureKey = Object.keys(out.nq)[0];
assert.equal(Number(pressureKey), 100000);
assert.ok(Math.abs(out.nq[pressureKey].y[1] - 1) < 1e-9, `expected 1 m3/s got ${out.nq[pressureKey].y[1]}`);
assert.ok(Math.abs(out.np[pressureKey].y[1] - 1000) < 1e-6, `expected 1000 W got ${out.np[pressureKey].y[1]}`);
});
test('normalizeCurveSection: warns on cross-pressure median > 3x jump', () => {
const logger = captureLogger();
const section = {
1000: { x: [0, 50, 100], y: [0, 5, 10] }, // median 5
1100: { x: [0, 50, 100], y: [0, 50, 100] }, // median 50 (10x jump)
};
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
const hit = logger.warns.find((w) => /Curve anomaly/.test(w));
assert.ok(hit, `expected a Curve anomaly warning, got: ${JSON.stringify(logger.warns)}`);
assert.match(hit, /pressure 1100/);
});
test('normalizeCurveSection: does not warn on smooth progressions', () => {
const logger = captureLogger();
const section = {
1000: { x: [0, 50, 100], y: [0, 5, 10] },
1100: { x: [0, 50, 100], y: [0, 6, 11] },
};
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
assert.equal(logger.warns.filter((w) => /Curve anomaly/.test(w)).length, 0);
});
test('normalizeCurveSection: throws when x/y length mismatch', () => {
assert.throws(
() => normalizeCurveSection({ 1000: { x: [0, 50], y: [0, 5, 10] } }, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null),
/Invalid nq section/
);
});
test('convertUnitValue: identity when units match or missing', () => {
assert.equal(convertUnitValue(42, 'm3/h', 'm3/h'), 42);
assert.equal(convertUnitValue(42, null, null), 42);
});
test('convertUnitValue: throws on non-finite input', () => {
assert.throws(() => convertUnitValue('not-a-number', 'm3/h', 'm3/s', 'test'), /not finite/);
});

View File

@@ -0,0 +1,130 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DriftAssessor = require('../../src/drift/driftAssessor');
/* ---- fakes ---- */
function fakeMeasurements(predictedValue) {
return {
type() { return this; },
variant() { return this; },
position() { return this; },
getCurrentValue() { return predictedValue; },
getAllValues() { return { values: [predictedValue], timestamps: [1] }; },
};
}
function makeErrorMetrics(driftFactory) {
return {
assessPoint: (metricId, predicted, measured, opts) => driftFactory(metricId, predicted, measured, opts),
assessDrift: () => ({ nrmse: 0.1, valid: true }),
};
}
const SILENT = { warn() {}, debug() {} };
test('updateMetricDrift returns drift object when predicted+measured both finite', () => {
const drift = { valid: true, nrmse: 0.05, immediateLevel: 0, longTermLevel: 0 };
const assessor = new DriftAssessor({
errorMetrics: makeErrorMetrics(() => drift),
measurements: fakeMeasurements(10),
driftProfiles: { flow: {} },
logger: SILENT,
});
const out = assessor.updateMetricDrift('flow', 11);
assert.deepEqual(out, drift);
assert.equal(assessor.latest.flow, drift);
});
test('updateMetricDrift returns null when predicted is non-finite', () => {
const assessor = new DriftAssessor({
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
measurements: fakeMeasurements(NaN),
driftProfiles: {},
logger: SILENT,
});
assert.equal(assessor.updateMetricDrift('flow', 5), null);
});
test('updateMetricDrift catches errorMetrics throw and logs', () => {
const warns = [];
const assessor = new DriftAssessor({
errorMetrics: { assessPoint() { throw new Error('boom'); } },
measurements: fakeMeasurements(10),
driftProfiles: {},
logger: { warn(m) { warns.push(m); }, debug() {} },
});
const out = assessor.updateMetricDrift('flow', 11);
assert.equal(out, null);
assert.match(warns[0], /Drift update failed for metric 'flow'/);
});
test('applyDriftPenalty leaves confidence unchanged for null/invalid drift', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
assert.equal(assessor.applyDriftPenalty(null, 0.9, flags, 'flow'), 0.9);
assert.equal(assessor.applyDriftPenalty({ valid: false }, 0.9, flags, 'flow'), 0.9);
assert.deepEqual(flags, []);
});
test('applyDriftPenalty level 1 reduces confidence by 0.1 + flag', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.1, immediateLevel: 1, longTermLevel: 0 },
0.9, flags, 'flow',
);
assert.ok(Math.abs(c - 0.8) < 1e-9);
assert.deepEqual(flags, ['flow_low_immediate_drift']);
});
test('applyDriftPenalty level 2 reduces confidence by 0.2 + flag', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.2, immediateLevel: 2, longTermLevel: 0 },
0.9, flags, 'power',
);
assert.ok(Math.abs(c - 0.7) < 1e-9);
assert.deepEqual(flags, ['power_medium_immediate_drift']);
});
test('applyDriftPenalty level 3 reduces confidence by 0.3 + flag', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.5, immediateLevel: 3, longTermLevel: 0 },
0.9, flags, 'flow',
);
assert.ok(Math.abs(c - 0.6) < 1e-9);
assert.deepEqual(flags, ['flow_high_immediate_drift']);
});
test('applyDriftPenalty stacks long-term penalty', () => {
const assessor = new DriftAssessor({ logger: SILENT });
const flags = [];
const c = assessor.applyDriftPenalty(
{ valid: true, nrmse: 0.4, immediateLevel: 2, longTermLevel: 2 },
0.9, flags, 'flow',
);
assert.ok(Math.abs(c - 0.6) < 1e-9);
assert.deepEqual(flags, ['flow_medium_immediate_drift', 'flow_long_term_drift']);
});
test('assessDrift returns null if no stored series', () => {
const assessor = new DriftAssessor({
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
measurements: {
type() { return this; },
variant() { return this; },
position() { return this; },
getAllValues() { return {}; },
},
driftProfiles: {},
logger: SILENT,
});
assert.equal(assessor.assessDrift('flow', 0, 1), null);
});

View File

@@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const FlowController = require('../../src/flow/flowController');
function makeLogger() {
const calls = { debug: [], info: [], warn: [], error: [] };
return {
calls,
debug: (m) => calls.debug.push(m),
info: (m) => calls.info.push(m),
warn: (m) => calls.warn.push(m),
error: (m) => calls.error.push(m),
};
}
function makeHost({
mode = 'auto',
allowedActions = new Set(['execsequence', 'execmovement', 'flowmovement', 'emergencystop', 'statuscheck', 'entermaintenance', 'exitmaintenance']),
allowedSources = true,
setpointError,
} = {}) {
const logger = makeLogger();
const host = {
logger,
currentMode: mode,
unitPolicy: {
canonical: { flow: 'm3/s' },
output: { flow: 'm3/h' },
},
isValidActionForMode: (action) => allowedActions.has(action),
isValidSourceForMode: () => allowedSources,
calls: { executeSequence: [], setpoint: [], calcCtrl: [], convertUnit: [] },
async executeSequence(seq) { host.calls.executeSequence.push(seq); return { ran: seq }; },
async setpoint(sp) {
host.calls.setpoint.push(sp);
if (setpointError) throw setpointError;
return { moved: sp };
},
calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; },
_convertUnitValue: (val, from, to, label) => {
host.calls.convertUnit.push({ val, from, to, label });
return val * 1000; // pretend m3/h -> m3/s factor
},
};
return host;
}
test('handle("parent","execSequence","startup") triggers executeSequence', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, ['startup']);
assert.deepEqual(result, { ran: 'startup' });
});
test('handle("parent","execMovement",50) invokes setpoint(50)', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execMovement', 50);
assert.deepEqual(host.calls.setpoint, [50]);
assert.deepEqual(result, { moved: 50 });
});
test('handle("parent","flowMovement",X) converts unit -> calcCtrl -> setpoint', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 'flowMovement', 36);
assert.equal(host.calls.convertUnit.length, 1);
assert.equal(host.calls.convertUnit[0].from, 'm3/h');
assert.equal(host.calls.convertUnit[0].to, 'm3/s');
assert.deepEqual(host.calls.calcCtrl, [36 * 1000]);
assert.deepEqual(host.calls.setpoint, [(36 * 1000) / 2]);
});
test('handle("parent","emergencyStop") fires executeSequence("emergencystop") and logs warn', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 'emergencyStop');
assert.deepEqual(host.calls.executeSequence, ['emergencystop']);
assert.ok(host.logger.calls.warn.some((m) => /Emergency stop activated/.test(m)));
});
test('handle rejects non-string action', async () => {
const host = makeHost();
const fc = new FlowController({ host });
await fc.handle('parent', 123, 'x');
assert.deepEqual(host.calls.executeSequence, []);
assert.deepEqual(host.calls.setpoint, []);
assert.ok(host.logger.calls.error.some((m) => /Action must be string/.test(m)));
});
test('handle bails out when action not allowed for mode', async () => {
const host = makeHost({ allowedActions: new Set(['statuscheck']) });
const fc = new FlowController({ host });
await fc.handle('parent', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, []);
});
test('handle bails out when source not allowed for mode', async () => {
const host = makeHost({ allowedSources: false });
const fc = new FlowController({ host });
await fc.handle('externalApi', 'execSequence', 'startup');
assert.deepEqual(host.calls.executeSequence, []);
});
test('handle catches downstream errors and logs them (does not propagate)', async () => {
const host = makeHost({ setpointError: new Error('boom') });
const fc = new FlowController({ host });
const result = await fc.handle('parent', 'execMovement', 12);
assert.equal(result, undefined);
assert.ok(host.logger.calls.error.some((m) => /Error handling input/.test(m)));
});
test('handle returns a success envelope for statuscheck', async () => {
const host = makeHost();
const fc = new FlowController({ host });
const out = await fc.handle('parent', 'statusCheck');
assert.equal(out.status, true);
assert.ok(out.feedback.includes('statuscheck'));
});
test('handle warns on unimplemented action', async () => {
const host = makeHost({ allowedActions: new Set(['weirdaction']) });
const fc = new FlowController({ host });
await fc.handle('parent', 'weirdAction');
assert.ok(host.logger.calls.warn.some((m) => /is not implemented/.test(m)));
});
test('constructor validates host', () => {
assert.throws(() => new FlowController({}), /ctx\.host is required/);
});

View File

@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { predict } = require('generalFunctions');
const { buildPredictors } = require('../../src/prediction/predictors');
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
function makeCanonicalCurve() {
return {
nq: {
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
},
np: {
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
},
};
}
test('buildGroupPredictors: returns null when source predictors absent', () => {
assert.equal(buildGroupPredictors(null), null);
assert.equal(buildGroupPredictors({ predictFlow: null, predictPower: null, predictCtrl: null }), null);
});
test('buildGroupPredictors: returns three group-scope Predict instances', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
assert.ok(group);
assert.ok(group.groupPredictFlow instanceof predict);
assert.ok(group.groupPredictPower instanceof predict);
assert.ok(group.groupPredictCtrl instanceof predict);
});
test('buildGroupPredictors: group instances share input curves with individuals', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
// Predict._adoptInputsFrom copies these refs from the source.
assert.equal(group.groupPredictFlow.inputCurve, predictors.predictFlow.inputCurve);
assert.equal(group.groupPredictPower.inputCurve, predictors.predictPower.inputCurve);
assert.equal(group.groupPredictCtrl.inputCurve, predictors.predictCtrl.inputCurve);
});
test('buildGroupPredictors: group operating-point state is independent of individual', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
predictors.predictFlow.fDimension = 100000;
group.groupPredictFlow.fDimension = 120000;
assert.equal(predictors.predictFlow.currentF, 100000);
assert.equal(group.groupPredictFlow.currentF, 120000);
});

View File

@@ -0,0 +1,149 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const MeasurementHandlers = require('../../src/measurement/measurementHandlers');
function makeChainable(sink) {
const builder = {
_path: {},
type(t) { this._path.type = t; return this; },
variant(v) { this._path.variant = v; return this; },
position(p){ this._path.position = p; return this; },
child(id) { this._path.child = id; return this; },
value(v, ts, unit) {
sink.push({ ...this._path, value: v, ts, unit });
this._path = {};
},
getCurrentValue(unit) {
return sink._currentValue != null ? sink._currentValue : 0;
},
};
return builder;
}
function makeLogger() {
const calls = { debug: [], info: [], warn: [], error: [] };
return {
calls,
debug: (m) => calls.debug.push(m),
info: (m) => calls.info.push(m),
warn: (m) => calls.warn.push(m),
error: (m) => calls.error.push(m),
};
}
function makeHost({ operational = true } = {}) {
const writes = [];
const logger = makeLogger();
const host = {
logger,
writes,
measurementUnits: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
unitPolicy: {
canonical: { flow: 'm3/s', power: 'W', temperature: 'K', pressure: 'Pa' },
output: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
},
predictFlow: { outputY: 7 },
predictPower: { outputY: 1234 },
measurements: makeChainable(writes),
_isOperationalState: () => operational,
_resolveMeasurementUnit: (type, unit) => {
if (!unit) throw new Error(`Missing unit for ${type} measurement.`);
return unit;
},
_updateMetricDrift: (...args) => { host.driftCalls.push(args); },
_updatePredictionHealth: () => { host.healthCalls++; },
driftCalls: [],
healthCalls: 0,
updateMeasuredPressure: (...args) => { host.pressureCalls.push(args); },
pressureCalls: [],
updatePosition: () => { host.positionCalls++; },
positionCalls: 0,
};
return host;
}
test('dispatch("flow", …) routes to updateMeasuredFlow', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h', childId: 'c1', childName: 'FT-1' });
const flowWrite = host.writes.find((w) => w.type === 'flow' && w.variant === 'measured');
assert.ok(flowWrite, 'expected measured flow write');
assert.equal(flowWrite.value, 5);
assert.equal(flowWrite.position, 'downstream');
assert.equal(flowWrite.child, 'c1');
const predictedWrites = host.writes.filter((w) => w.type === 'flow' && w.variant === 'predicted');
assert.equal(predictedWrites.length, 2, 'two predicted writes (downstream+atEquipment)');
assert.equal(host.driftCalls.length, 1);
assert.equal(host.driftCalls[0][0], 'flow');
assert.equal(host.healthCalls, 1);
});
test('dispatch("temperature", …) writes to measurements (works in non-operational state too)', () => {
const host = makeHost({ operational: false });
const mh = new MeasurementHandlers({ host });
mh.dispatch('temperature', 22.5, 'atEquipment', { unit: 'C', childId: 'tc', childName: 'TT-1', timestamp: 111 });
const write = host.writes.find((w) => w.type === 'temperature');
assert.ok(write);
assert.equal(write.value, 22.5);
assert.equal(write.unit, 'C');
assert.equal(write.ts, 111);
});
test('dispatch("power", …) routes to updateMeasuredPower and respects unit', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('power', 1500, 'atEquipment', { unit: 'kW', childId: 'pwr', childName: 'P-1' });
const measured = host.writes.find((w) => w.type === 'power' && w.variant === 'measured');
assert.ok(measured);
assert.equal(measured.unit, 'kW');
const predicted = host.writes.find((w) => w.type === 'power' && w.variant === 'predicted');
assert.ok(predicted);
assert.equal(host.driftCalls.length, 1);
assert.equal(host.driftCalls[0][0], 'power');
});
test('flow/power updates are skipped when machine is not operational', () => {
const host = makeHost({ operational: false });
const mh = new MeasurementHandlers({ host });
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h' });
mh.dispatch('power', 99, 'atEquipment', { unit: 'kW' });
assert.equal(host.writes.length, 0);
assert.equal(host.driftCalls.length, 0);
assert.ok(host.logger.calls.warn.some((m) => /Machine not operational/.test(m)));
});
test('dispatch("pressure", …) delegates to host.updateMeasuredPressure (pressureRouter)', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('pressure', 1013, 'upstream', { unit: 'mbar', childId: 'PT-1' });
assert.equal(host.pressureCalls.length, 1);
assert.deepEqual(host.pressureCalls[0][0], 1013);
});
test('dispatch(unknown, …) logs warn and falls back to updatePosition', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('vibration', 1, 'atEquipment', {});
assert.equal(host.positionCalls, 1);
assert.ok(host.logger.calls.warn.some((m) => /No handler for measurement type/.test(m)));
});
test('handler rejects update when unit resolution throws', () => {
const host = makeHost();
const mh = new MeasurementHandlers({ host });
mh.dispatch('flow', 5, 'downstream', { /* no unit */ });
assert.equal(host.writes.length, 0);
assert.ok(host.logger.calls.warn.some((m) => /Rejected flow update/.test(m)));
});
test('constructor validates host', () => {
assert.throws(() => new MeasurementHandlers({}), /ctx\.host is required/);
});

View File

@@ -2,13 +2,19 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const NodeClass = require('../../src/nodeClass');
const Machine = require('../../src/specificClass');
const { makeNodeStub } = require('../helpers/factories'); const { makeNodeStub } = require('../helpers/factories');
// After the BaseNodeAdapter migration, _loadConfig + _setupSpecificClass
// are gone — config building lives in buildDomainConfig(). These tests
// drive that contract through a prototype-derived nodeClass instance so
// we exercise the surface without booting Node-RED.
function makeUiConfig(overrides = {}) { function makeUiConfig(overrides = {}) {
return { return {
unit: 'm3/h', unit: 'm3/h',
enableLog: true, enableLog: false,
logLevel: 'debug', logLevel: 'error',
supplier: 'hidrostal', supplier: 'hidrostal',
category: 'machine', category: 'machine',
assetType: 'pump', assetType: 'pump',
@@ -28,82 +34,53 @@ function makeUiConfig(overrides = {}) {
}; };
} }
test('_loadConfig maps legacy editor fields for asset identity', () => { function callBuildDomainConfig(ui) {
const inst = Object.create(NodeClass.prototype); const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub(); // Clear any leftover pending extras so this test's call is the only one
inst.name = 'rotatingMachine'; // that stamps Machine._pendingExtras.
Machine._pendingExtras = null;
return inst.buildDomainConfig(ui);
}
inst._loadConfig( test('buildDomainConfig maps legacy editor fields for asset identity', () => {
makeUiConfig({ const cfg = callBuildDomainConfig(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
uuid: 'uuid-from-editor', assert.equal(cfg.asset.uuid, 'uuid-from-editor');
assetTagNumber: 'TAG-123', assert.equal(cfg.asset.tagCode, 'TAG-123');
}), assert.equal(cfg.asset.tagNumber, 'TAG-123');
inst.node
);
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
assert.equal(inst.config.asset.tagCode, 'TAG-123');
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
}); });
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => { test('buildDomainConfig prefers explicit assetUuid/assetTagCode when present', () => {
const inst = Object.create(NodeClass.prototype); const cfg = callBuildDomainConfig(makeUiConfig({
inst.node = makeNodeStub(); uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
inst.name = 'rotatingMachine'; assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
}));
inst._loadConfig( assert.equal(cfg.asset.uuid, 'explicit-uuid');
makeUiConfig({ assert.equal(cfg.asset.tagCode, 'explicit-tag');
uuid: 'legacy-uuid',
assetUuid: 'explicit-uuid',
assetTagNumber: 'legacy-tag',
assetTagCode: 'explicit-tag',
}),
inst.node
);
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
}); });
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => { test('buildDomainConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
const inst = Object.create(NodeClass.prototype); const cfg = callBuildDomainConfig(makeUiConfig({
inst.node = makeNodeStub(); unit: 'not-a-unit',
inst.name = 'rotatingMachine'; curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW', curveControlUnit: '%',
inst._loadConfig( }));
makeUiConfig({ assert.equal(cfg.general.unit, 'm3/h');
unit: 'not-a-unit', assert.equal(cfg.asset.unit, 'm3/h');
curvePressureUnit: 'mbar', assert.equal(cfg.asset.curveUnits.pressure, 'mbar');
curveFlowUnit: 'm3/h', assert.equal(cfg.asset.curveUnits.flow, 'm3/h');
curvePowerUnit: 'kW', assert.equal(cfg.asset.curveUnits.power, 'kW');
curveControlUnit: '%', assert.equal(cfg.asset.curveUnits.control, '%');
}),
inst.node
);
assert.equal(inst.config.general.unit, 'm3/h');
assert.equal(inst.config.asset.unit, 'm3/h');
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
assert.equal(inst.config.asset.curveUnits.power, 'kW');
assert.equal(inst.config.asset.curveUnits.control, '%');
assert.ok(inst.node._warns.length >= 1);
}); });
test('_setupSpecificClass propagates logging settings into state config', () => { test('buildDomainConfig stashes state config including logging + movement + time', () => {
Machine._pendingExtras = null;
const inst = Object.create(NodeClass.prototype); const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub(); inst.buildDomainConfig(makeUiConfig({ enableLog: true, logLevel: 'warn', speed: 5, startup: 3 }));
inst.name = 'rotatingMachine'; const extras = Machine._pendingExtras;
const uiConfig = makeUiConfig({ assert.ok(extras, 'Machine._pendingExtras should be set by buildDomainConfig');
enableLog: true, assert.equal(extras.stateConfig.general.logging.enabled, true);
logLevel: 'warn', assert.equal(extras.stateConfig.general.logging.logLevel, 'warn');
uuid: 'uuid-test', assert.equal(extras.stateConfig.movement.speed, 5);
assetTagNumber: 'TAG-9', assert.equal(extras.stateConfig.time.starting, 3);
}); Machine._pendingExtras = null;
inst._loadConfig(uiConfig, inst.node);
inst._setupSpecificClass(uiConfig);
assert.equal(inst.source.state.config.general.logging.enabled, true);
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
}); });

View File

@@ -0,0 +1,73 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildPredictors } = require('../../src/prediction/predictors');
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
const OperatingPoint = require('../../src/prediction/operatingPoint');
function makeCanonicalCurve() {
return {
nq: {
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
},
np: {
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
},
};
}
test('OperatingPoint.setIndividual: updates working pressure on all three predictors', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors);
const ok = op.setIndividual(100000);
assert.equal(ok, true);
assert.equal(predictors.predictFlow.currentF, 100000);
assert.equal(predictors.predictPower.currentF, 100000);
assert.equal(predictors.predictCtrl.currentF, 100000);
});
test('OperatingPoint.setIndividual: rejects non-finite pressure', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors);
assert.equal(op.setIndividual(NaN), false);
assert.equal(op.setIndividual('not-a-number'), false);
});
test('OperatingPoint.setGroup: no-op when group predictors absent', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors, null);
assert.equal(op.setGroup(100000), false);
});
test('OperatingPoint.setGroup: updates only group predictors', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
const op = new OperatingPoint(predictors, group);
predictors.predictFlow.fDimension = 120000;
op.setGroup(100000);
assert.equal(group.groupPredictFlow.currentF, 100000);
assert.equal(predictors.predictFlow.currentF, 120000);
});
test('OperatingPoint.flowFor: returns a finite predicted flow', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const op = new OperatingPoint(predictors);
op.setIndividual(100000);
const flow = op.flowFor(50);
assert.ok(Number.isFinite(flow), `expected finite flow, got ${flow}`);
assert.ok(flow > 0);
});
test('OperatingPoint.useGroup: switches getters to group predictors', () => {
const predictors = buildPredictors(makeCanonicalCurve());
const group = buildGroupPredictors(predictors);
const op = new OperatingPoint(predictors, group);
op.setIndividual(100000);
op.setGroup(120000);
const indivFlow = op.useIndividual().flowFor(50);
const groupFlow = op.useGroup().flowFor(50);
assert.ok(Number.isFinite(indivFlow));
assert.ok(Number.isFinite(groupFlow));
});

View File

@@ -0,0 +1,93 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const PredictionHealth = require('../../src/drift/predictionHealth');
const DriftAssessor = require('../../src/drift/driftAssessor');
function makeHealth(overrides = {}) {
return new PredictionHealth({
getPressureInitializationStatus: () => ({
initialized: true, hasDifferential: true, source: 'differential',
}),
isOperational: () => true,
applyDriftPenalty: new DriftAssessor({}).applyDriftPenalty.bind(new DriftAssessor({})),
...overrides,
});
}
test('empty snapshots + differential pressure → nominal health, confidence=0.9', () => {
const ph = makeHealth();
const { health, confidence } = ph.evaluate({
flow: null,
power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.equal(health.level, 0);
assert.ok(Math.abs(confidence - 0.9) < 1e-9);
assert.equal(typeof health.message, 'string');
});
test('pressure not initialized + flow drift level 2 → composite level >= 2 and multiple flags', () => {
const ph = makeHealth({
getPressureInitializationStatus: () => ({
initialized: false, hasDifferential: false, source: null,
}),
});
const { health, confidence } = ph.evaluate({
flow: { valid: true, nrmse: 0.3, immediateLevel: 2, longTermLevel: 0 },
power: null,
pressure: { level: 2, flags: ['no_pressure_input'], source: null },
});
assert.ok(health.level >= 2);
assert.ok(health.flags.includes('no_pressure_input'));
assert.ok(health.flags.includes('flow_medium_immediate_drift'));
assert.ok(confidence < 0.5);
});
test('returned object has both health and confidence', () => {
const ph = makeHealth();
const out = ph.evaluate({ flow: null, power: null, pressure: { level: 0, flags: [], source: 'differential' } });
assert.ok('health' in out);
assert.ok('confidence' in out);
assert.equal(typeof out.confidence, 'number');
assert.equal(typeof out.health.level, 'number');
});
test('non-operational forces confidence=0 and bumps level >=2', () => {
const ph = makeHealth({ isOperational: () => false });
const { health, confidence } = ph.evaluate({
flow: null, power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.equal(confidence, 0);
assert.ok(health.flags.includes('not_operational'));
assert.ok(health.level >= 2);
});
test('curve-edge penalty applies when current position is near min/max', () => {
const ph = makeHealth({
getCurrentPosition: () => 0.01,
resolveSetpointBounds: () => ({ min: 0, max: 1 }),
});
const { health, confidence } = ph.evaluate({
flow: null, power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.ok(health.flags.includes('near_curve_edge'));
assert.ok(confidence < 0.9);
});
test('HealthStatus shape — has the standardised five fields', () => {
const ph = makeHealth();
const { health } = ph.evaluate({
flow: null, power: null,
pressure: { level: 0, flags: [], source: 'differential' },
});
assert.ok('level' in health);
assert.ok('flags' in health);
assert.ok('message' in health);
assert.ok('source' in health);
assert.ok(Array.isArray(health.flags));
});

View File

@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { predict } = require('generalFunctions');
const { buildPredictors } = require('../../src/prediction/predictors');
function makeCanonicalCurve() {
// Canonical units already applied: pressure Pa, flow m3/s, power W,
// x-axis is control %. Two pressure levels, monotonically rising y.
return {
nq: {
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
},
np: {
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
},
};
}
test('buildPredictors: returns three Predict instances', () => {
const predictors = buildPredictors(makeCanonicalCurve());
assert.ok(predictors.predictFlow instanceof predict);
assert.ok(predictors.predictPower instanceof predict);
assert.ok(predictors.predictCtrl instanceof predict);
});
test('buildPredictors: predictFlow yMax/yMin reflect input range', () => {
const predictors = buildPredictors(makeCanonicalCurve());
// After buildAllFxyCurves the fDimension is initialised to fValues.min.
// currentFxyYMin/Max are the y-range at that pressure curve.
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMax));
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMin));
assert.ok(predictors.predictFlow.currentFxyYMax > predictors.predictFlow.currentFxyYMin);
});
test('buildPredictors: predictCtrl is built from reversed nq (flow->ctrl mapping)', () => {
const predictors = buildPredictors(makeCanonicalCurve());
// predictCtrl's x-axis values must come from y-values in nq.
// sanity-check via currentFxyXMax being in the flow range
assert.ok(predictors.predictCtrl.currentFxyXMax <= 0.02, // flow range upper bound
`expected predictCtrl xMax in flow-range, got ${predictors.predictCtrl.currentFxyXMax}`);
});
test('buildPredictors: throws when machineCurve is missing nq or np', () => {
assert.throws(() => buildPredictors(null), /machineCurve\.nq and \.np are required/);
assert.throws(() => buildPredictors({ nq: {} }), /required/);
});

View File

@@ -0,0 +1,103 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const PressureInitialization = require('../../src/pressure/pressureInitialization');
const SILENT = { warn() {}, debug() {} };
/* A tiny in-memory stand-in for MeasurementContainer's chained API. */
function makeFakeMeasurements() {
const store = new Map();
const key = (pos, childId) => `${pos}::${childId == null ? '*' : childId}`;
return {
_write(pos, childId, value) { store.set(key(pos, childId), value); },
type() { return this; },
variant() { return this; },
position(p) { this._pos = p; return this; },
child(c) { this._child = c; return this; },
getCurrentValue() {
const k = key(this._pos, this._child);
this._child = null;
const v = store.get(k);
if (v != null) return v;
// fallback to bare position when no child specified
return store.get(key(this._pos, null));
},
};
}
test('getStatus reports initialized:false when neither real nor virtual data present', () => {
const init = new PressureInitialization({
measurements: makeFakeMeasurements(),
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
const s = init.getStatus();
assert.equal(s.initialized, false);
assert.equal(s.hasDifferential, false);
assert.equal(s.source, null);
});
test('registerReal then getStatus reports initialized:true for that position', () => {
const meas = makeFakeMeasurements();
const init = new PressureInitialization({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
init.registerReal('upstream', 'pt-101');
meas._write('upstream', 'pt-101', 5000);
const s = init.getStatus();
assert.equal(s.initialized, true);
assert.equal(s.hasUpstream, true);
assert.equal(s.hasDownstream, false);
assert.equal(s.hasDifferential, false);
assert.equal(s.source, 'upstream');
});
test('hasDifferential true only when both upstream + downstream have data', () => {
const meas = makeFakeMeasurements();
const init = new PressureInitialization({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
init.registerReal('upstream', 'pt-1');
meas._write('upstream', 'pt-1', 5000);
assert.equal(init.getStatus().hasDifferential, false);
init.registerReal('downstream', 'pt-2');
meas._write('downstream', 'pt-2', 7000);
const s = init.getStatus();
assert.equal(s.hasDifferential, true);
assert.equal(s.source, 'differential');
});
test('virtual fallback when no real children registered', () => {
const meas = makeFakeMeasurements();
const init = new PressureInitialization({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
meas._write('upstream', 'sim-u', 5000);
const s = init.getStatus();
assert.equal(s.hasUpstream, true);
assert.equal(s.source, 'upstream');
});
test('unregisterReal removes a tracked child id', () => {
const init = new PressureInitialization({
measurements: makeFakeMeasurements(),
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
logger: SILENT,
});
init.registerReal('upstream', 'pt-1');
assert.ok(init.realPressureChildIds.upstream.has('pt-1'));
init.unregisterReal('upstream', 'pt-1');
assert.ok(!init.realPressureChildIds.upstream.has('pt-1'));
});

View File

@@ -0,0 +1,101 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const PressureRouter = require('../../src/pressure/pressureRouter');
const SILENT = { warn() {}, debug() {} };
function makeFakeMeasurements() {
const writes = [];
return {
writes,
type() { return this; },
variant() { return this; },
position(p) { this._pos = p; return this; },
child(c) { this._child = c; return this; },
value(v, t, u) { writes.push({ pos: this._pos, child: this._child, value: v, t, u }); },
};
}
test('route("upstream", 1, ctx) writes to the upstream pressure slot', () => {
const meas = makeFakeMeasurements();
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
logger: SILENT,
});
router.route('upstream', 1, { childId: 'real-1', unit: 'mbar', timestamp: 1234 });
assert.equal(meas.writes.length, 1);
assert.equal(meas.writes[0].pos, 'upstream');
assert.equal(meas.writes[0].child, 'real-1');
assert.equal(meas.writes[0].value, 1);
assert.equal(meas.writes[0].u, 'mbar');
});
test('virtual source: refresh hooks NOT called', () => {
const meas = makeFakeMeasurements();
let posCalled = 0, driftCalled = 0, healthCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
updatePosition: () => { posCalled++; },
refreshDrift: () => { driftCalled++; },
refreshHealth: () => { healthCalled++; },
logger: SILENT,
});
router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' });
assert.equal(posCalled, 0);
assert.equal(driftCalled, 0);
assert.equal(healthCalled, 0);
});
test('real source: all refresh hooks called', () => {
const meas = makeFakeMeasurements();
let posCalled = 0, driftCalled = 0, healthCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
resolveMeasurementUnit: () => 'mbar',
updatePosition: () => { posCalled++; },
refreshDrift: () => { driftCalled++; },
refreshHealth: () => { healthCalled++; },
logger: SILENT,
});
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
assert.equal(posCalled, 1);
assert.equal(driftCalled, 1);
assert.equal(healthCalled, 1);
});
test('rejected unit returns false and skips the write', () => {
const meas = makeFakeMeasurements();
const warns = [];
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: {},
resolveMeasurementUnit: () => { throw new Error('bad unit'); },
logger: { warn(m) { warns.push(m); }, debug() {} },
});
const ok = router.route('upstream', 1, { childId: 'x', unit: 'wat' });
assert.equal(ok, false);
assert.equal(meas.writes.length, 0);
assert.match(warns[0], /Rejected pressure update/);
});
test('childId null is treated as not-virtual', () => {
const meas = makeFakeMeasurements();
let posCalled = 0;
const router = new PressureRouter({
measurements: meas,
virtualPressureChildIds: { upstream: 'sim-u' },
resolveMeasurementUnit: () => 'mbar',
updatePosition: () => { posCalled++; },
logger: SILENT,
});
router.route('upstream', 2, { unit: 'mbar' });
assert.equal(posCalled, 1);
});

View File

@@ -0,0 +1,29 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { reverseCurve } = require('../../src/curves/reverseCurve');
test('reverseCurve: swaps x and y for each pressure key', () => {
const input = {
700: { x: [0, 50, 100], y: [0, 10, 20] },
800: { x: [0, 50, 100], y: [0, 11, 22] },
};
const out = reverseCurve(input);
assert.deepEqual(out['700'].x, [0, 10, 20]);
assert.deepEqual(out['700'].y, [0, 50, 100]);
assert.deepEqual(out['800'].x, [0, 11, 22]);
assert.deepEqual(out['800'].y, [0, 50, 100]);
});
test('reverseCurve: returns a fresh object with cloned arrays', () => {
const input = { 700: { x: [1, 2], y: [3, 4] } };
const out = reverseCurve(input);
out['700'].x.push(999);
assert.deepEqual(input['700'].x, [1, 2]);
assert.deepEqual(input['700'].y, [3, 4]);
});
test('reverseCurve: handles empty input', () => {
assert.deepEqual(reverseCurve({}), {});
assert.deepEqual(reverseCurve(null), {});
});

View File

@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('events');
const { bindStateEvents, isOperationalState, OPERATIONAL_STATES } =
require('../../src/state/stateBindings');
function makeFakeState() {
const emitter = new EventEmitter();
let current = 'idle';
return {
emitter,
setState(s) { current = s; },
getCurrentState() { return current; },
};
}
test('bindStateEvents attaches both listeners and they fire on emit', () => {
const state = makeFakeState();
let posCalls = 0;
let stateCalls = 0;
let lastStateArg = null;
bindStateEvents({
state,
onPositionChange: () => { posCalls++; },
onStateChange: (newState) => { stateCalls++; lastStateArg = newState; },
});
assert.equal(state.emitter.listenerCount('positionChange'), 1);
assert.equal(state.emitter.listenerCount('stateChange'), 1);
state.emitter.emit('positionChange', 42);
state.emitter.emit('stateChange', 'operational');
assert.equal(posCalls, 1);
assert.equal(stateCalls, 1);
assert.equal(lastStateArg, 'operational');
});
test('bindStateEvents teardown removes both listeners and is idempotent', () => {
const state = makeFakeState();
const teardown = bindStateEvents({
state,
onPositionChange: () => {},
onStateChange: () => {},
});
assert.equal(state.emitter.listenerCount('positionChange'), 1);
assert.equal(state.emitter.listenerCount('stateChange'), 1);
teardown();
assert.equal(state.emitter.listenerCount('positionChange'), 0);
assert.equal(state.emitter.listenerCount('stateChange'), 0);
teardown();
assert.equal(state.emitter.listenerCount('positionChange'), 0);
});
test('bindStateEvents validates context shape', () => {
assert.throws(() => bindStateEvents(null), /ctx\.state\.emitter is required/);
assert.throws(
() => bindStateEvents({ state: makeFakeState() }),
/handlers are required/,
);
});
test('isOperationalState returns true for operational/accelerating/decelerating/warmingup', () => {
const state = makeFakeState();
for (const s of ['operational', 'accelerating', 'decelerating', 'warmingup']) {
state.setState(s);
assert.equal(isOperationalState(state), true, `expected ${s} to be operational`);
}
});
test('isOperationalState returns false for non-operational states and bad input', () => {
const state = makeFakeState();
for (const s of ['idle', 'starting', 'stopping', 'coolingdown', 'emergencystopped']) {
state.setState(s);
assert.equal(isOperationalState(state), false, `expected ${s} not to be operational`);
}
assert.equal(isOperationalState(null), false);
assert.equal(isOperationalState({}), false);
});
test('OPERATIONAL_STATES list is exported and frozen-ish (no extras beyond contract)', () => {
assert.deepEqual(
[...OPERATIONAL_STATES].sort(),
['accelerating', 'decelerating', 'operational', 'warmingup'],
);
});

View File

@@ -0,0 +1,70 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const VirtualPressureChildren = require('../../src/pressure/virtualChildren');
const SILENT = { warn() {}, debug() {}, info() {}, error() {} };
const UNIT_POLICY = {
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K', atmPressure: 'Pa' },
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
};
test('build() returns two children with the expected config shape', () => {
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
const { upstream, downstream } = factory.build();
for (const child of [upstream, downstream]) {
assert.ok(child.config.general.id);
assert.ok(child.config.general.name);
assert.equal(child.config.functionality.softwareType, 'measurement');
assert.ok(['upstream', 'downstream'].includes(child.config.functionality.positionVsParent));
assert.equal(child.config.asset.type, 'pressure');
assert.equal(child.config.asset.unit, 'mbar');
}
assert.equal(upstream.config.functionality.positionVsParent, 'upstream');
assert.equal(downstream.config.functionality.positionVsParent, 'downstream');
});
test('each child has its own MeasurementContainer instance', () => {
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
const { upstream, downstream } = factory.build();
assert.ok(upstream.measurements);
assert.ok(downstream.measurements);
assert.notStrictEqual(upstream.measurements, downstream.measurements);
});
test('the MeasurementContainer accepts pressure writes (unit policy applied)', () => {
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
const { upstream } = factory.build();
upstream.measurements
.type('pressure').variant('measured').position('upstream')
.value(1000, Date.now(), 'mbar');
const v = upstream.measurements
.type('pressure').variant('measured').position('upstream').getCurrentValue();
assert.ok(v != null);
});
test('setParentRef wires children to the supplied parent ref', () => {
const parent = { id: 'parent-machine' };
const factory = new VirtualPressureChildren({
logger: SILENT, unitPolicy: UNIT_POLICY, parentRef: parent,
});
const { upstream, downstream } = factory.build();
assert.equal(typeof upstream.measurements.setParentRef, 'function');
assert.equal(typeof downstream.measurements.setParentRef, 'function');
});
test('custom ids are honoured', () => {
const factory = new VirtualPressureChildren({
logger: SILENT,
unitPolicy: UNIT_POLICY,
ids: { upstream: 'sim-u', downstream: 'sim-d' },
});
const { upstream, downstream } = factory.build();
assert.equal(upstream.config.general.id, 'sim-u');
assert.equal(downstream.config.general.id, 'sim-d');
});

View File

@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { showWorkingCurves, showCoG } = require('../../src/display/workingCurves');
function makePredictors(overrides = {}) {
return {
hasCurve: true,
cog: 0.65,
cogIndex: 7,
NCog: 0.5,
minEfficiency: 0.4,
currentEfficiencyCurve: { x: [0, 1], y: [0.4, 0.8] },
absDistFromPeak: 0.15,
relDistFromPeak: 0.3,
calcCog: () => ({ cog: 0.65, cogIndex: 7, NCog: 0.5, minEfficiency: 0.4 }),
getCurrentCurves: () => ({
powerCurve: { x: [0, 1], y: [10, 20] },
flowCurve: { x: [0, 1], y: [0, 5] },
}),
...overrides,
};
}
test('showWorkingCurves returns the expected shape when curves exist', () => {
const p = makePredictors();
const out = showWorkingCurves(p);
assert.deepEqual(out.powerCurve, { x: [0, 1], y: [10, 20] });
assert.deepEqual(out.flowCurve, { x: [0, 1], y: [0, 5] });
assert.equal(out.cog, 0.65);
assert.equal(out.cogIndex, 7);
assert.equal(out.NCog, 0.5);
assert.equal(out.minEfficiency, 0.4);
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
assert.equal(out.absDistFromPeak, 0.15);
assert.equal(out.relDistFromPeak, 0.3);
});
test('showWorkingCurves returns error envelope when hasCurve is false', () => {
const out = showWorkingCurves(makePredictors({ hasCurve: false }));
assert.deepEqual(out, { error: 'No curve data available' });
});
test('showWorkingCurves handles null predictors safely', () => {
const out = showWorkingCurves(null);
assert.equal(out.error, 'No curve data available');
});
test('showCoG returns CoG data with rounded NCogPercent when curves exist', () => {
const p = makePredictors();
const out = showCoG(p);
assert.equal(out.cog, 0.65);
assert.equal(out.cogIndex, 7);
assert.equal(out.NCog, 0.5);
// 0.5 * 100 = 50.0, rounded *100 /100 still 50
assert.equal(out.NCogPercent, 50);
assert.equal(out.minEfficiency, 0.4);
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
assert.equal(out.absDistFromPeak, 0.15);
assert.equal(out.relDistFromPeak, 0.3);
});
test('showCoG rounds NCogPercent to 2 decimal places', () => {
const p = makePredictors({
calcCog: () => ({ cog: 0.1, cogIndex: 1, NCog: 0.123456, minEfficiency: 0.2 }),
});
const out = showCoG(p);
assert.equal(out.NCogPercent, 12.35);
});
test('showCoG returns degraded shape when hasCurve is false', () => {
const out = showCoG(makePredictors({ hasCurve: false }));
assert.equal(out.error, 'No curve data available');
assert.equal(out.cog, 0);
assert.equal(out.NCog, 0);
assert.equal(out.cogIndex, 0);
});
test('showCoG handles null predictors safely', () => {
const out = showCoG(null);
assert.equal(out.error, 'No curve data available');
assert.equal(out.cog, 0);
});

View File

@@ -34,22 +34,20 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
assert.equal(requested[1], max); assert.equal(requested[1], max);
}); });
test('nodeClass _updateNodeStatus returns error status on internal failure', () => { test('source.getStatusBadge returns error status on internal failure', () => {
const inst = Object.create(NodeClass.prototype); // Status badge lives on the domain post-refactor. Build a tiny stub
const node = makeNodeStub(); // that throws to verify the error-path returns an error badge.
inst.node = node; const errors = [];
inst.source = { const source = {
currentMode: 'auto', currentMode: 'auto',
state: { state: { getCurrentState() { throw new Error('boom'); } },
getCurrentState() { logger: { error: (m) => errors.push(m) },
throw new Error('boom');
},
},
}; };
const { buildStatusBadge } = require('../../src/io/output');
const status = inst._updateNodeStatus(); const status = buildStatusBadge(source);
assert.equal(status.text, 'Status Error'); assert.match(status.text, /Status Error/);
assert.equal(node._errors.length, 1); assert.equal(status.fill, 'red');
assert.equal(errors.length, 1);
}); });
test('measurement handlers reject incompatible units', () => { test('measurement handlers reject incompatible units', () => {

View File

@@ -2,89 +2,75 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const NodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories'); const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('input handler routes topics to source methods', () => { // Post-BaseNodeAdapter, dispatch is the commands-registry. These tests
// drive the same surface from a prototype-derived nodeClass instance to
// keep the routing covered without booting Node-RED.
function makeSourceStub() {
const calls = [];
return {
calls,
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild(childSource, pos) { calls.push(['registerChild', childSource, pos]); } },
setMode(mode) { calls.push(['setMode', mode]); },
handleInput(source, action, parameter) { calls.push(['handleInput', source, action, parameter]); return Promise.resolve(); },
showWorkingCurves() { return { ok: true }; },
showCoG() { return { cog: 1 }; },
updateSimulatedMeasurement(type, position, value) { calls.push(['updateSimulatedMeasurement', type, position, value]); },
updateMeasuredPressure(value, position) { calls.push(['updateMeasuredPressure', value, position]); },
updateMeasuredFlow(value, position) { calls.push(['updateMeasuredFlow', value, position]); },
updateMeasuredPower(value, position) { calls.push(['updateMeasuredPower', value, position]); },
updateMeasuredTemperature(value, position) { calls.push(['updateMeasuredTemperature', value, position]); },
isUnitValidForType() { return true; },
};
}
test('input handler routes topics to source methods via commands registry', async () => {
const inst = Object.create(NodeClass.prototype); const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub(); const node = makeNodeStub();
const source = makeSourceStub();
const calls = [];
inst.node = node; inst.node = node;
inst.RED = makeREDStub({ inst.RED = makeREDStub({ child1: { source: { id: 'child-source' } } });
child1: { inst.source = source;
source: { id: 'child-source' }, inst._commands = createRegistry(commands, { logger: source.logger });
},
});
inst.source = {
childRegistrationUtils: {
registerChild(childSource, pos) {
calls.push(['registerChild', childSource, pos]);
},
},
setMode(mode) {
calls.push(['setMode', mode]);
},
handleInput(source, action, parameter) {
calls.push(['handleInput', source, action, parameter]);
},
showWorkingCurves() {
return { ok: true };
},
showCoG() {
return { cog: 1 };
},
updateSimulatedMeasurement(type, position, value) {
calls.push(['updateSimulatedMeasurement', type, position, value]);
},
updateMeasuredPressure(value, position) {
calls.push(['updateMeasuredPressure', value, position]);
},
updateMeasuredFlow(value, position) {
calls.push(['updateMeasuredFlow', value, position]);
},
updateMeasuredPower(value, position) {
calls.push(['updateMeasuredPower', value, position]);
},
updateMeasuredTemperature(value, position) {
calls.push(['updateMeasuredTemperature', value, position]);
},
isUnitValidForType() {
return true;
},
};
inst._attachInputHandler(); inst._attachInputHandler();
const onInput = node._handlers.input; const onInput = node._handlers.input;
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {}); await onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {}); await onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, () => {}, () => {});
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {}); await onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {}); await onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {}); await onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {}); await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {}); await onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
assert.deepEqual(calls[0], ['setMode', 'auto']); assert.deepEqual(source.calls[0], ['setMode', 'auto']);
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']); assert.deepEqual(source.calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]); assert.deepEqual(source.calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]); // estop handler defaults action to 'emergencystop' even without one
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']); // supplied, so the trailing arg is undefined — passed as positional.
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]); assert.deepEqual(source.calls[3].slice(0, 3), ['handleInput', 'GUI', 'emergencystop']);
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']); assert.deepEqual(source.calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
assert.deepEqual(source.calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
assert.deepEqual(source.calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
}); });
test('simulateMeasurement warns and ignores invalid payloads', () => { test('simulateMeasurement warns and ignores invalid payloads', async () => {
const warns = [];
const inst = Object.create(NodeClass.prototype); const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub(); const node = makeNodeStub();
const calls = []; const calls = [];
inst.node = node; inst.node = node;
inst.RED = makeREDStub(); inst.RED = makeREDStub();
inst.source = { inst.source = {
logger: { warn: (m) => warns.push(m), info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild() {} }, childRegistrationUtils: { registerChild() {} },
setMode() {}, setMode() {},
handleInput() {}, handleInput() { return Promise.resolve(); },
showWorkingCurves() { return {}; }, showWorkingCurves() { return {}; },
showCoG() { return {}; }, showCoG() { return {}; },
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); }, updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
@@ -92,90 +78,67 @@ test('simulateMeasurement warns and ignores invalid payloads', () => {
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); }, updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
updateMeasuredPower() { calls.push('updateMeasuredPower'); }, updateMeasuredPower() { calls.push('updateMeasuredPower'); },
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); }, updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
isUnitValidForType() { return true; },
}; };
inst._commands = createRegistry(commands, { logger: inst.source.logger });
inst._attachInputHandler(); inst._attachInputHandler();
const onInput = node._handlers.input; const onInput = node._handlers.input;
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {}); await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {}); await onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {}); await onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
assert.equal(node._warns.length, 3); // Filter out the one-time deprecation warning for the legacy
assert.match(String(node._warns[0]), /finite number/i); // 'simulateMeasurement' alias — only the three invalid-payload warns
assert.match(String(node._warns[1]), /payload\.unit is required/i); // matter for this assertion.
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i); const payloadWarns = warns.filter((w) => !/deprecated/i.test(String(w)));
assert.equal(payloadWarns.length, 3);
assert.match(String(payloadWarns[0]), /finite number/i);
assert.match(String(payloadWarns[1]), /payload\.unit is required/i);
assert.match(String(payloadWarns[2]), /unsupported simulatemeasurement type/i);
}); });
test('status shows warning when pressure inputs are not initialized', () => { test('source.getStatusBadge shows warning when pressure inputs are not initialized', () => {
const inst = Object.create(NodeClass.prototype); // Status badge now lives on the domain (Machine). Build a tiny stub.
const node = makeNodeStub(); const source = {
inst.node = node;
inst.source = {
currentMode: 'virtualControl', currentMode: 'virtualControl',
state: { state: { getCurrentState: () => 'operational', getCurrentPosition: () => 50 },
getCurrentState() { pressureInit: { getStatus: () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false }) },
return 'operational'; measurements: { type() { return { variant() { return { position() { return { getCurrentValue() { return 0; } }; } }; } }; } },
}, unitPolicyView: { output: { flow: 'm3/h' } },
getCurrentPosition() { logger: { error: () => {} },
return 50;
},
},
getPressureInitializationStatus() {
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
},
measurements: {
type() {
return {
variant() {
return {
position() {
return { getCurrentValue() { return 0; } };
},
};
},
};
},
},
}; };
// Import the buildStatusBadge helper directly — it's the same code the
const status = inst._updateNodeStatus(); // domain's getStatusBadge() invokes.
const statusAgain = inst._updateNodeStatus(); const { buildStatusBadge } = require('../../src/io/output');
const status = buildStatusBadge(source);
assert.equal(status.fill, 'yellow'); assert.equal(status.fill, 'yellow');
assert.equal(status.shape, 'ring'); assert.equal(status.shape, 'ring');
assert.match(status.text, /pressure not initialized/i); assert.match(status.text, /pressure not initialized/i);
assert.equal(statusAgain.fill, 'yellow');
assert.equal(node._warns.length, 1);
assert.match(String(node._warns[0]), /Pressure input is not initialized/i);
}); });
test('showWorkingCurves and CoG route reply messages to process output index', () => { test('showWorkingCurves and CoG route reply messages to process output index', async () => {
const inst = Object.create(NodeClass.prototype); const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub(); const node = makeNodeStub();
const source = {
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
childRegistrationUtils: { registerChild() {} },
setMode() {}, handleInput() { return Promise.resolve(); },
showWorkingCurves() { return { curve: [1, 2, 3] }; },
showCoG() { return { cog: 0.77 }; },
};
inst.node = node; inst.node = node;
inst.RED = makeREDStub(); inst.RED = makeREDStub();
inst.source = { inst.source = source;
childRegistrationUtils: { registerChild() {} }, inst._commands = createRegistry(commands, { logger: source.logger });
setMode() {},
handleInput() {},
showWorkingCurves() {
return { curve: [1, 2, 3] };
},
showCoG() {
return { cog: 0.77 };
},
};
inst._attachInputHandler(); inst._attachInputHandler();
const onInput = node._handlers.input; const onInput = node._handlers.input;
const sent = []; const sent = [];
const send = (out) => sent.push(out); const send = (out) => sent.push(out);
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {}); await onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {}); await onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
assert.equal(sent.length, 2); assert.equal(sent.length, 2);
assert.equal(Array.isArray(sent[0]), true); assert.equal(Array.isArray(sent[0]), true);