Files
rotatingMachine/src/specificClass.js

1752 lines
66 KiB
JavaScript
Raw Normal View History

2025-06-25 17:26:13 +02:00
const EventEmitter = require('events');
const {loadCurve,gravity,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils,coolprop, convert, POSITIONS} = require('generalFunctions');
2026-03-11 11:13:26 +01:00
const CANONICAL_UNITS = Object.freeze({
pressure: 'Pa',
atmPressure: 'Pa',
flow: 'm3/s',
power: 'W',
temperature: 'K',
});
const DEFAULT_IO_UNITS = Object.freeze({
pressure: 'mbar',
flow: 'm3/h',
power: 'kW',
temperature: 'C',
});
const DEFAULT_CURVE_UNITS = Object.freeze({
pressure: 'mbar',
flow: 'm3/h',
power: 'kW',
control: '%',
});
2025-06-25 17:26:13 +02:00
2026-02-23 13:17:18 +01:00
/**
* Rotating machine domain model.
* Combines machine curves, state transitions and measurement reconciliation
* to produce flow/power/efficiency behavior for pumps and similar assets.
*/
2025-06-25 17:26:13 +02:00
class Machine {
/*------------------- Construct and set vars -------------------*/
constructor(machineConfig = {}, stateConfig = {}, errorMetricsConfig = {}) {
//basic setup
this.emitter = new EventEmitter(); // Own EventEmitter
2025-07-01 17:02:51 +02:00
2025-07-01 15:25:07 +02:00
this.logger = new logger(machineConfig.general.logging.enabled,machineConfig.general.logging.logLevel, machineConfig.general.name);
2025-06-25 17:26:13 +02:00
this.configManager = new configManager();
2025-07-01 15:25:07 +02:00
this.defaultConfig = this.configManager.getConfig('rotatingMachine'); // Load default config for rotating machine ( use software type name ? )
2025-06-25 17:26:13 +02:00
this.configUtils = new configUtils(this.defaultConfig);
2025-07-01 15:25:07 +02:00
// Load a specific curve
this.model = machineConfig.asset.model; // Get the model from the machineConfig
2026-03-11 11:13:26 +01:00
this.rawCurve = this.model ? loadCurve(this.model) : null;
this.curve = null;
2025-07-01 15:25:07 +02:00
2025-07-24 13:15:33 +02:00
//Init config and check if it is valid
2025-07-02 16:00:52 +02:00
this.config = this.configUtils.initConfig(machineConfig);
2025-09-22 16:06:18 +02:00
//add unique name for this node.
this.config = this.configUtils.updateConfig(this.config, {general:{name: this.config.functionality?.softwareType + "_" + machineConfig.general.id}}); // add unique name if not present
2026-03-11 11:13:26 +01:00
this.unitPolicy = this._buildUnitPolicy(this.config);
this.config = this.configUtils.updateConfig(this.config, {
general: { unit: this.unitPolicy.output.flow },
asset: {
...this.config.asset,
unit: this.unitPolicy.output.flow,
curveUnits: this.unitPolicy.curve,
},
});
2025-07-02 16:00:52 +02:00
2026-03-11 11:13:26 +01:00
if (!this.model || !this.rawCurve) {
this.logger.error(`${!this.model ? 'Model not specified' : 'Curve not found for model ' + this.model} in machineConfig. Cannot make predictions.`);
2025-07-01 15:25:07 +02:00
// Set prediction objects to null to prevent method calls
this.predictFlow = null;
this.predictPower = null;
this.predictCtrl = null;
this.hasCurve = false;
}
else{
2026-03-11 11:13:26 +01:00
try {
this.hasCurve = true;
this.curve = this._normalizeMachineCurve(this.rawCurve);
this.config = this.configUtils.updateConfig(this.config, { asset: { ...this.config.asset, machineCurve: this.curve } });
//machineConfig = { ...machineConfig, asset: { ...machineConfig.asset, machineCurve: this.curve } }; // Merge curve into machineConfig
this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq }); // load nq (x : ctrl , y : flow relationship)
2026-03-11 11:13:26 +01:00
this.predictPower = new predict({ curve: this.config.asset.machineCurve.np }); // load np (x : ctrl , y : power relationship)
this.predictCtrl = new predict({ curve: this.reverseCurve(this.config.asset.machineCurve.nq) }); // load reversed nq (x: flow, y: ctrl relationship)
} catch (error) {
this.logger.error(`Curve normalization failed for model '${this.model}': ${error.message}`);
this.predictFlow = null;
this.predictPower = null;
this.predictCtrl = null;
this.hasCurve = false;
}
2025-07-01 15:25:07 +02:00
}
2025-06-25 17:26:13 +02:00
// Group-scope predicts. These are parallel "views" of the same source
// curves used by an MGC parent for combination optimization. Created
// lazily on the first setGroupOperatingPoint() call so pumps that
// never have an MGC parent pay nothing. They share input-curve refs
// with the individual predicts (see Predict.shareInputsFrom) but
// maintain independent operating-point state, so the pump's own
// sensor stream and the MGC's group operating point can coexist.
this.groupPredictFlow = null;
this.groupPredictPower = null;
this.groupPredictCtrl = null;
this.groupNCog = 0;
2025-06-25 17:26:13 +02:00
this.state = new state(stateConfig, this.logger); // Init State manager and pass logger
this.errorMetrics = new nrmse(errorMetricsConfig, this.logger);
// Initialize measurements
this.measurements = new MeasurementContainer({
autoConvert: true,
windowSize: 50,
defaultUnits: {
2026-03-11 11:13:26 +01:00
pressure: this.unitPolicy.output.pressure,
flow: this.unitPolicy.output.flow,
power: this.unitPolicy.output.power,
temperature: this.unitPolicy.output.temperature,
atmPressure: 'Pa',
},
preferredUnits: {
pressure: this.unitPolicy.output.pressure,
flow: this.unitPolicy.output.flow,
power: this.unitPolicy.output.power,
temperature: this.unitPolicy.output.temperature,
atmPressure: 'Pa',
},
canonicalUnits: this.unitPolicy.canonical,
storeCanonical: true,
strictUnitValidation: true,
throwOnInvalidUnit: true,
requireUnitForTypes: ['pressure', 'flow', 'power', 'temperature', 'atmPressure'],
}, this.logger);
2025-06-25 17:26:13 +02:00
this.interpolation = new interpolation();
2025-06-25 17:26:13 +02:00
this.flowDrift = null;
2026-03-11 11:13:26 +01:00
this.powerDrift = null;
this.pressureDrift = { level: 0, flags: ["nominal"], source: null };
this.driftProfiles = {
flow: {
windowSize: 30,
minSamplesForLongTerm: 10,
ewmaAlpha: 0.15,
alignmentToleranceMs: 2500,
strictValidation: true,
},
power: {
windowSize: 30,
minSamplesForLongTerm: 10,
ewmaAlpha: 0.15,
alignmentToleranceMs: 2500,
strictValidation: true,
},
};
this.errorMetrics.registerMetric("flow", this.driftProfiles.flow);
this.errorMetrics.registerMetric("power", this.driftProfiles.power);
this.predictionHealth = {
quality: "invalid",
confidence: 0,
pressureSource: null,
flags: ["not_initialized"],
};
2025-06-25 17:26:13 +02:00
this.currentMode = this.config.mode.current;
this.currentEfficiencyCurve = {};
this.cog = 0;
this.NCog = 0;
this.cogIndex = 0;
this.minEfficiency = 0;
this.absDistFromPeak = 0;
this.relDistFromPeak = 0;
// When position state changes, update position
2025-06-25 17:26:13 +02:00
this.state.emitter.on("positionChange", (data) => {
this.logger.debug(`Position change detected: ${data}`);
this.updatePosition();
});
//When state changes look if we need to do other updates
this.state.emitter.on("stateChange", (newState) => {
this.logger.debug(`State change detected: ${newState}`);
this._updateState();
});
//perform init for certain values
this._init();
this.child = {}; // object to hold child information so we know on what to subscribe
2025-06-25 17:26:13 +02:00
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
2026-02-19 17:36:44 +01:00
this.virtualPressureChildIds = {
upstream: "dashboard-sim-upstream",
downstream: "dashboard-sim-downstream",
};
this.virtualPressureChildren = {};
this.realPressureChildIds = {
upstream: new Set(),
downstream: new Set(),
};
2026-03-11 11:13:26 +01:00
this.childMeasurementListeners = new Map();
2026-02-19 17:36:44 +01:00
this._initVirtualPressureChildren();
2025-06-25 17:26:13 +02:00
}
2026-02-19 17:36:44 +01:00
_initVirtualPressureChildren() {
const createVirtualChild = (position) => {
const id = this.virtualPressureChildIds[position];
const name = `dashboard-sim-${position}`;
const measurements = new MeasurementContainer({
autoConvert: true,
defaultUnits: {
2026-03-11 11:13:26 +01:00
pressure: this.unitPolicy.output.pressure,
flow: this.unitPolicy.output.flow,
power: this.unitPolicy.output.power,
temperature: this.unitPolicy.output.temperature,
2026-02-19 17:36:44 +01:00
},
2026-03-11 11:13:26 +01:00
preferredUnits: {
pressure: this.unitPolicy.output.pressure,
flow: this.unitPolicy.output.flow,
power: this.unitPolicy.output.power,
temperature: this.unitPolicy.output.temperature,
},
canonicalUnits: this.unitPolicy.canonical,
storeCanonical: true,
strictUnitValidation: true,
throwOnInvalidUnit: true,
requireUnitForTypes: ['pressure'],
}, this.logger);
2026-02-19 17:36:44 +01:00
measurements.setChildId(id);
measurements.setChildName(name);
measurements.setParentRef(this);
return {
config: {
general: { id, name },
functionality: {
softwareType: "measurement",
positionVsParent: position,
},
asset: {
type: "pressure",
2026-03-11 11:13:26 +01:00
unit: this.unitPolicy.output.pressure,
2026-02-19 17:36:44 +01:00
},
},
measurements,
};
};
const upstreamChild = createVirtualChild("upstream");
const downstreamChild = createVirtualChild("downstream");
this.virtualPressureChildren.upstream = upstreamChild;
this.virtualPressureChildren.downstream = downstreamChild;
this.registerChild(upstreamChild, "measurement");
this.registerChild(downstreamChild, "measurement");
}
_init(){
//assume standard temperature is 20degrees
2026-03-11 11:13:26 +01:00
this.measurements.type('temperature').variant('measured').position('atEquipment').value(15, Date.now(), this.unitPolicy.output.temperature);
//assume standard atm pressure is at sea level
2026-03-11 11:13:26 +01:00
this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325, Date.now(), 'Pa');
2026-02-12 10:48:44 +01:00
//populate min and max when curve data is available
2026-03-11 11:13:26 +01:00
const flowunit = this.unitPolicy.canonical.flow;
2026-02-12 10:48:44 +01:00
if (this.predictFlow) {
this.measurements.type('flow').variant('predicted').position('max').value(this.predictFlow.currentFxyYMax, Date.now() , flowunit);
2026-03-11 11:13:26 +01:00
this.measurements.type('flow').variant('predicted').position('min').value(this.predictFlow.currentFxyYMin, Date.now(), flowunit);
2026-02-12 10:48:44 +01:00
} else {
this.measurements.type('flow').variant('predicted').position('max').value(0, Date.now(), flowunit);
this.measurements.type('flow').variant('predicted').position('min').value(0, Date.now(), flowunit);
}
2025-06-25 17:26:13 +02:00
}
_updateState(){
const isOperational = this._isOperationalState();
if(!isOperational){
//overrule the last prediction this should be 0 now
2026-03-11 11:13:26 +01:00
this.measurements.type("flow").variant("predicted").position("downstream").value(0,Date.now(),this.unitPolicy.canonical.flow);
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0,Date.now(),this.unitPolicy.canonical.flow);
this.measurements.type("power").variant("predicted").position("atEquipment").value(0,Date.now(),this.unitPolicy.canonical.power);
}
2026-03-11 11:13:26 +01:00
this._updatePredictionHealth();
}
/*------------------- Register child events -------------------*/
2025-09-04 17:07:29 +02:00
registerChild(child, softwareType) {
2026-02-19 17:36:44 +01:00
const resolvedSoftwareType = softwareType || child?.config?.functionality?.softwareType || "measurement";
this.logger.debug('Setting up child event for softwaretype ' + resolvedSoftwareType);
2025-09-04 17:07:29 +02:00
2026-02-19 17:36:44 +01:00
if(resolvedSoftwareType === "measurement"){
const position = String(child.config.functionality.positionVsParent || "atEquipment").toLowerCase();
2025-09-04 17:07:29 +02:00
const measurementType = child.config.asset.type;
2026-02-19 17:36:44 +01:00
const childId = child.config?.general?.id || `${measurementType}-${position}-unknown`;
const isVirtualPressureChild = Object.values(this.virtualPressureChildIds).includes(childId);
if (measurementType === "pressure" && !isVirtualPressureChild) {
this.realPressureChildIds[position]?.add(childId);
}
2025-10-03 15:41:53 +02:00
//rebuild to measurementype.variant no position and then switch based on values not strings or names.
2025-09-04 17:07:29 +02:00
const eventName = `${measurementType}.measured.${position}`;
2026-03-11 11:13:26 +01:00
const listenerKey = `${childId}:${eventName}`;
const existingListener = this.childMeasurementListeners.get(listenerKey);
if (existingListener) {
if (typeof existingListener.emitter.off === "function") {
existingListener.emitter.off(existingListener.eventName, existingListener.handler);
} else if (typeof existingListener.emitter.removeListener === "function") {
existingListener.emitter.removeListener(existingListener.eventName, existingListener.handler);
}
}
2025-09-04 17:07:29 +02:00
2025-10-03 15:41:53 +02:00
this.logger.debug(`Setting up listener for ${eventName} from child ${child.config.general.name}`);
2025-09-04 17:07:29 +02:00
// Register event listener for measurement updates
2026-03-11 11:13:26 +01:00
const listener = (eventData) => {
2025-09-04 17:07:29 +02:00
this.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
2025-10-03 15:41:53 +02:00
this.logger.debug(` Emitting... ${eventName} with data:`);
2026-03-11 11:13:26 +01:00
// Route through centralized handlers so unit validation/conversion is applied once.
2025-09-04 17:07:29 +02:00
this._callMeasurementHandler(measurementType, eventData.value, position, eventData);
2026-03-11 11:13:26 +01:00
};
child.measurements.emitter.on(eventName, listener);
this.childMeasurementListeners.set(listenerKey, {
emitter: child.measurements.emitter,
eventName,
handler: listener,
2025-09-04 17:07:29 +02:00
});
}
2025-09-04 17:07:29 +02:00
}
// Centralized handler dispatcher
_callMeasurementHandler(measurementType, value, position, context) {
switch (measurementType) {
case 'pressure':
this.updateMeasuredPressure(value, position, context);
break;
case 'flow':
this.updateMeasuredFlow(value, position, context);
break;
2026-03-11 11:13:26 +01:00
case 'power':
this.updateMeasuredPower(value, position, context);
break;
case 'temperature':
this.updateMeasuredTemperature(value, position, context);
break;
default:
this.logger.warn(`No handler for measurement type: ${measurementType}`);
// Generic handler - just update position
this.updatePosition();
break;
}
}
//---------------- END child stuff -------------//
fix: interruptible shutdown/emergencystop + dual-curve test coverage Runtime: - executeSequence now normalizes sequenceName to lowercase so parent orchestrators that use 'emergencyStop' (capital S) route correctly to the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop' not defined" warn seen when commands reach the node during accelerating. - When a shutdown or emergencystop sequence is requested while the FSM is in accelerating/decelerating, the active movement is aborted via state.abortCurrentMovement() and the sequence waits (up to 2s) for the FSM to return to 'operational' before proceeding. New helper _waitForOperational listens on the state emitter for the transition. - Single-side pressure warning: fix "acurate" typo and make the message actionable. Tests (+15, now 91/91 passing): - test/integration/interruptible-movement.integration.test.js (+3): shutdown during accelerating -> idle; emergencystop during accelerating -> off; mixed-case sequence-name normalization. - test/integration/curve-prediction.integration.test.js (+12): parametrized across both shipped pump curves (hidrostal-H05K-S03R and hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG finiteness, and reverse-predictor round-trip. E2E: - test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED benchmark that deploys one rotatingMachine per curve and runs a per-pump (pressure x ctrl) sweep inside each curve's envelope. Reports envelope compliance and monotonicity. - test/e2e/README.md documents the benchmark and a known limitation: pressure below the curve's minimum slice extrapolates wildly (defended by upstream measurement-node clamping in production). UX: - rotatingMachine.html: added placeholders and descriptions for Reaction Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED help panel with a topic reference, port documentation, state diagram, and prediction rules. Docs: - README.md rewritten (was a single line) with install, quick start, topic/port reference, state machine, predictions, testing, production status. Depends on generalFunctions commit 75d16c6 (state.js abort recovery and rotatingMachine schema additions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
/**
* Wait until the state machine reaches 'operational', or until a timeout.
* Used after an aborted movement to ensure subsequent sequence transitions
* (stopping/emergencystop) will be accepted by the FSM.
* @param {number} timeoutMs - maximum time to wait in milliseconds
* @returns {Promise<string>} the state observed when the wait ends
*/
async _waitForOperational(timeoutMs = 2000) {
if (this.state.getCurrentState() === "operational") {
return "operational";
}
return await new Promise((resolve) => {
let done = false;
const timer = setTimeout(() => {
if (done) return;
done = true;
this.state.emitter.off("stateChange", onChange);
resolve(this.state.getCurrentState());
}, timeoutMs);
const onChange = (newState) => {
if (done) return;
if (newState === "operational") {
done = true;
clearTimeout(timer);
this.state.emitter.off("stateChange", onChange);
resolve("operational");
}
};
this.state.emitter.on("stateChange", onChange);
});
}
2026-03-11 11:13:26 +01:00
_buildUnitPolicy(config) {
const flowOutputUnit = this._resolveUnitOrFallback(
config?.general?.unit,
'volumeFlowRate',
DEFAULT_IO_UNITS.flow,
'general.flow'
);
const pressureOutputUnit = this._resolveUnitOrFallback(
config?.asset?.pressureUnit,
'pressure',
DEFAULT_IO_UNITS.pressure,
'asset.pressure'
);
const powerOutputUnit = this._resolveUnitOrFallback(
config?.asset?.powerUnit,
'power',
DEFAULT_IO_UNITS.power,
'asset.power'
);
const temperatureOutputUnit = this._resolveUnitOrFallback(
config?.asset?.temperatureUnit,
'temperature',
DEFAULT_IO_UNITS.temperature,
'asset.temperature'
);
const curveUnits = this._resolveCurveUnits(config?.asset?.curveUnits || {}, flowOutputUnit);
2025-06-25 17:26:13 +02:00
2026-03-11 11:13:26 +01:00
return {
canonical: { ...CANONICAL_UNITS },
output: {
pressure: pressureOutputUnit,
flow: flowOutputUnit,
power: powerOutputUnit,
temperature: temperatureOutputUnit,
atmPressure: 'Pa',
},
curve: curveUnits,
};
}
_resolveCurveUnits(curveUnits = {}, fallbackFlowUnit = DEFAULT_CURVE_UNITS.flow) {
const pressure = this._resolveUnitOrFallback(
curveUnits.pressure,
'pressure',
DEFAULT_CURVE_UNITS.pressure,
'asset.curveUnits.pressure'
);
const flow = this._resolveUnitOrFallback(
curveUnits.flow,
'volumeFlowRate',
fallbackFlowUnit || DEFAULT_CURVE_UNITS.flow,
'asset.curveUnits.flow'
);
const power = this._resolveUnitOrFallback(
curveUnits.power,
'power',
DEFAULT_CURVE_UNITS.power,
'asset.curveUnits.power'
);
const control = typeof curveUnits.control === 'string' && curveUnits.control.trim()
? curveUnits.control.trim()
: DEFAULT_CURVE_UNITS.control;
return { pressure, flow, power, control };
}
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
const fallback = String(fallbackUnit || '').trim();
const raw = typeof candidate === 'string' ? candidate.trim() : '';
if (!raw) return fallback;
try {
const desc = convert().describe(raw);
if (expectedMeasure && desc.measure !== expectedMeasure) {
throw new Error(`expected ${expectedMeasure} but got ${desc.measure}`);
}
return raw;
} catch (error) {
this.logger.warn(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
return fallback;
}
}
_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);
}
_normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName) {
const normalized = {};
const pressureEntries = Object.entries(section || {});
let prevMedianY = null;
for (const [pressureKey, pair] of pressureEntries) {
2026-03-11 11:13:26 +01:00
const canonicalPressure = this._convertUnitValue(
Number(pressureKey),
fromPressureUnit,
toPressureUnit,
`${sectionName} pressure axis`
2025-06-25 17:26:13 +02:00
);
2026-03-11 11:13:26 +01:00
const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : [];
const yArray = Array.isArray(pair?.y) ? pair.y.map((v) => this._convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`)) : [];
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
}
// Cross-pressure anomaly detection: flag sudden jumps in median y between adjacent pressure levels
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) {
this.logger.warn(
`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.`
);
}
}
prevMedianY = medianY;
2026-03-11 11:13:26 +01:00
normalized[String(canonicalPressure)] = {
x: xArray,
y: yArray,
};
}
return normalized;
}
_normalizeMachineCurve(rawCurve, curveUnits = this.unitPolicy.curve) {
if (!rawCurve || typeof rawCurve !== 'object' || !rawCurve.nq || !rawCurve.np) {
throw new Error('Machine curve is missing required nq/np sections.');
}
return {
nq: this._normalizeCurveSection(
rawCurve.nq,
curveUnits.flow,
this.unitPolicy.canonical.flow,
curveUnits.pressure,
this.unitPolicy.canonical.pressure,
'nq'
),
np: this._normalizeCurveSection(
rawCurve.np,
curveUnits.power,
this.unitPolicy.canonical.power,
curveUnits.pressure,
this.unitPolicy.canonical.pressure,
'np'
),
};
}
isUnitValidForType(type, unit) {
return this.measurements?.isUnitCompatible?.(type, unit) === true;
}
_resolveMeasurementUnit(type, providedUnit) {
const unit = typeof providedUnit === 'string' ? providedUnit.trim() : '';
if (!unit) {
throw new Error(`Missing unit for ${type} measurement.`);
}
if (!this.isUnitValidForType(type, unit)) {
throw new Error(`Unsupported unit '${unit}' for ${type} measurement.`);
}
return unit;
}
_measurementPositionForMetric(metricId) {
if (metricId === "power") return "atEquipment";
return "downstream";
}
_resolveProcessRangeForMetric(metricId, predictedValue, measuredValue) {
let processMin = NaN;
let processMax = NaN;
if (metricId === "flow") {
processMin = Number(this.predictFlow?.currentFxyYMin);
processMax = Number(this.predictFlow?.currentFxyYMax);
} else if (metricId === "power") {
processMin = Number(this.predictPower?.currentFxyYMin);
processMax = Number(this.predictPower?.currentFxyYMax);
}
if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) {
const p = Number(predictedValue);
const m = Number(measuredValue);
const localMin = Math.min(p, m);
const localMax = Math.max(p, m);
processMin = Number.isFinite(localMin) ? localMin : 0;
processMax = Number.isFinite(localMax) && localMax > processMin ? localMax : processMin + 1;
}
return { processMin, processMax };
}
_updateMetricDrift(metricId, measuredValue, context = {}) {
const position = this._measurementPositionForMetric(metricId);
const predictedValue = Number(
this.measurements
.type(metricId)
.variant("predicted")
.position(position)
.getCurrentValue()
);
const measured = Number(measuredValue);
if (!Number.isFinite(predictedValue) || !Number.isFinite(measured)) return null;
const { processMin, processMax } = this._resolveProcessRangeForMetric(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) {
if (metricId === "flow") this.flowDrift = drift;
if (metricId === "power") this.powerDrift = drift;
}
return drift;
} catch (error) {
this.logger.warn(`Drift update failed for metric '${metricId}': ${error.message}`);
return null;
}
}
_updatePressureDriftStatus() {
const status = this.getPressureInitializationStatus();
const flags = [];
let level = 0;
if (!status.initialized) {
level = 2;
flags.push("no_pressure_input");
} else if (!status.hasDifferential) {
level = 1;
flags.push("single_side_pressure");
}
if (status.hasDifferential) {
const upstream = this._getPreferredPressureValue("upstream");
const downstream = this._getPreferredPressureValue("downstream");
const diff = Number(downstream) - Number(upstream);
if (Number.isFinite(diff) && diff < 0) {
level = Math.max(level, 3);
flags.push("negative_pressure_differential");
}
}
this.pressureDrift = {
level,
source: status.source,
flags: flags.length ? flags : ["nominal"],
};
return this.pressureDrift;
}
assessDrift(measurement, processMin, processMax) {
const metricId = String(measurement || "").toLowerCase();
const position = this._measurementPositionForMetric(metricId);
const predictedMeasurement = this.measurements.type(metricId).variant("predicted").position(position).getAllValues();
const measuredMeasurement = this.measurements.type(metricId).variant("measured").position(position).getAllValues();
if (!predictedMeasurement?.values || !measuredMeasurement?.values) return null;
return this.errorMetrics.assessDrift(
predictedMeasurement.values,
measuredMeasurement.values,
processMin,
processMax,
{
metricId,
predictedTimestamps: predictedMeasurement.timestamps,
measuredTimestamps: measuredMeasurement.timestamps,
...(this.driftProfiles[metricId] || {}),
}
);
}
_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;
}
_updatePredictionHealth() {
const status = this.getPressureInitializationStatus();
const pressureDrift = this._updatePressureDriftStatus();
const flags = [...pressureDrift.flags];
let confidence = 0;
const pressureSource = status.source;
if (pressureSource === "differential") {
confidence = 0.9;
} else if (pressureSource === "upstream" || pressureSource === "downstream") {
confidence = 0.55;
} else {
confidence = 0.2;
}
if (!this._isOperationalState()) {
confidence = 0;
flags.push("not_operational");
}
if (pressureDrift.level >= 3) confidence -= 0.35;
else if (pressureDrift.level === 2) confidence -= 0.2;
else if (pressureDrift.level === 1) confidence -= 0.1;
const currentPosition = Number(this.state?.getCurrentPosition?.());
const { min, max } = this._resolveSetpointBounds();
if (Number.isFinite(currentPosition) && Number.isFinite(min) && Number.isFinite(max) && max > min) {
const span = max - min;
const edgeDistance = Math.min(Math.abs(currentPosition - min), Math.abs(max - currentPosition));
if (edgeDistance < span * 0.05) {
confidence -= 0.1;
flags.push("near_curve_edge");
}
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
confidence = this._applyDriftPenalty(this.flowDrift, confidence, flags, "flow");
confidence = this._applyDriftPenalty(this.powerDrift, confidence, flags, "power");
confidence = Math.max(0, Math.min(1, confidence));
let quality = "invalid";
if (confidence >= 0.8) quality = "high";
else if (confidence >= 0.55) quality = "medium";
else if (confidence >= 0.3) quality = "low";
this.predictionHealth = {
quality,
confidence,
pressureSource,
flags: flags.length ? Array.from(new Set(flags)) : ["nominal"],
};
return this.predictionHealth;
}
2025-06-25 17:26:13 +02:00
reverseCurve(curve) {
const reversedCurve = {};
for (const [pressure, values] of Object.entries(curve)) {
reversedCurve[pressure] = {
x: [...values.y], // Previous y becomes new x
y: [...values.x] // Previous x becomes new y
};
}
return reversedCurve;
}
// -------- Config -------- //
updateConfig(newConfig) {
this.config = this.configUtils.updateConfig(this.config, newConfig);
}
// -------- Mode and Input Management -------- //
isValidSourceForMode(source, mode) {
const allowedSourcesSet = this.config.mode.allowedSources[mode] || [];
const allowed = allowedSourcesSet.has(source);
allowed?
this.logger.debug(`source is allowed proceeding with ${source} for mode ${mode}`) :
this.logger.warn(`${source} is not allowed in mode ${mode}`);
return allowed;
2025-06-25 17:26:13 +02:00
}
isValidActionForMode(action, mode) {
const allowedActionsSet = this.config.mode.allowedActions[mode] || [];
const allowed = allowedActionsSet.has(action);
allowed ?
this.logger.debug(`Action is allowed proceeding with ${action} for mode ${mode}`) :
this.logger.warn(`${action} is not allowed in mode ${mode}`);
return allowed;
2025-06-25 17:26:13 +02:00
}
async handleInput(source, action, parameter) {
//sanitize input
if( typeof action !== 'string'){this.logger.error(`Action must be string`); return;}
//convert to lower case to avoid to many mistakes in commands
action = action.toLowerCase();
// check for validity of the request
if(!this.isValidActionForMode(action,this.currentMode)){return ;}
if (!this.isValidSourceForMode(source, this.currentMode)) {return ;}
2025-06-25 17:26:13 +02:00
this.logger.info(`Handling input from source '${source}' with action '${action}' in mode '${this.currentMode}'.`);
2025-06-25 17:26:13 +02:00
try {
switch (action) {
case "execsequence":
return await this.executeSequence(parameter);
case "execmovement":
return await this.setpoint(parameter);
case "entermaintenance":
return await this.executeSequence(parameter);
case "exitmaintenance":
return await this.executeSequence(parameter);
case "flowmovement":
2026-03-11 11:13:26 +01:00
// External flow setpoint is interpreted in configured output flow unit.
const canonicalFlowSetpoint = this._convertUnitValue(
parameter,
this.unitPolicy.output.flow,
this.unitPolicy.canonical.flow,
'flowmovement setpoint'
);
2025-06-25 17:26:13 +02:00
// Calculate the control value for a desired flow
2026-03-11 11:13:26 +01:00
const pos = this.calcCtrl(canonicalFlowSetpoint);
2025-06-25 17:26:13 +02:00
// Move to the desired setpoint
return await this.setpoint(pos);
case "emergencystop":
2025-06-25 17:26:13 +02:00
this.logger.warn(`Emergency stop activated by '${source}'.`);
return await this.executeSequence("emergencystop");
case "statuscheck":
2025-06-25 17:26:13 +02:00
this.logger.info(`Status Check: Mode = '${this.currentMode}', Source = '${source}'.`);
break;
2025-06-25 17:26:13 +02:00
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}`);
}
}
abortMovement(reason = "group override") {
if (this.state?.abortCurrentMovement) {
this.state.abortCurrentMovement(reason);
}
2025-06-25 17:26:13 +02:00
}
setMode(newMode) {
2025-07-24 13:15:33 +02:00
const availableModes = this.defaultConfig.mode.current.rules.values.map(v => v.value);
2025-06-25 17:26:13 +02:00
if (!availableModes.includes(newMode)) {
this.logger.warn(`Invalid mode '${newMode}'. Allowed modes are: ${availableModes.join(', ')}`);
return;
}
this.currentMode = newMode;
this.logger.info(`Mode successfully changed to '${newMode}'.`);
}
// -------- Sequence Handlers -------- //
async executeSequence(sequenceName) {
fix: interruptible shutdown/emergencystop + dual-curve test coverage Runtime: - executeSequence now normalizes sequenceName to lowercase so parent orchestrators that use 'emergencyStop' (capital S) route correctly to the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop' not defined" warn seen when commands reach the node during accelerating. - When a shutdown or emergencystop sequence is requested while the FSM is in accelerating/decelerating, the active movement is aborted via state.abortCurrentMovement() and the sequence waits (up to 2s) for the FSM to return to 'operational' before proceeding. New helper _waitForOperational listens on the state emitter for the transition. - Single-side pressure warning: fix "acurate" typo and make the message actionable. Tests (+15, now 91/91 passing): - test/integration/interruptible-movement.integration.test.js (+3): shutdown during accelerating -> idle; emergencystop during accelerating -> off; mixed-case sequence-name normalization. - test/integration/curve-prediction.integration.test.js (+12): parametrized across both shipped pump curves (hidrostal-H05K-S03R and hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG finiteness, and reverse-predictor round-trip. E2E: - test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED benchmark that deploys one rotatingMachine per curve and runs a per-pump (pressure x ctrl) sweep inside each curve's envelope. Reports envelope compliance and monotonicity. - test/e2e/README.md documents the benchmark and a known limitation: pressure below the curve's minimum slice extrapolates wildly (defended by upstream measurement-node clamping in production). UX: - rotatingMachine.html: added placeholders and descriptions for Reaction Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED help panel with a topic reference, port documentation, state diagram, and prediction rules. Docs: - README.md rewritten (was a single line) with install, quick start, topic/port reference, state machine, predictions, testing, production status. Depends on generalFunctions commit 75d16c6 (state.js abort recovery and rotatingMachine schema additions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
// Defensive: sequence keys in the config are lowercase. Accept any casing
// from callers (parent orchestrators, tests, legacy flows) and normalize.
if (typeof sequenceName === 'string') {
sequenceName = sequenceName.toLowerCase();
}
2025-06-25 17:26:13 +02:00
const sequence = this.config.sequences[sequenceName];
if (!sequence || sequence.size === 0) {
this.logger.warn(`Sequence '${sequenceName}' not defined.`);
return;
}
fix: interruptible shutdown/emergencystop + dual-curve test coverage Runtime: - executeSequence now normalizes sequenceName to lowercase so parent orchestrators that use 'emergencyStop' (capital S) route correctly to the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop' not defined" warn seen when commands reach the node during accelerating. - When a shutdown or emergencystop sequence is requested while the FSM is in accelerating/decelerating, the active movement is aborted via state.abortCurrentMovement() and the sequence waits (up to 2s) for the FSM to return to 'operational' before proceeding. New helper _waitForOperational listens on the state emitter for the transition. - Single-side pressure warning: fix "acurate" typo and make the message actionable. Tests (+15, now 91/91 passing): - test/integration/interruptible-movement.integration.test.js (+3): shutdown during accelerating -> idle; emergencystop during accelerating -> off; mixed-case sequence-name normalization. - test/integration/curve-prediction.integration.test.js (+12): parametrized across both shipped pump curves (hidrostal-H05K-S03R and hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG finiteness, and reverse-predictor round-trip. E2E: - test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED benchmark that deploys one rotatingMachine per curve and runs a per-pump (pressure x ctrl) sweep inside each curve's envelope. Reports envelope compliance and monotonicity. - test/e2e/README.md documents the benchmark and a known limitation: pressure below the curve's minimum slice extrapolates wildly (defended by upstream measurement-node clamping in production). UX: - rotatingMachine.html: added placeholders and descriptions for Reaction Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED help panel with a topic reference, port documentation, state diagram, and prediction rules. Docs: - README.md rewritten (was a single line) with install, quick start, topic/port reference, state machine, predictions, testing, production status. Depends on generalFunctions commit 75d16c6 (state.js abort recovery and rotatingMachine schema additions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
// Interruptible movement: if a shutdown or emergency-stop is requested
// while a setpoint move is mid-flight (accelerating/decelerating), abort
// the move first and wait briefly for the FSM to return to 'operational'.
// Without this, transitions like accelerating->stopping are rejected by
// stateManager.isValidTransition, leaving the machine running.
const currentState = this.state.getCurrentState();
const interruptible = new Set(["shutdown", "emergencystop"]);
if (interruptible.has(sequenceName) &&
(currentState === "accelerating" || currentState === "decelerating")) {
this.logger.warn(`Sequence '${sequenceName}' requested during '${currentState}'. Aborting active movement.`);
this.state.abortCurrentMovement(`${sequenceName} sequence requested`, { returnToOperational: true });
fix: interruptible shutdown/emergencystop + dual-curve test coverage Runtime: - executeSequence now normalizes sequenceName to lowercase so parent orchestrators that use 'emergencyStop' (capital S) route correctly to the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop' not defined" warn seen when commands reach the node during accelerating. - When a shutdown or emergencystop sequence is requested while the FSM is in accelerating/decelerating, the active movement is aborted via state.abortCurrentMovement() and the sequence waits (up to 2s) for the FSM to return to 'operational' before proceeding. New helper _waitForOperational listens on the state emitter for the transition. - Single-side pressure warning: fix "acurate" typo and make the message actionable. Tests (+15, now 91/91 passing): - test/integration/interruptible-movement.integration.test.js (+3): shutdown during accelerating -> idle; emergencystop during accelerating -> off; mixed-case sequence-name normalization. - test/integration/curve-prediction.integration.test.js (+12): parametrized across both shipped pump curves (hidrostal-H05K-S03R and hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG finiteness, and reverse-predictor round-trip. E2E: - test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED benchmark that deploys one rotatingMachine per curve and runs a per-pump (pressure x ctrl) sweep inside each curve's envelope. Reports envelope compliance and monotonicity. - test/e2e/README.md documents the benchmark and a known limitation: pressure below the curve's minimum slice extrapolates wildly (defended by upstream measurement-node clamping in production). UX: - rotatingMachine.html: added placeholders and descriptions for Reaction Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED help panel with a topic reference, port documentation, state diagram, and prediction rules. Docs: - README.md rewritten (was a single line) with install, quick start, topic/port reference, state machine, predictions, testing, production status. Depends on generalFunctions commit 75d16c6 (state.js abort recovery and rotatingMachine schema additions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
await this._waitForOperational(2000);
}
2025-06-25 17:26:13 +02:00
if (this.state.getCurrentState() == "operational" && sequenceName == "shutdown") {
this.logger.info(`Machine will ramp down to position 0 before performing ${sequenceName} sequence`);
await this.setpoint(0);
}
this.logger.info(` --------- Executing sequence: ${sequenceName} -------------`);
for (const state of sequence) {
try {
await this.state.transitionToState(state);
// Update measurements after state change
} catch (error) {
this.logger.error(`Error during sequence '${sequenceName}': ${error}`);
break; // Exit sequence execution on error
}
}
//recalc flow and power
this.updatePosition();
2025-06-25 17:26:13 +02:00
}
async setpoint(setpoint) {
try {
2026-03-11 11:13:26 +01:00
// Validate and normalize setpoint
if (!Number.isFinite(setpoint)) {
this.logger.error("Invalid setpoint: Setpoint must be a finite number.");
return;
}
const { min, max } = this._resolveSetpointBounds();
const constrainedSetpoint = Math.min(Math.max(setpoint, min), max);
if (constrainedSetpoint !== setpoint) {
this.logger.warn(`Requested setpoint ${setpoint} constrained to ${constrainedSetpoint} (min=${min}, max=${max})`);
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
this.logger.info(`Setting setpoint to ${constrainedSetpoint}. Current position: ${this.state.getCurrentPosition()}`);
2025-07-02 16:00:52 +02:00
2025-06-25 17:26:13 +02:00
// Move to the desired setpoint
2026-03-11 11:13:26 +01:00
await this.state.moveTo(constrainedSetpoint);
2025-06-25 17:26:13 +02:00
} catch (error) {
2026-03-11 11:13:26 +01:00
this.logger.error(`Error setting setpoint: ${error}`);
2025-06-25 17:26:13 +02:00
}
}
2026-03-11 11:13:26 +01:00
_resolveSetpointBounds() {
const stateMin = Number(this.state?.movementManager?.minPosition);
const stateMax = Number(this.state?.movementManager?.maxPosition);
const curveMin = Number(this.predictFlow?.currentFxyXMin);
const curveMax = Number(this.predictFlow?.currentFxyXMax);
const minCandidates = [stateMin, curveMin].filter(Number.isFinite);
const maxCandidates = [stateMax, curveMax].filter(Number.isFinite);
const fallbackMin = Number.isFinite(stateMin) ? stateMin : 0;
const fallbackMax = Number.isFinite(stateMax) ? stateMax : 100;
let min = minCandidates.length ? Math.max(...minCandidates) : fallbackMin;
let max = maxCandidates.length ? Math.min(...maxCandidates) : fallbackMax;
if (min > max) {
this.logger.warn(`Invalid setpoint bounds detected (min=${min}, max=${max}). Falling back to movement bounds.`);
min = fallbackMin;
max = fallbackMax;
}
return { min, max };
}
2025-06-25 17:26:13 +02:00
// Calculate flow based on current pressure and position
calcFlow(x) {
2025-07-02 16:00:52 +02:00
if(this.hasCurve) {
if (!this._isOperationalState()) {
2026-03-11 11:13:26 +01:00
this.measurements.type("flow").variant("predicted").position("downstream").value(0,Date.now(),this.unitPolicy.canonical.flow);
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0,Date.now(),this.unitPolicy.canonical.flow);
2025-07-01 15:25:07 +02:00
this.logger.debug(`Machine is not operational. Setting predicted flow to 0.`);
return 0;
}
2025-06-25 17:26:13 +02:00
const rawFlow = this.predictFlow.y(x);
const cFlow = Math.max(0, rawFlow);
2026-03-11 11:13:26 +01:00
this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
2025-06-25 17:26:13 +02:00
return cFlow;
2025-07-01 15:25:07 +02:00
}
// If no curve data is available, log a warning and return 0
this.logger.warn(`No curve data available for flow calculation. Returning 0.`);
2026-03-11 11:13:26 +01:00
this.measurements.type("flow").variant("predicted").position("downstream").value(0, Date.now(),this.unitPolicy.canonical.flow);
this.measurements.type("flow").variant("predicted").position("atEquipment").value(0, Date.now(),this.unitPolicy.canonical.flow);
2025-07-01 15:25:07 +02:00
return 0;
2025-06-25 17:26:13 +02:00
}
// Calculate power based on current pressure and position
calcPower(x) {
2025-07-02 16:00:52 +02:00
if(this.hasCurve) {
if (!this._isOperationalState()) {
2026-03-11 11:13:26 +01:00
this.measurements.type("power").variant("predicted").position('atEquipment').value(0, Date.now(), this.unitPolicy.canonical.power);
2025-07-01 15:25:07 +02:00
this.logger.debug(`Machine is not operational. Setting predicted power to 0.`);
return 0;
}
2025-06-25 17:26:13 +02:00
const rawPower = this.predictPower.y(x);
const cPower = Math.max(0, rawPower);
2026-03-11 11:13:26 +01:00
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power);
2025-06-25 17:26:13 +02:00
return cPower;
2025-07-01 15:25:07 +02:00
}
// If no curve data is available, log a warning and return 0
this.logger.warn(`No curve data available for power calculation. Returning 0.`);
2026-03-11 11:13:26 +01:00
this.measurements.type("power").variant("predicted").position('atEquipment').value(0, Date.now(), this.unitPolicy.canonical.power);
2025-07-01 15:25:07 +02:00
return 0;
2025-06-25 17:26:13 +02:00
}
// calculate the power consumption using only flow and pressure
inputFlowCalcPower(flow) {
2025-07-02 16:00:52 +02:00
if(this.hasCurve) {
2025-07-01 15:25:07 +02:00
this.predictCtrl.currentX = flow;
const cCtrl = this.predictCtrl.y(flow);
this.predictPower.currentX = cCtrl;
const cPower = this.predictPower.y(cCtrl);
return cPower;
}
2025-07-01 15:25:07 +02:00
// If no curve data is available, log a warning and return 0
this.logger.warn(`No curve data available for power calculation. Returning 0.`);
2026-03-11 11:13:26 +01:00
this.measurements.type("power").variant("predicted").position('atEquipment').value(0, Date.now(), this.unitPolicy.canonical.power);
2025-07-01 15:25:07 +02:00
return 0;
2025-06-25 17:26:13 +02:00
}
// ---------- Group-scope operating point (MGC parent uses this) ----------
//
// The pump's individual predicts (predictFlow / predictPower / predictCtrl)
// are driven by THIS pump's own pressure sensors via getMeasuredPressure().
// For combination optimization an MGC parent needs every pump curve
// evaluated at ONE shared operating point (the manifold differential).
// Doing that on the individual predicts would corrupt the pump's own
// diagnostic outputs. So we keep a parallel set of predicts here that
// ONLY the MGC drives via setGroupOperatingPoint(). Pump's individual
// outputs are unaffected.
// Lazily create group-scope predicts that share input curves with the
// individual ones. Safe to call multiple times.
_ensureGroupPredicts() {
if (!this.hasCurve || !this.predictFlow || !this.predictPower || !this.predictCtrl) return;
if (this.groupPredictFlow && this.groupPredictPower && this.groupPredictCtrl) return;
this.groupPredictFlow = new predict({ shareInputsFrom: this.predictFlow });
this.groupPredictPower = new predict({ shareInputsFrom: this.predictPower });
this.groupPredictCtrl = new predict({ shareInputsFrom: this.predictCtrl });
}
// External (MGC) API: set the group operating point. Recomputes the
// group predicts at the new differential pressure and updates groupNCog.
// Does NOT touch this.predictFlow / predictPower / predictCtrl /
// this.NCog / this.measurements.
setGroupOperatingPoint(downstreamPa, upstreamPa) {
this._ensureGroupPredicts();
if (!this.groupPredictFlow || !this.groupPredictPower) return;
if (!Number.isFinite(downstreamPa) || !Number.isFinite(upstreamPa)) return;
const diff = downstreamPa - upstreamPa;
if (diff <= 0) return;
this.groupPredictFlow.fDimension = diff;
this.groupPredictPower.fDimension = diff;
if (this.groupPredictCtrl) this.groupPredictCtrl.fDimension = diff;
this.groupNCog = this._calcGroupCog();
}
// Power consumption at flow on the group operating point (used by
// MGC's marginal-cost refinement). Falls back to the individual
// calculation if the group predicts haven't been initialised.
groupCalcPower(flow) {
if (!this.groupPredictFlow || !this.groupPredictPower || !this.groupPredictCtrl) {
return this.inputFlowCalcPower(flow);
}
this.groupPredictCtrl.currentX = flow;
const cCtrl = this.groupPredictCtrl.y(flow);
this.groupPredictPower.currentX = cCtrl;
return this.groupPredictPower.y(cCtrl);
}
// Mirrors calcCog() but reads from group predicts. Returns the
// normalised cog (0..1) — the MGC optimizer uses this for BEP-Gravitation.
_calcGroupCog() {
if (!this.groupPredictFlow || !this.groupPredictPower) return 0;
const powerCurve = this.groupPredictPower.currentFxyCurve[this.groupPredictPower.currentF];
const flowCurve = this.groupPredictFlow.currentFxyCurve[this.groupPredictFlow.currentF];
if (!powerCurve?.y?.length || !flowCurve?.y?.length) return 0;
const { peakIndex } = this.calcEfficiencyCurve(powerCurve, flowCurve);
const yMin = this.groupPredictFlow.currentFxyYMin;
const yMax = this.groupPredictFlow.currentFxyYMax;
if (yMax <= yMin) return 0;
return (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
}
2025-06-25 17:26:13 +02:00
// Function to predict control value for a desired flow
calcCtrl(x) {
2025-07-02 16:00:52 +02:00
if(this.hasCurve) {
2025-07-01 15:25:07 +02:00
this.predictCtrl.currentX = x;
const cCtrl = this.predictCtrl.y(x);
this.measurements.type("ctrl").variant("predicted").position('atEquipment').value(cCtrl);
2025-07-01 15:25:07 +02:00
//this.logger.debug(`Calculated ctrl: ${cCtrl} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
return cCtrl;
}
// If no curve data is available, log a warning and return 0
this.logger.warn(`No curve data available for control calculation. Returning 0.`);
2026-03-11 11:13:26 +01:00
this.measurements.type("ctrl").variant("predicted").position('atEquipment').value(0, Date.now());
2025-07-01 15:25:07 +02:00
return 0;
2025-06-25 17:26:13 +02:00
}
// returns the best available pressure measurement to use in the prediction calculation
// this will be either the differential pressure, downstream or upstream pressure
2025-06-25 17:26:13 +02:00
getMeasuredPressure() {
if(!this.hasCurve || !this.predictFlow || !this.predictPower || !this.predictCtrl){
this.logger.error(`No valid curve available to calculate prediction using last known pressure`);
return 0;
}
2026-02-19 17:36:44 +01:00
const upstreamPressure = this._getPreferredPressureValue("upstream");
const downstreamPressure = this._getPreferredPressureValue("downstream");
2025-06-25 17:26:13 +02:00
// Both upstream & downstream => differential
2026-02-19 17:36:44 +01:00
if (upstreamPressure != null && downstreamPressure != null) {
const pressureDiffValue = downstreamPressure - upstreamPressure;
this.logger.debug(`Pressure differential: ${pressureDiffValue}`);
this.predictFlow.fDimension = pressureDiffValue;
this.predictPower.fDimension = pressureDiffValue;
this.predictCtrl.fDimension = pressureDiffValue;
2025-06-25 17:26:13 +02:00
//update the cog
const { cog, minEfficiency } = this.calcCog();
// calc efficiency
const efficiency = this.calcEfficiency(this.predictPower.outputY, this.predictFlow.outputY, "predicted");
//update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency);
2026-02-19 17:36:44 +01:00
return pressureDiffValue;
2025-06-25 17:26:13 +02:00
}
// Only downstream => use it, warn that it's partial
if (downstreamPressure != null) {
fix: interruptible shutdown/emergencystop + dual-curve test coverage Runtime: - executeSequence now normalizes sequenceName to lowercase so parent orchestrators that use 'emergencyStop' (capital S) route correctly to the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop' not defined" warn seen when commands reach the node during accelerating. - When a shutdown or emergencystop sequence is requested while the FSM is in accelerating/decelerating, the active movement is aborted via state.abortCurrentMovement() and the sequence waits (up to 2s) for the FSM to return to 'operational' before proceeding. New helper _waitForOperational listens on the state emitter for the transition. - Single-side pressure warning: fix "acurate" typo and make the message actionable. Tests (+15, now 91/91 passing): - test/integration/interruptible-movement.integration.test.js (+3): shutdown during accelerating -> idle; emergencystop during accelerating -> off; mixed-case sequence-name normalization. - test/integration/curve-prediction.integration.test.js (+12): parametrized across both shipped pump curves (hidrostal-H05K-S03R and hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG finiteness, and reverse-predictor round-trip. E2E: - test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED benchmark that deploys one rotatingMachine per curve and runs a per-pump (pressure x ctrl) sweep inside each curve's envelope. Reports envelope compliance and monotonicity. - test/e2e/README.md documents the benchmark and a known limitation: pressure below the curve's minimum slice extrapolates wildly (defended by upstream measurement-node clamping in production). UX: - rotatingMachine.html: added placeholders and descriptions for Reaction Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED help panel with a topic reference, port documentation, state diagram, and prediction rules. Docs: - README.md rewritten (was a single line) with install, quick start, topic/port reference, state machine, predictions, testing, production status. Depends on generalFunctions commit 75d16c6 (state.js abort recovery and rotatingMachine schema additions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure}. Prediction accuracy is degraded; inject upstream pressure too.`);
2025-06-25 17:26:13 +02:00
this.predictFlow.fDimension = downstreamPressure;
this.predictPower.fDimension = downstreamPressure;
this.predictCtrl.fDimension = downstreamPressure;
//update the cog
const { cog, minEfficiency } = this.calcCog();
// calc efficiency
const efficiency = this.calcEfficiency(this.predictPower.outputY, this.predictFlow.outputY, "predicted");
//update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency);
return downstreamPressure;
}
2026-02-19 17:36:44 +01:00
// Only upstream => use it, warn that it's partial
if (upstreamPressure != null) {
fix: interruptible shutdown/emergencystop + dual-curve test coverage Runtime: - executeSequence now normalizes sequenceName to lowercase so parent orchestrators that use 'emergencyStop' (capital S) route correctly to the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop' not defined" warn seen when commands reach the node during accelerating. - When a shutdown or emergencystop sequence is requested while the FSM is in accelerating/decelerating, the active movement is aborted via state.abortCurrentMovement() and the sequence waits (up to 2s) for the FSM to return to 'operational' before proceeding. New helper _waitForOperational listens on the state emitter for the transition. - Single-side pressure warning: fix "acurate" typo and make the message actionable. Tests (+15, now 91/91 passing): - test/integration/interruptible-movement.integration.test.js (+3): shutdown during accelerating -> idle; emergencystop during accelerating -> off; mixed-case sequence-name normalization. - test/integration/curve-prediction.integration.test.js (+12): parametrized across both shipped pump curves (hidrostal-H05K-S03R and hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG finiteness, and reverse-predictor round-trip. E2E: - test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED benchmark that deploys one rotatingMachine per curve and runs a per-pump (pressure x ctrl) sweep inside each curve's envelope. Reports envelope compliance and monotonicity. - test/e2e/README.md documents the benchmark and a known limitation: pressure below the curve's minimum slice extrapolates wildly (defended by upstream measurement-node clamping in production). UX: - rotatingMachine.html: added placeholders and descriptions for Reaction Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED help panel with a topic reference, port documentation, state diagram, and prediction rules. Docs: - README.md rewritten (was a single line) with install, quick start, topic/port reference, state machine, predictions, testing, production status. Depends on generalFunctions commit 75d16c6 (state.js abort recovery and rotatingMachine schema additions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
this.logger.warn(`Using upstream pressure only for prediction: ${upstreamPressure}. Prediction accuracy is degraded; inject downstream pressure too.`);
2026-02-19 17:36:44 +01:00
this.predictFlow.fDimension = upstreamPressure;
this.predictPower.fDimension = upstreamPressure;
this.predictCtrl.fDimension = upstreamPressure;
//update the cog
const { cog, minEfficiency } = this.calcCog();
// calc efficiency
const efficiency = this.calcEfficiency(this.predictPower.outputY, this.predictFlow.outputY, "predicted");
//update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency);
return upstreamPressure;
}
2025-06-25 17:26:13 +02:00
this.logger.error(`No valid pressure measurements available to calculate prediction using last known pressure`);
//set default at 0 => lowest pressure possible
this.predictFlow.fDimension = 0;
this.predictPower.fDimension = 0;
this.predictCtrl.fDimension = 0;
//update the cog
const { cog, minEfficiency } = this.calcCog();
// calc efficiency
const efficiency = this.calcEfficiency(this.predictPower.outputY, this.predictFlow.outputY, "predicted");
//update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency);
//place min and max flow capabilities in containerthis.predictFlow.currentFxyYMax - this.predictFlow.currentFxyYMin
2026-03-11 11:13:26 +01:00
this.measurements.type('flow').variant('predicted').position('max').value(this.predictFlow.currentFxyYMax, Date.now(), this.unitPolicy.canonical.flow);
this.measurements.type('flow').variant('predicted').position('min').value(this.predictFlow.currentFxyYMin, Date.now(), this.unitPolicy.canonical.flow);
2025-06-25 17:26:13 +02:00
return 0;
}
2026-02-19 17:36:44 +01:00
_getPreferredPressureValue(position) {
const realIds = Array.from(this.realPressureChildIds[position] || []);
for (const childId of realIds) {
const value = this.measurements
.type("pressure")
.variant("measured")
.position(position)
.child(childId)
.getCurrentValue();
if (value != null) return value;
}
const virtualId = this.virtualPressureChildIds[position];
if (virtualId) {
const simulatedValue = this.measurements
.type("pressure")
.variant("measured")
.position(position)
.child(virtualId)
.getCurrentValue();
if (simulatedValue != null) return simulatedValue;
}
return this.measurements
.type("pressure")
.variant("measured")
.position(position)
.getCurrentValue();
}
getPressureInitializationStatus() {
const upstreamPressure = this._getPreferredPressureValue("upstream");
const downstreamPressure = this._getPreferredPressureValue("downstream");
const hasUpstream = upstreamPressure != null;
const hasDownstream = downstreamPressure != null;
const hasDifferential = hasUpstream && hasDownstream;
return {
hasUpstream,
hasDownstream,
hasDifferential,
initialized: hasUpstream || hasDownstream || hasDifferential,
source: hasDifferential ? 'differential' : hasDownstream ? 'downstream' : hasUpstream ? 'upstream' : null,
};
}
updateSimulatedMeasurement(type, position, value, context = {}) {
const normalizedType = String(type || "").toLowerCase();
const normalizedPosition = String(position || "atEquipment").toLowerCase();
if (normalizedType !== "pressure") {
this._callMeasurementHandler(normalizedType, value, normalizedPosition, context);
return;
}
if (!this.virtualPressureChildIds[normalizedPosition]) {
this.logger.warn(`Unsupported simulated pressure position '${normalizedPosition}'`);
return;
}
const child = this.virtualPressureChildren[normalizedPosition];
if (!child?.measurements) {
this.logger.error(`Virtual pressure child '${normalizedPosition}' is missing`);
return;
}
2026-03-11 11:13:26 +01:00
let measurementUnit;
try {
measurementUnit = this._resolveMeasurementUnit('pressure', context.unit);
} catch (error) {
this.logger.warn(`Rejected simulated pressure measurement: ${error.message}`);
return;
}
2026-02-19 17:36:44 +01:00
child.measurements
.type("pressure")
.variant("measured")
.position(normalizedPosition)
2026-03-11 11:13:26 +01:00
.value(value, context.timestamp || Date.now(), measurementUnit);
2026-02-19 17:36:44 +01:00
}
2025-06-25 17:26:13 +02:00
handleMeasuredFlow() {
const flowDiff = this.measurements.type('flow').variant('measured').difference();
// If both are present
if (flowDiff != null) {
// In theory, mass flow in = mass flow out, so they should match or be close.
if (flowDiff.value < 0.001) {
// flows match within tolerance
this.logger.debug(`Flow match: ${flowDiff.value}`);
return flowDiff.value;
} else {
// Mismatch => decide how to handle. Maybe take the average?
// Or bail out with an error. Example: we bail out here.
this.logger.error(`Something wrong with down or upstream flow measurement. Bailing out!`);
return null;
}
}
// get
2025-07-02 16:00:52 +02:00
const upstreamFlow = this.measurements.type('flow').variant('measured').position('upstream').getCurrentValue();
2025-06-25 17:26:13 +02:00
// Only upstream => might still accept it, but warn
if (upstreamFlow != null) {
this.logger.warn(`Only upstream flow is present. Using it but results may be incomplete!`);
return upstreamFlow;
}
// get
2025-07-02 16:00:52 +02:00
const downstreamFlow = this.measurements.type('flow').variant('measured').position('downstream').getCurrentValue();
2025-06-25 17:26:13 +02:00
// Only downstream => might still accept it, but warn
if (downstreamFlow != null) {
this.logger.warn(`Only downstream flow is present. Using it but results may be incomplete!`);
return downstreamFlow;
}
// Neither => error
this.logger.error(`No upstream or downstream flow measurement. Bailing out!`);
return null;
}
handleMeasuredPower() {
const power = this.measurements.type("power").variant("measured").position("atEquipment").getCurrentValue();
2025-06-25 17:26:13 +02:00
// If your system calls it "upstream" or just a single "value", adjust accordingly
if (power != null) {
this.logger.debug(`Measured power: ${power}`);
return power;
} else {
this.logger.error(`No measured power found. Bailing out!`);
return null;
}
}
2026-01-29 13:32:39 +01:00
updateMeasuredTemperature(value, position, context = {}) {
this.logger.debug(`Temperature update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
2026-03-11 11:13:26 +01:00
let measurementUnit;
try {
measurementUnit = this._resolveMeasurementUnit('temperature', context.unit);
} catch (error) {
this.logger.warn(`Rejected temperature update: ${error.message}`);
return;
}
this.measurements.type("temperature").variant("measured").position(position || 'atEquipment').child(context.childId).value(value, context.timestamp, measurementUnit);
2026-01-29 13:32:39 +01:00
}
2025-10-07 18:10:45 +02:00
// context handler for pressure updates
updateMeasuredPressure(value, position, context = {}) {
this.logger.debug(`Pressure update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
2026-03-11 11:13:26 +01:00
let measurementUnit;
try {
measurementUnit = this._resolveMeasurementUnit('pressure', context.unit);
} catch (error) {
this.logger.warn(`Rejected pressure update: ${error.message}`);
return;
}
2025-10-07 18:10:45 +02:00
// Store in parent's measurement container
2026-03-11 11:13:26 +01:00
this.measurements.type("pressure").variant("measured").position(position).child(context.childId).value(value, context.timestamp, measurementUnit);
// Determine what kind of value to use as pressure (upstream , downstream or difference)
const pressure = this.getMeasuredPressure();
this.updatePosition();
2026-03-11 11:13:26 +01:00
this._updatePressureDriftStatus();
this._updatePredictionHealth();
this.logger.debug(`Using pressure: ${pressure} for calculations`);
}
2025-06-25 17:26:13 +02:00
2025-09-23 15:51:16 +02:00
// NEW: Flow handler
updateMeasuredFlow(value, position, context = {}) {
if (!this._isOperationalState()) {
this.logger.warn(`Machine not operational, skipping flow update from ${context.childName || 'unknown'}`);
return;
}
2025-06-25 17:26:13 +02:00
this.logger.debug(`Flow update: ${value} at ${position} from ${context.childName || 'child'}`);
2026-03-11 11:13:26 +01:00
let measurementUnit;
try {
measurementUnit = this._resolveMeasurementUnit('flow', context.unit);
} catch (error) {
this.logger.warn(`Rejected flow update: ${error.message}`);
return;
}
// Store in parent's measurement container
2026-03-11 11:13:26 +01:00
this.measurements.type("flow").variant("measured").position(position).child(context.childId).value(value, context.timestamp, measurementUnit);
// Update predicted flow if you have prediction capability
if (this.predictFlow) {
2026-03-11 11:13:26 +01:00
this.measurements.type("flow").variant("predicted").position("downstream").value(this.predictFlow.outputY || 0, Date.now(), this.unitPolicy.canonical.flow);
this.measurements.type("flow").variant("predicted").position("atEquipment").value(this.predictFlow.outputY || 0, Date.now(), this.unitPolicy.canonical.flow);
}
const measuredCanonical = this.measurements
.type("flow")
.variant("measured")
.position(position)
.getCurrentValue(this.unitPolicy.canonical.flow);
this._updateMetricDrift("flow", measuredCanonical, context);
this._updatePredictionHealth();
}
updateMeasuredPower(value, position, context = {}) {
if (!this._isOperationalState()) {
this.logger.warn(`Machine not operational, skipping power update from ${context.childName || 'unknown'}`);
return;
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
this.logger.debug(`Power update: ${value} at ${position} from ${context.childName || 'child'}`);
let measurementUnit;
try {
measurementUnit = this._resolveMeasurementUnit('power', context.unit);
} catch (error) {
this.logger.warn(`Rejected power update: ${error.message}`);
return;
}
this.measurements.type("power").variant("measured").position(position).child(context.childId).value(value, context.timestamp, measurementUnit);
if (this.predictPower) {
this.measurements.type("power").variant("predicted").position("atEquipment").value(this.predictPower.outputY || 0, Date.now(), this.unitPolicy.canonical.power);
}
const measuredCanonical = this.measurements
.type("power")
.variant("measured")
.position(position)
.getCurrentValue(this.unitPolicy.canonical.power);
this._updateMetricDrift("power", measuredCanonical, context);
this._updatePredictionHealth();
2025-06-25 17:26:13 +02:00
}
// Helper method for operational state check
_isOperationalState() {
const state = this.state.getCurrentState();
2026-02-19 17:36:44 +01:00
const activeStates = ["operational", "warmingup", "accelerating", "decelerating"];
this.logger.debug(`Checking operational state ${this.state.getCurrentState()} ? ${activeStates.includes(state)}`);
return activeStates.includes(state);
2025-06-25 17:26:13 +02:00
}
//what is the internal functions that need updating when something changes that has influence on this.
updatePosition() {
if (this._isOperationalState()) {
2025-06-25 17:26:13 +02:00
const currentPosition = this.state.getCurrentPosition();
// Update the predicted values based on the new position
const { cPower, cFlow } = this.calcFlowPower(currentPosition);
// Calc predicted efficiency
const efficiency = this.calcEfficiency(cPower, cFlow, "predicted");
//update the cog
const { cog, minEfficiency } = this.calcCog();
//update the distance from peak
this.calcDistanceBEP(efficiency,cog,minEfficiency);
}
2026-03-11 11:13:26 +01:00
this._updatePredictionHealth();
2025-06-25 17:26:13 +02:00
}
calcDistanceFromPeak(currentEfficiency,peakEfficiency){
return Math.abs(currentEfficiency - peakEfficiency);
}
calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){
let distance = 1;
if(currentEfficiency != null && maxEfficiency !== minEfficiency){
2025-06-25 17:26:13 +02:00
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
}
return distance;
}
showCoG() {
if (!this.hasCurve) {
return { error: 'No curve data available', cog: 0, NCog: 0, cogIndex: 0 };
}
const { cog, cogIndex, NCog, minEfficiency } = this.calcCog();
return {
cog,
cogIndex,
NCog,
NCogPercent: Math.round(NCog * 100 * 100) / 100,
minEfficiency,
currentEfficiencyCurve: this.currentEfficiencyCurve,
absDistFromPeak: this.absDistFromPeak,
relDistFromPeak: this.relDistFromPeak,
};
}
2025-07-24 13:15:33 +02:00
showWorkingCurves() {
if (!this.hasCurve) {
return { error: 'No curve data available' };
}
2025-07-24 13:15:33 +02:00
// Show the current curves for debugging
const { powerCurve, flowCurve } = this.getCurrentCurves();
return {
powerCurve: powerCurve,
flowCurve: flowCurve,
cog: this.cog,
cogIndex: this.cogIndex,
NCog: this.NCog,
minEfficiency: this.minEfficiency,
currentEfficiencyCurve: this.currentEfficiencyCurve,
absDistFromPeak: this.absDistFromPeak,
relDistFromPeak: this.relDistFromPeak
};
}
2025-06-25 17:26:13 +02:00
// Calculate the center of gravity for current pressure
calcCog() {
if (!this.hasCurve || !this.predictFlow || !this.predictPower) {
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
}
2025-06-25 17:26:13 +02:00
//fetch current curve data for power and flow
const { powerCurve, flowCurve } = this.getCurrentCurves();
const {efficiencyCurve, peak, peakIndex, minEfficiency } = this.calcEfficiencyCurve(powerCurve, flowCurve);
// Calculate the normalized center of gravity
2025-11-20 22:29:24 +01:00
const NCog = (flowCurve.y[peakIndex] - this.predictFlow.currentFxyYMin) / (this.predictFlow.currentFxyYMax - this.predictFlow.currentFxyYMin); //
2025-06-25 17:26:13 +02:00
//store in object for later retrieval
this.currentEfficiencyCurve = efficiencyCurve;
this.cog = peak;
this.cogIndex = peakIndex;
this.NCog = NCog;
this.minEfficiency = minEfficiency;
return { cog: peak, cogIndex: peakIndex, NCog: NCog, minEfficiency: minEfficiency };
}
calcEfficiencyCurve(powerCurve, flowCurve) {
const efficiencyCurve = [];
let peak = 0;
let peakIndex = 0;
let minEfficiency = Infinity;
2025-06-25 17:26:13 +02:00
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
}
2025-06-25 17:26:13 +02:00
// Specific flow ratio (Q/P): for variable-speed centrifugal pumps this is
// monotonically decreasing (P scales ~Q³ by affinity laws), so the peak is
// always at minimum flow and NCog = 0. The MGC BEP-Gravitation algorithm
// compensates via slope-based redistribution which IS sensitive to curve shape.
powerCurve.y.forEach((power, index) => {
2025-06-25 17:26:13 +02:00
const flow = flowCurve.y[index];
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
efficiencyCurve.push(eff);
2025-06-25 17:26:13 +02:00
if (eff > peak) {
peak = eff;
peakIndex = index;
}
if (eff < minEfficiency) {
minEfficiency = eff;
}
2025-06-25 17:26:13 +02:00
});
if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
2025-06-25 17:26:13 +02:00
return { efficiencyCurve, peak, peakIndex, minEfficiency };
}
//calc flow power based on pressure and current position
calcFlowPower(x) {
// Calculate flow and power
const cFlow = this.calcFlow(x);
const cPower = this.calcPower(x);
return { cPower, cFlow };
}
calcEfficiency(power,flow,variant) {
2026-02-23 13:17:18 +01:00
// Request a pressure differential explicitly in Pascal for hydraulic efficiency.
const pressureDiff = this.measurements
.type('pressure')
.variant('measured')
.difference({ unit: 'Pa' });
const g = gravity.getStandardGravity();
const temp = this.measurements.type('temperature').variant('measured').position('atEquipment').getCurrentValue('K');
const atmPressure = this.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa');
2026-02-12 10:48:44 +01:00
let rho = null;
try {
rho = coolprop.PropsSI('D', 'T', temp, 'P', atmPressure, 'WasteWater');
} catch (error) {
// coolprop can throw transient initialization errors; keep machine calculations running.
this.logger.warn(`CoolProp density lookup failed: ${error.message}. Using fallback density.`);
rho = 1000; // kg/m3 fallback for water-like fluids
}
this.logger.debug(`temp: ${temp} atmPressure : ${atmPressure} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
const flowM3s = this.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
const powerWatt = this.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
this.logger.debug(`Flow : ${flowM3s} power: ${powerWatt}`);
2025-06-25 17:26:13 +02:00
if (power > 0 && flow > 0) {
const specificFlow = flow / power;
const specificEnergyConsumption = power / flow;
this.measurements.type("efficiency").variant(variant).position('atEquipment').value(specificFlow);
this.measurements.type("specificEnergyConsumption").variant(variant).position('atEquipment').value(specificEnergyConsumption);
2026-03-11 11:13:26 +01:00
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerWatt) && powerWatt > 0) {
// Engineering references: P_h = Q * Δp = ρ g Q H, η_h = P_h / P_in
const pressureDiffPa = Number(pressureDiff.value);
const headMeters = (Number.isFinite(rho) && rho > 0) ? pressureDiffPa / (rho * g) : null;
const hydraulicPowerW = pressureDiffPa * flowM3s;
const nHydraulicEfficiency = hydraulicPowerW / powerWatt;
if (Number.isFinite(headMeters)) {
this.measurements.type("pumpHead").variant(variant).position('atEquipment').value(headMeters, Date.now(), 'm');
}
this.measurements.type("hydraulicPower").variant(variant).position('atEquipment').value(hydraulicPowerW, Date.now(), 'W');
this.measurements.type("nHydraulicEfficiency").variant(variant).position('atEquipment').value(nHydraulicEfficiency);
}
}
2025-06-25 17:26:13 +02:00
//change this to nhydrefficiency ?
return this.measurements.type("efficiency").variant(variant).position('atEquipment').getCurrentValue();
2025-06-25 17:26:13 +02:00
}
updateCurve(newCurve) {
this.logger.info(`Updating machine curve`);
2026-03-11 11:13:26 +01:00
const normalizedCurve = this._normalizeMachineCurve(newCurve);
const newConfig = {
asset: {
machineCurve: normalizedCurve,
curveUnits: this.unitPolicy.curve,
},
};
2025-06-25 17:26:13 +02:00
//validate input of new curve fed to the machine
this.config = this.configUtils.updateConfig(this.config, newConfig);
//After we passed validation load the curves into their predictors
if (!this.predictFlow || !this.predictPower || !this.predictCtrl) {
this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq });
this.predictPower = new predict({ curve: this.config.asset.machineCurve.np });
this.predictCtrl = new predict({ curve: this.reverseCurve(this.config.asset.machineCurve.nq) });
this.hasCurve = true;
} else {
this.predictFlow.updateCurve(this.config.asset.machineCurve.nq);
this.predictPower.updateCurve(this.config.asset.machineCurve.np);
this.predictCtrl.updateCurve(this.reverseCurve(this.config.asset.machineCurve.nq));
}
2025-06-25 17:26:13 +02:00
}
getCompleteCurve() {
if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
return { powerCurve: null, flowCurve: null };
}
2025-06-25 17:26:13 +02:00
const powerCurve = this.predictPower.inputCurveData;
const flowCurve = this.predictFlow.inputCurveData;
return { powerCurve, flowCurve };
}
getCurrentCurves() {
if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
}
2025-06-25 17:26:13 +02:00
const powerCurve = this.predictPower.currentFxyCurve[this.predictPower.currentF];
const flowCurve = this.predictFlow.currentFxyCurve[this.predictFlow.currentF];
return { powerCurve, flowCurve };
}
calcDistanceBEP(efficiency,maxEfficiency,minEfficiency) {
const absDistFromPeak = this.calcDistanceFromPeak(efficiency,maxEfficiency);
const relDistFromPeak = this.calcRelativeDistanceFromPeak(efficiency,maxEfficiency,minEfficiency);
//store internally
this.absDistFromPeak = absDistFromPeak ;
this.relDistFromPeak = relDistFromPeak;
return { absDistFromPeak: absDistFromPeak, relDistFromPeak: relDistFromPeak };
}
getOutput() {
// Improved output object generation
2026-03-11 11:13:26 +01:00
const output = this.measurements.getFlattenedOutput({
requestedUnits: this.unitPolicy.output,
});
2025-06-25 17:26:13 +02:00
//fill in the rest of the output object
output["state"] = this.state.getCurrentState();
output["runtime"] = this.state.getRunTimeHours();
output["ctrl"] = this.state.getCurrentPosition();
output["moveTimeleft"] = this.state.getMoveTimeLeft();
output["mode"] = this.currentMode;
output["cog"] = this.cog; // flow / power efficiency
output["NCog"] = this.NCog; // normalized cog
output["NCogPercent"] = Math.round(this.NCog * 100 * 100) / 100 ;
output["maintenanceTime"] = this.state.getMaintenanceTimeHours();
2025-06-25 17:26:13 +02:00
if(this.flowDrift != null){
const flowDrift = this.flowDrift;
output["flowNrmse"] = flowDrift.nrmse;
output["flowLongterNRMSD"] = flowDrift.longTermNRMSD;
2026-03-11 11:13:26 +01:00
output["flowLongTermNRMSD"] = flowDrift.longTermNRMSD;
2025-06-25 17:26:13 +02:00
output["flowImmediateLevel"] = flowDrift.immediateLevel;
output["flowLongTermLevel"] = flowDrift.longTermLevel;
2026-03-11 11:13:26 +01:00
output["flowDriftValid"] = flowDrift.valid;
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
if(this.powerDrift != null){
const powerDrift = this.powerDrift;
output["powerNrmse"] = powerDrift.nrmse;
output["powerLongTermNRMSD"] = powerDrift.longTermNRMSD;
output["powerImmediateLevel"] = powerDrift.immediateLevel;
output["powerLongTermLevel"] = powerDrift.longTermLevel;
output["powerDriftValid"] = powerDrift.valid;
}
output["pressureDriftLevel"] = this.pressureDrift.level;
output["pressureDriftSource"] = this.pressureDrift.source;
output["pressureDriftFlags"] = this.pressureDrift.flags;
output["predictionQuality"] = this.predictionHealth.quality;
output["predictionConfidence"] = Math.round(this.predictionHealth.confidence * 1000) / 1000;
output["predictionPressureSource"] = this.predictionHealth.pressureSource;
output["predictionFlags"] = this.predictionHealth.flags;
2025-06-25 17:26:13 +02:00
//should this all go in the container of measurements?
output["effDistFromPeak"] = this.absDistFromPeak;
output["effRelDistFromPeak"] = this.relDistFromPeak;
//this.logger.debug(`Output: ${JSON.stringify(output)}`);
return output;
}
} // end of class
module.exports = Machine;