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:
150
src/commands/handlers.js
Normal file
150
src/commands/handlers.js
Normal 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
85
src/commands/index.js
Normal 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
19
src/curves/curveLoader.js
Normal 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 };
|
||||
117
src/curves/curveNormalizer.js
Normal file
117
src/curves/curveNormalizer.js
Normal 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 };
|
||||
17
src/curves/reverseCurve.js
Normal file
17
src/curves/reverseCurve.js
Normal 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 };
|
||||
61
src/display/workingCurves.js
Normal file
61
src/display/workingCurves.js
Normal 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
135
src/drift/driftAssessor.js
Normal 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;
|
||||
132
src/drift/predictionHealth.js
Normal file
132
src/drift/predictionHealth.js
Normal 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;
|
||||
85
src/flow/flowController.js
Normal file
85
src/flow/flowController.js
Normal 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;
|
||||
134
src/measurement/measurementHandlers.js
Normal file
134
src/measurement/measurementHandlers.js
Normal 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;
|
||||
23
src/prediction/groupPredictors.js
Normal file
23
src/prediction/groupPredictors.js
Normal 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 };
|
||||
82
src/prediction/operatingPoint.js
Normal file
82
src/prediction/operatingPoint.js
Normal 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;
|
||||
25
src/prediction/predictors.js
Normal file
25
src/prediction/predictors.js
Normal 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 };
|
||||
100
src/pressure/pressureInitialization.js
Normal file
100
src/pressure/pressureInitialization.js
Normal 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;
|
||||
80
src/pressure/pressureRouter.js
Normal file
80
src/pressure/pressureRouter.js
Normal 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;
|
||||
92
src/pressure/virtualChildren.js
Normal file
92
src/pressure/virtualChildren.js
Normal 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;
|
||||
58
src/state/stateBindings.js
Normal file
58
src/state/stateBindings.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user