2026-05-10 22:00:34 +02:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
// rotatingMachine — S88 Equipment Module domain orchestrator.
|
|
|
|
|
//
|
|
|
|
|
// All heavy lifting lives in concern modules under src/{curves,prediction,
|
|
|
|
|
// drift,pressure,state,measurement,flow,display,io,commands}. This file
|
|
|
|
|
// stitches them together and preserves the public API the existing test
|
|
|
|
|
// suite + sibling nodes (MGC, pumpingStation) depend on.
|
|
|
|
|
|
|
|
|
|
const { BaseDomain, UnitPolicy, state, nrmse, interpolation, convert } = require('generalFunctions');
|
|
|
|
|
|
|
|
|
|
const { loadModelCurve } = require('./curves/curveLoader');
|
|
|
|
|
const { normalizeMachineCurve } = require('./curves/curveNormalizer');
|
|
|
|
|
const { reverseCurve } = require('./curves/reverseCurve');
|
|
|
|
|
const { buildPredictors } = require('./prediction/predictors');
|
|
|
|
|
const { buildGroupPredictors } = require('./prediction/groupPredictors');
|
|
|
|
|
const pmath = require('./prediction/predictionMath');
|
|
|
|
|
const eff = require('./prediction/efficiencyMath');
|
|
|
|
|
const DriftAssessor = require('./drift/driftAssessor');
|
|
|
|
|
const healthRefresh = require('./drift/healthRefresh');
|
|
|
|
|
const VirtualPressureChildren = require('./pressure/virtualChildren');
|
|
|
|
|
const PressureInitialization = require('./pressure/pressureInitialization');
|
|
|
|
|
const PressureRouter = require('./pressure/pressureRouter');
|
|
|
|
|
const { getMeasuredPressure } = require('./pressure/pressureSelector');
|
|
|
|
|
const { bindStateEvents, isOperationalState } = require('./state/stateBindings');
|
|
|
|
|
const sequence = require('./state/sequenceController');
|
|
|
|
|
const MeasurementHandlers = require('./measurement/measurementHandlers');
|
|
|
|
|
const { registerMeasurementChild, detachAllListeners } = require('./measurement/childRegistrar');
|
|
|
|
|
const FlowController = require('./flow/flowController');
|
|
|
|
|
const display = require('./display/workingCurves');
|
|
|
|
|
const io = require('./io/output');
|
|
|
|
|
|
|
|
|
|
class Machine extends BaseDomain {
|
|
|
|
|
static name = 'rotatingMachine';
|
|
|
|
|
|
|
|
|
|
static unitPolicy = UnitPolicy.declare({
|
|
|
|
|
canonical: { pressure: 'Pa', atmPressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
|
|
|
|
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C', atmPressure: 'Pa' },
|
|
|
|
|
curve: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
|
|
|
|
|
requireUnitForTypes: ['pressure', 'flow', 'power', 'temperature', 'atmPressure'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ES6 forbids `this` before super(). Single-threaded JS means stashing
|
|
|
|
|
// on the class itself between the caller's args and super() is race-free;
|
|
|
|
|
// configure() picks the extras up immediately after.
|
2025-06-25 17:26:13 +02:00
|
|
|
constructor(machineConfig = {}, stateConfig = {}, errorMetricsConfig = {}) {
|
2026-05-10 22:00:34 +02:00
|
|
|
Machine._pendingExtras = { stateConfig, errorMetricsConfig };
|
|
|
|
|
super(machineConfig);
|
|
|
|
|
}
|
2025-06-25 17:26:13 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
configure() {
|
|
|
|
|
const extras = Machine._pendingExtras || {};
|
|
|
|
|
Machine._pendingExtras = null;
|
2025-10-31 18:35:40 +01:00
|
|
|
|
2025-06-25 17:26:13 +02:00
|
|
|
this.interpolation = new interpolation();
|
2026-05-10 22:00:34 +02:00
|
|
|
this.config = this.configUtils.updateConfig(this.config, {
|
|
|
|
|
general: { name: `${this.config.functionality?.softwareType}_${this.config.general.id}` },
|
2025-06-25 17:26:13 +02:00
|
|
|
});
|
2026-05-10 22:00:34 +02:00
|
|
|
|
|
|
|
|
this._setupCurves();
|
|
|
|
|
this.groupPredictFlow = null; this.groupPredictPower = null; this.groupPredictCtrl = null; this.groupNCog = 0;
|
|
|
|
|
this._setupState(extras);
|
|
|
|
|
this._setupDrift();
|
|
|
|
|
this._setupPressure();
|
|
|
|
|
this._setupChildren();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_setupCurves() {
|
|
|
|
|
this.model = this.config.asset?.model;
|
|
|
|
|
const { rawCurve, error } = loadModelCurve(this.model);
|
|
|
|
|
this.rawCurve = rawCurve;
|
|
|
|
|
if (error) { this.logger.error(`${error} in machineConfig. Cannot make predictions.`); this._installNullPredictors(); return; }
|
|
|
|
|
try {
|
2026-05-11 17:13:20 +02:00
|
|
|
this.curve = normalizeMachineCurve(rawCurve, this.unitPolicy, this.logger);
|
2026-05-10 22:00:34 +02:00
|
|
|
this.config = this.configUtils.updateConfig(this.config, { asset: { ...this.config.asset, machineCurve: this.curve } });
|
|
|
|
|
const built = buildPredictors(this.config.asset.machineCurve);
|
|
|
|
|
this.predictors = built;
|
|
|
|
|
this.predictFlow = built.predictFlow; this.predictPower = built.predictPower; this.predictCtrl = built.predictCtrl;
|
|
|
|
|
this.hasCurve = true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.logger.error(`Curve normalization failed for model '${this.model}': ${e.message}`);
|
|
|
|
|
this._installNullPredictors();
|
2025-08-07 13:52:06 +02:00
|
|
|
}
|
2025-09-04 17:07:29 +02:00
|
|
|
}
|
2025-08-08 14:29:15 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
_installNullPredictors() {
|
|
|
|
|
this.predictFlow = null; this.predictPower = null; this.predictCtrl = null;
|
|
|
|
|
this.predictors = { predictFlow: null, predictPower: null, predictCtrl: null };
|
|
|
|
|
this.hasCurve = false;
|
2025-08-08 14:29:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
_setupState(extras) {
|
|
|
|
|
this.state = new state(extras.stateConfig || {}, this.logger);
|
|
|
|
|
this.errorMetrics = new nrmse(extras.errorMetricsConfig || {}, this.logger);
|
|
|
|
|
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;
|
|
|
|
|
this._stateUnbind = bindStateEvents({
|
|
|
|
|
state: this.state,
|
|
|
|
|
onPositionChange: () => this.updatePosition(),
|
|
|
|
|
onStateChange: () => this._updateState(),
|
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
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
_setupDrift() {
|
|
|
|
|
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 },
|
2026-03-11 11:13:26 +01:00
|
|
|
};
|
2026-05-10 22:00:34 +02:00
|
|
|
this.errorMetrics.registerMetric('flow', this.driftProfiles.flow);
|
|
|
|
|
this.errorMetrics.registerMetric('power', this.driftProfiles.power);
|
|
|
|
|
this.flowDrift = null; this.powerDrift = null;
|
|
|
|
|
this.pressureDrift = { level: 0, flags: ['nominal'], source: null };
|
|
|
|
|
this.predictionHealth = { quality: 'invalid', confidence: 0, pressureSource: null, flags: ['not_initialized'] };
|
|
|
|
|
this.driftAssessor = new DriftAssessor({
|
|
|
|
|
errorMetrics: this.errorMetrics,
|
|
|
|
|
measurements: this.measurements,
|
|
|
|
|
driftProfiles: this.driftProfiles,
|
|
|
|
|
logger: this.logger,
|
|
|
|
|
resolveProcessRange: (m, p, q) => this._resolveProcessRangeForMetric(m, p, q),
|
|
|
|
|
measurementPositionForMetric: (m) => this._measurementPositionForMetric(m),
|
|
|
|
|
});
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
_setupPressure() {
|
|
|
|
|
this.virtualPressureChildIds = { upstream: 'dashboard-sim-upstream', downstream: 'dashboard-sim-downstream' };
|
|
|
|
|
this.realPressureChildIds = { upstream: new Set(), downstream: new Set() };
|
|
|
|
|
this.virtualPressureChildren = new VirtualPressureChildren({
|
2026-05-11 17:13:20 +02:00
|
|
|
logger: this.logger, unitPolicy: this.unitPolicy, parentRef: this,
|
2026-05-10 22:00:34 +02:00
|
|
|
ids: this.virtualPressureChildIds,
|
|
|
|
|
}).build();
|
|
|
|
|
this.pressureInit = new PressureInitialization({
|
|
|
|
|
measurements: this.measurements,
|
|
|
|
|
virtualPressureChildIds: this.virtualPressureChildIds,
|
|
|
|
|
realPressureChildIds: this.realPressureChildIds,
|
|
|
|
|
logger: this.logger,
|
|
|
|
|
});
|
|
|
|
|
this.pressureRouter = new PressureRouter({
|
|
|
|
|
measurements: this.measurements,
|
|
|
|
|
virtualPressureChildIds: this.virtualPressureChildIds,
|
|
|
|
|
resolveMeasurementUnit: (t, u) => this._resolveMeasurementUnit(t, u),
|
|
|
|
|
updatePosition: () => this.updatePosition(),
|
|
|
|
|
refreshDrift: () => this._updatePressureDriftStatus(),
|
|
|
|
|
refreshHealth: () => this._updatePredictionHealth(),
|
|
|
|
|
getPressure: () => this.getMeasuredPressure(),
|
|
|
|
|
logger: this.logger,
|
|
|
|
|
});
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
_setupChildren() {
|
|
|
|
|
this.child = this.child || {};
|
|
|
|
|
this.childMeasurementListeners = new Map();
|
|
|
|
|
this.measurementHandlers = new MeasurementHandlers({ host: this, logger: this.logger });
|
|
|
|
|
this.flowController = new FlowController({ host: this, logger: this.logger });
|
|
|
|
|
this.registerChild = (child, softwareType) => registerMeasurementChild(this, child, softwareType);
|
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
this._init();
|
|
|
|
|
this.registerChild(this.virtualPressureChildren.upstream, 'measurement');
|
|
|
|
|
this.registerChild(this.virtualPressureChildren.downstream, 'measurement');
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
_init() {
|
2026-05-11 17:13:20 +02:00
|
|
|
const tu = this.unitPolicy.output.temperature;
|
2026-05-10 22:00:34 +02:00
|
|
|
this.measurements.type('temperature').variant('measured').position('atEquipment').value(15, Date.now(), tu);
|
|
|
|
|
this.measurements.type('atmPressure').variant('measured').position('atEquipment').value(101325, Date.now(), 'Pa');
|
2026-05-11 17:13:20 +02:00
|
|
|
const fu = this.unitPolicy.canonical.flow;
|
2026-05-10 22:00:34 +02:00
|
|
|
const fmin = this.predictFlow ? this.predictFlow.currentFxyYMin : 0;
|
|
|
|
|
const fmax = this.predictFlow ? this.predictFlow.currentFxyYMax : 0;
|
|
|
|
|
this.measurements.type('flow').variant('predicted').position('max').value(fmax, Date.now(), fu);
|
|
|
|
|
this.measurements.type('flow').variant('predicted').position('min').value(fmin, Date.now(), fu);
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
_callMeasurementHandler(measurementType, value, position, context = {}) {
|
|
|
|
|
return this.measurementHandlers.dispatch(measurementType, value, position, context);
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
// ── unit helpers ────────────────────────────────────────────────────
|
|
|
|
|
isUnitValidForType(type, unit) { return this.measurements?.isUnitCompatible?.(type, unit) === true; }
|
2026-03-11 11:13:26 +01:00
|
|
|
_resolveMeasurementUnit(type, providedUnit) {
|
2026-05-10 22:00:34 +02:00
|
|
|
const u = typeof providedUnit === 'string' ? providedUnit.trim() : '';
|
|
|
|
|
if (!u) throw new Error(`Missing unit for ${type} measurement.`);
|
|
|
|
|
if (!this.isUnitValidForType(type, u)) throw new Error(`Unsupported unit '${u}' for ${type} measurement.`);
|
|
|
|
|
return u;
|
|
|
|
|
}
|
|
|
|
|
_convertUnitValue(value, from, to, ctx = 'unit conversion') {
|
|
|
|
|
const n = Number(value);
|
|
|
|
|
if (!Number.isFinite(n)) throw new Error(`${ctx}: value '${value}' is not finite`);
|
|
|
|
|
if (!from || !to || from === to) return n;
|
|
|
|
|
return convert(n).from(from).to(to);
|
|
|
|
|
}
|
|
|
|
|
_measurementPositionForMetric(metricId) { return metricId === 'power' ? 'atEquipment' : 'downstream'; }
|
|
|
|
|
_resolveProcessRangeForMetric(metricId, predicted, measured) {
|
|
|
|
|
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); }
|
2026-03-11 11:13:26 +01:00
|
|
|
if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) {
|
2026-05-10 22:00:34 +02:00
|
|
|
const p = Number(predicted); const m = Number(measured);
|
|
|
|
|
const lo = Math.min(p, m); const hi = Math.max(p, m);
|
|
|
|
|
processMin = Number.isFinite(lo) ? lo : 0;
|
|
|
|
|
processMax = Number.isFinite(hi) && hi > processMin ? hi : processMin + 1;
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
|
|
|
|
return { processMin, processMax };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_updateMetricDrift(metricId, measuredValue, context = {}) {
|
2026-05-10 22:00:34 +02:00
|
|
|
const drift = this.driftAssessor.updateMetricDrift(metricId, measuredValue, context);
|
|
|
|
|
if (drift && drift.valid) {
|
|
|
|
|
if (metricId === 'flow') this.flowDrift = drift;
|
|
|
|
|
if (metricId === 'power') this.powerDrift = drift;
|
|
|
|
|
}
|
|
|
|
|
return drift;
|
|
|
|
|
}
|
|
|
|
|
assessDrift(measurement, processMin, processMax) { return this.driftAssessor.assessDrift(measurement, processMin, processMax); }
|
|
|
|
|
_applyDriftPenalty(drift, confidence, flags, prefix) { return this.driftAssessor.applyDriftPenalty(drift, confidence, flags, prefix); }
|
|
|
|
|
_isOperationalState() { return isOperationalState(this.state); }
|
|
|
|
|
|
|
|
|
|
// ── pressure ───────────────────────────────────────────────────────
|
|
|
|
|
_getPreferredPressureValue(position) { return this.pressureInit.getPreferredValue(position); }
|
|
|
|
|
getPressureInitializationStatus() { return this.pressureInit.getStatus(); }
|
|
|
|
|
getMeasuredPressure() { return getMeasuredPressure(this); }
|
|
|
|
|
_updatePressureDriftStatus() { return healthRefresh.updatePressureDriftStatus(this); }
|
|
|
|
|
_updatePredictionHealth() { return healthRefresh.updatePredictionHealth(this); }
|
|
|
|
|
|
|
|
|
|
// ── measurement updaters (delegate to handlers) ────────────────────
|
|
|
|
|
updateMeasuredPressure(value, position, context = {}) { this.pressureRouter.route(position, value, context); }
|
|
|
|
|
updateMeasuredFlow(value, position, context = {}) { return this.measurementHandlers.updateMeasuredFlow(value, position, context); }
|
|
|
|
|
updateMeasuredPower(value, position, context = {}) { return this.measurementHandlers.updateMeasuredPower(value, position, context); }
|
|
|
|
|
updateMeasuredTemperature(value, position, context = {}) { return this.measurementHandlers.updateMeasuredTemperature(value, position, context); }
|
|
|
|
|
updateSimulatedMeasurement(type, position, value, context = {}) {
|
|
|
|
|
return this.measurementHandlers.updateSimulatedMeasurement(type, position, value, context);
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
2026-05-10 22:00:34 +02:00
|
|
|
handleMeasuredFlow() { return this.measurementHandlers.handleMeasuredFlow(); }
|
|
|
|
|
handleMeasuredPower() { return this.measurementHandlers.handleMeasuredPower(); }
|
2026-03-11 11:13:26 +01:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
// ── state-machine driven recompute ─────────────────────────────────
|
|
|
|
|
_updateState() {
|
2026-03-11 11:13:26 +01:00
|
|
|
if (!this._isOperationalState()) {
|
2026-05-11 17:13:20 +02:00
|
|
|
const fu = this.unitPolicy.canonical.flow;
|
|
|
|
|
const pu = this.unitPolicy.canonical.power;
|
2026-05-10 22:00:34 +02:00
|
|
|
this.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), fu);
|
|
|
|
|
this.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), fu);
|
|
|
|
|
this.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), pu);
|
2025-06-25 17:26:13 +02:00
|
|
|
}
|
2026-05-10 22:00:34 +02:00
|
|
|
this._updatePredictionHealth();
|
2026-03-11 11:13:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
updatePosition() {
|
|
|
|
|
if (this._isOperationalState()) {
|
|
|
|
|
const x = this.state.getCurrentPosition();
|
|
|
|
|
const { cPower, cFlow } = this.calcFlowPower(x);
|
|
|
|
|
const efficiency = this.calcEfficiency(cPower, cFlow, 'predicted');
|
|
|
|
|
const { cog, minEfficiency } = this.calcCog();
|
|
|
|
|
this.calcDistanceBEP(efficiency, cog, minEfficiency);
|
2025-06-25 17:26:13 +02:00
|
|
|
}
|
2026-05-10 22:00:34 +02:00
|
|
|
this._updatePredictionHealth();
|
2025-06-25 17:26:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
// ── mode + input dispatch ──────────────────────────────────────────
|
2025-06-25 17:26:13 +02:00
|
|
|
isValidSourceForMode(source, mode) {
|
2026-05-10 22:00:34 +02:00
|
|
|
const ok = (this.config.mode.allowedSources[mode] || []).has(source);
|
|
|
|
|
if (ok) this.logger.debug(`source is allowed proceeding with ${source} for mode ${mode}`);
|
|
|
|
|
else this.logger.warn(`${source} is not allowed in mode ${mode}`);
|
|
|
|
|
return ok;
|
2025-06-25 17:26:13 +02:00
|
|
|
}
|
|
|
|
|
isValidActionForMode(action, mode) {
|
2026-05-10 22:00:34 +02:00
|
|
|
const ok = (this.config.mode.allowedActions[mode] || []).has(action);
|
|
|
|
|
if (ok) this.logger.debug(`Action is allowed proceeding with ${action} for mode ${mode}`);
|
|
|
|
|
else this.logger.warn(`${action} is not allowed in mode ${mode}`);
|
|
|
|
|
return ok;
|
2025-10-02 17:09:24 +02:00
|
|
|
}
|
2026-05-10 22:00:34 +02:00
|
|
|
handleInput(source, action, parameter) { return this.flowController.handle(source, action, parameter); }
|
|
|
|
|
abortMovement(reason = 'group override') { if (this.state?.abortCurrentMovement) this.state.abortCurrentMovement(reason); }
|
2025-06-25 17:26:13 +02:00
|
|
|
setMode(newMode) {
|
2026-05-10 22:00:34 +02:00
|
|
|
const allowed = this.defaultConfig.mode.current.rules.values.map((v) => v.value);
|
|
|
|
|
if (!allowed.includes(newMode)) { this.logger.warn(`Invalid mode '${newMode}'. Allowed modes are: ${allowed.join(', ')}`); return; }
|
2025-06-25 17:26:13 +02:00
|
|
|
this.currentMode = newMode;
|
|
|
|
|
this.logger.info(`Mode successfully changed to '${newMode}'.`);
|
|
|
|
|
}
|
2026-05-10 22:00:34 +02:00
|
|
|
updateConfig(newConfig) { this.config = this.configUtils.updateConfig(this.config, newConfig); }
|
|
|
|
|
_waitForOperational(t) { return sequence.waitForOperational(this, t); }
|
|
|
|
|
executeSequence(name) { return sequence.executeSequence(this, name); }
|
|
|
|
|
setpoint(target) { return sequence.setpoint(this, target); }
|
|
|
|
|
_resolveSetpointBounds() { return sequence.resolveSetpointBounds(this); }
|
2025-06-25 17:26:13 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
// ── curve-driven prediction (delegates) ────────────────────────────
|
|
|
|
|
calcFlow(x) { return pmath.calcFlow(this, x); }
|
|
|
|
|
calcPower(x) { return pmath.calcPower(this, x); }
|
|
|
|
|
calcCtrl(x) { return pmath.calcCtrl(this, x); }
|
|
|
|
|
inputFlowCalcPower(f) { return pmath.inputFlowCalcPower(this, f); }
|
|
|
|
|
calcFlowPower(x) { return { cFlow: this.calcFlow(x), cPower: this.calcPower(x) }; }
|
2025-07-01 15:25:07 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
// ── group-scope operating point (MGC) ──────────────────────────────
|
2026-05-08 11:20:17 +02:00
|
|
|
_ensureGroupPredicts() {
|
|
|
|
|
if (!this.hasCurve || !this.predictFlow || !this.predictPower || !this.predictCtrl) return;
|
|
|
|
|
if (this.groupPredictFlow && this.groupPredictPower && this.groupPredictCtrl) return;
|
2026-05-10 22:00:34 +02:00
|
|
|
const built = buildGroupPredictors(this.predictors);
|
|
|
|
|
if (!built) return;
|
|
|
|
|
this.groupPredictFlow = built.groupPredictFlow;
|
|
|
|
|
this.groupPredictPower = built.groupPredictPower;
|
|
|
|
|
this.groupPredictCtrl = built.groupPredictCtrl;
|
2026-05-08 11:20:17 +02:00
|
|
|
}
|
|
|
|
|
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;
|
2026-05-10 22:00:34 +02:00
|
|
|
this.groupPredictFlow.fDimension = diff;
|
2026-05-08 11:20:17 +02:00
|
|
|
this.groupPredictPower.fDimension = diff;
|
|
|
|
|
if (this.groupPredictCtrl) this.groupPredictCtrl.fDimension = diff;
|
|
|
|
|
this.groupNCog = this._calcGroupCog();
|
|
|
|
|
}
|
|
|
|
|
groupCalcPower(flow) {
|
2026-05-10 22:00:34 +02:00
|
|
|
if (!this.groupPredictFlow || !this.groupPredictPower || !this.groupPredictCtrl) return this.inputFlowCalcPower(flow);
|
2026-05-08 11:20:17 +02:00
|
|
|
this.groupPredictCtrl.currentX = flow;
|
|
|
|
|
const cCtrl = this.groupPredictCtrl.y(flow);
|
|
|
|
|
this.groupPredictPower.currentX = cCtrl;
|
|
|
|
|
return this.groupPredictPower.y(cCtrl);
|
|
|
|
|
}
|
|
|
|
|
_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);
|
|
|
|
|
}
|
2026-05-10 22:00:34 +02:00
|
|
|
reverseCurve(c) { return reverseCurve(c); }
|
2026-05-08 11:20:17 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
// ── efficiency math (delegates) ────────────────────────────────────
|
|
|
|
|
calcCog() { return eff.calcCog(this); }
|
|
|
|
|
calcEfficiencyCurve(p, f) { return eff.calcEfficiencyCurve(p, f); }
|
|
|
|
|
calcEfficiency(power, flow, variant) { return eff.calcEfficiency(this, power, flow, variant); }
|
|
|
|
|
calcDistanceBEP(e, max, min) { return eff.calcDistanceBEP(this, e, max, min); }
|
|
|
|
|
calcDistanceFromPeak(e, peak) { return eff.calcDistanceFromPeak(e, peak); }
|
|
|
|
|
calcRelativeDistanceFromPeak(e, max, min) { return eff.calcRelativeDistanceFromPeak(this, e, max, min); }
|
|
|
|
|
getCurrentCurves() { return eff.getCurrentCurves(this); }
|
|
|
|
|
getCompleteCurve() { return eff.getCompleteCurve(this); }
|
2025-06-25 17:26:13 +02:00
|
|
|
|
|
|
|
|
updateCurve(newCurve) {
|
2026-05-10 22:00:34 +02:00
|
|
|
this.logger.info('Updating machine curve');
|
2026-05-11 17:13:20 +02:00
|
|
|
const normalized = normalizeMachineCurve(newCurve, this.unitPolicy, this.logger);
|
2026-05-10 22:00:34 +02:00
|
|
|
this.config = this.configUtils.updateConfig(this.config, {
|
2026-05-11 17:13:20 +02:00
|
|
|
asset: { machineCurve: normalized, curveUnits: this.unitPolicy.curve },
|
2026-05-10 22:00:34 +02:00
|
|
|
});
|
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
|
|
|
if (!this.predictFlow || !this.predictPower || !this.predictCtrl) {
|
2026-05-10 22:00:34 +02:00
|
|
|
const built = buildPredictors(this.config.asset.machineCurve);
|
|
|
|
|
this.predictors = built;
|
|
|
|
|
this.predictFlow = built.predictFlow; this.predictPower = built.predictPower; this.predictCtrl = built.predictCtrl;
|
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
|
|
|
this.hasCurve = true;
|
|
|
|
|
} else {
|
|
|
|
|
this.predictFlow.updateCurve(this.config.asset.machineCurve.nq);
|
|
|
|
|
this.predictPower.updateCurve(this.config.asset.machineCurve.np);
|
2026-05-10 22:00:34 +02:00
|
|
|
this.predictCtrl.updateCurve(reverseCurve(this.config.asset.machineCurve.nq));
|
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
|
|
|
}
|
2025-06-25 17:26:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
showCoG() { return display.showCoG(this); }
|
|
|
|
|
showWorkingCurves() { return display.showWorkingCurves(this); }
|
2025-06-25 17:26:13 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
// ── output + status ─────────────────────────────────────────────────
|
|
|
|
|
getOutput() { return io.buildOutput(this); }
|
|
|
|
|
getStatusBadge() { return io.buildStatusBadge(this); }
|
2025-06-25 17:26:13 +02:00
|
|
|
|
2026-05-10 22:00:34 +02:00
|
|
|
close() {
|
|
|
|
|
this._stateUnbind?.();
|
|
|
|
|
detachAllListeners(this);
|
|
|
|
|
if (this.state?.emitter) this.state.emitter.removeAllListeners();
|
|
|
|
|
super.close?.();
|
2025-06-25 17:26:13 +02:00
|
|
|
}
|
2026-05-10 22:00:34 +02:00
|
|
|
}
|
2025-06-25 17:26:13 +02:00
|
|
|
|
|
|
|
|
module.exports = Machine;
|