'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, assetResolver } = 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. // // Two call sites exist: // - nodeClass.buildDomainConfig() pre-sets Machine._pendingExtras and // then BaseNodeAdapter calls `new Machine(this.config)` (single-arg). // - Tests / direct callers pass (machineConfig, stateConfig, errMetrics) // explicitly. // With default-param `stateConfig={}`, the single-arg path was silently // clobbering the pre-set extras with an empty object, so the state machine // booted with schema defaults (warmingup=5s, speed=1%/s, mode=dynspeed) // regardless of what the editor saved. Only overwrite when an explicit // value is provided. constructor(machineConfig = {}, stateConfig, errorMetricsConfig) { if (stateConfig !== undefined || errorMetricsConfig !== undefined) { Machine._pendingExtras = { stateConfig: stateConfig ?? {}, errorMetricsConfig: errorMetricsConfig ?? {}, }; } super(machineConfig); } configure() { const extras = Machine._pendingExtras || {}; Machine._pendingExtras = null; this.interpolation = new interpolation(); this.config = this.configUtils.updateConfig(this.config, { general: { name: `${this.config.functionality?.softwareType}_${this.config.general.id}` }, }); 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; // Resolve derived metadata (supplier / type / allowed units) from the asset // registry. Source of truth lives in generalFunctions/datasets/assetData/. // If the registry has no entry for this model, assetMetadata is null and // we'll error out with a clear message below. this.assetMetadata = this.model ? assetResolver.resolveAssetMetadata('rotatingmachine', this.model) : null; if (!this.model) { this.logger.error(`rotatingMachine: asset.model is required. Open the node, pick a model from the asset menu, and save.`); this._installNullPredictors(); return; } if (!this.assetMetadata) { this.logger.error(`rotatingMachine: model '${this.model}' not found in asset registry (datasets/assetData/rotatingmachine.json). Cannot derive supplier/type/units.`); this._installNullPredictors(); return; } // Validate the chosen deployment unit. Hard check: it must be a recognised // flow unit (convert() can describe it). Soft check: warn if it isn't in // the registry's allowed-set for this model — the list is the editor's // recommended dropdown, not an exhaustive whitelist. const chosenUnit = this.config.asset?.unit; if (!chosenUnit) { this.logger.error(`rotatingMachine: asset.unit is required for model '${this.model}'. Re-save the node from the editor.`); this._installNullPredictors(); return; } try { const desc = convert().describe(chosenUnit); if (desc.measure !== 'volumeFlowRate') { this.logger.error(`rotatingMachine: asset.unit '${chosenUnit}' is not a flow unit (got measure '${desc.measure}').`); this._installNullPredictors(); return; } } catch (_) { this.logger.error(`rotatingMachine: asset.unit '${chosenUnit}' is not a recognised unit.`); this._installNullPredictors(); return; } const allowedUnits = this.assetMetadata.units || []; if (allowedUnits.length > 0 && !allowedUnits.includes(chosenUnit)) { this.logger.warn( `rotatingMachine: asset.unit '${chosenUnit}' is not in the registry's recommended list ` + `for model '${this.model}' (allowed: [${allowedUnits.join(', ')}]). Continuing — the unit is a valid flow unit.`, ); } const { rawCurve, error } = loadModelCurve(this.model); this.rawCurve = rawCurve; if (error) { this.logger.error(`${error} in machineConfig. Cannot make predictions.`); this._installNullPredictors(); return; } try { this.curve = normalizeMachineCurve(rawCurve, this.unitPolicy, this.logger); 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(); } } _installNullPredictors() { this.predictFlow = null; this.predictPower = null; this.predictCtrl = null; this.predictors = { predictFlow: null, predictPower: null, predictCtrl: null }; this.hasCurve = false; } _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(), }); } _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 }, }; 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), }); } _setupPressure() { this.virtualPressureChildIds = { upstream: 'dashboard-sim-upstream', downstream: 'dashboard-sim-downstream' }; this.realPressureChildIds = { upstream: new Set(), downstream: new Set() }; this.virtualPressureChildren = new VirtualPressureChildren({ logger: this.logger, unitPolicy: this.unitPolicy, parentRef: this, 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, }); } _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); this._init(); this.registerChild(this.virtualPressureChildren.upstream, 'measurement'); this.registerChild(this.virtualPressureChildren.downstream, 'measurement'); } _init() { const tu = this.unitPolicy.output.temperature; 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'); const fu = this.unitPolicy.canonical.flow; 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); } _callMeasurementHandler(measurementType, value, position, context = {}) { return this.measurementHandlers.dispatch(measurementType, value, position, context); } // ── unit helpers ──────────────────────────────────────────────────── isUnitValidForType(type, unit) { return this.measurements?.isUnitCompatible?.(type, unit) === true; } _resolveMeasurementUnit(type, providedUnit) { 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); } if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) { 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; } return { processMin, processMax }; } _updateMetricDrift(metricId, measuredValue, context = {}) { 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); } handleMeasuredFlow() { return this.measurementHandlers.handleMeasuredFlow(); } handleMeasuredPower() { return this.measurementHandlers.handleMeasuredPower(); } // ── state-machine driven recompute ───────────────────────────────── _updateState() { if (!this._isOperationalState()) { const fu = this.unitPolicy.canonical.flow; const pu = this.unitPolicy.canonical.power; 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); } this._updatePredictionHealth(); // Push port 0 deltas so downstream dashboards / probes see state + // predicted-flow updates as they happen. BaseNodeAdapter listens for // 'output-changed' on this.emitter to fire _emitOutputs(). this.notifyOutputChanged(); } 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); } this._updatePredictionHealth(); this.notifyOutputChanged(); } // ── mode + input dispatch ────────────────────────────────────────── isValidSourceForMode(source, mode) { 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; } isValidActionForMode(action, mode) { 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; } handleInput(source, action, parameter) { return this.flowController.handle(source, action, parameter); } abortMovement(reason = 'group override') { if (this.state?.abortCurrentMovement) this.state.abortCurrentMovement(reason); } setMode(newMode) { 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; } this.currentMode = newMode; this.logger.info(`Mode successfully changed to '${newMode}'.`); } 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); } // ── 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) }; } // ── group-scope operating point (MGC) ────────────────────────────── _ensureGroupPredicts() { if (!this.hasCurve || !this.predictFlow || !this.predictPower || !this.predictCtrl) return; if (this.groupPredictFlow && this.groupPredictPower && this.groupPredictCtrl) return; const built = buildGroupPredictors(this.predictors); if (!built) return; this.groupPredictFlow = built.groupPredictFlow; this.groupPredictPower = built.groupPredictPower; this.groupPredictCtrl = built.groupPredictCtrl; } 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(); } 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); } _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 dP = this.groupPredictFlow.currentF; const { peakIndex } = this.calcEfficiencyCurve(powerCurve, flowCurve, dP); const yMin = this.groupPredictFlow.currentFxyYMin; const yMax = this.groupPredictFlow.currentFxyYMax; if (yMax <= yMin) return 0; return (flowCurve.y[peakIndex] - yMin) / (yMax - yMin); } reverseCurve(c) { return reverseCurve(c); } // ── efficiency math (delegates) ──────────────────────────────────── calcCog() { return eff.calcCog(this); } calcEfficiencyCurve(p, f, dP) { return eff.calcEfficiencyCurve(p, f, dP); } 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); } updateCurve(newCurve) { this.logger.info('Updating machine curve'); const normalized = normalizeMachineCurve(newCurve, this.unitPolicy, this.logger); this.config = this.configUtils.updateConfig(this.config, { asset: { machineCurve: normalized, curveUnits: this.unitPolicy.curve }, }); if (!this.predictFlow || !this.predictPower || !this.predictCtrl) { 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; } else { this.predictFlow.updateCurve(this.config.asset.machineCurve.nq); this.predictPower.updateCurve(this.config.asset.machineCurve.np); this.predictCtrl.updateCurve(reverseCurve(this.config.asset.machineCurve.nq)); } } showCoG() { return display.showCoG(this); } showWorkingCurves() { return display.showWorkingCurves(this); } // ── output + status ───────────────────────────────────────────────── getOutput() { return io.buildOutput(this); } getStatusBadge() { return io.buildStatusBadge(this); } close() { this._stateUnbind?.(); detachAllListeners(this); if (this.state?.emitter) this.state.emitter.removeAllListeners(); super.close?.(); } } module.exports = Machine;