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>
This commit is contained in:
znetsixe
2026-05-10 21:38:45 +02:00
parent 8f9150e160
commit c5bb375dd0
34 changed files with 3036 additions and 0 deletions

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,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;

View File

@@ -0,0 +1,134 @@
/**
* 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();
}
}
module.exports = MeasurementHandlers;

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,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,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;

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,
};