Files
rotatingMachine/src/specificClass.js
znetsixe 394a972d10 hydraulic efficiency η = (Q·ΔP)/P + asset registry rename
The pre-existing efficiency formula `η = flow/power` produced tiny SI-unit
values (m³/J ≈ 1e-5), was monotonic in ctrl for centrifugal-pump curves
(no interior peak), and made NCog collapse to 0 — which cascaded into MGC
reporting BEP-position 0.0% always. Replaced with hydraulic efficiency
η = (Q·ΔP)/P_shaft, the dimensionless 0..1 ratio that has a real BEP and
matches the form MGC's group-level math uses.

- prediction/efficiencyMath.js:
  * calcEfficiencyCurve takes pressureDiffPa; η = 0 when dP missing
  * calcCog guards (yMax > yMin) before computing NCog (was unguarded /0)
  * calcEfficiency falls back to predictFlow.currentF when measured ΔP is
    missing, so predicted-variant calls still produce a meaningful η before
    the differential measurement settles
- specificClass.js:
  * Asset-registry lookup renamed: 'machine' → 'rotatingmachine' (matches
    the datasets/assetData/ rename in generalFunctions). The error path
    quotes the new filename so operators can find it.
  * Two-call-site fix: with default-param stateConfig={}, the single-arg
    constructor path (BaseNodeAdapter calls `new Machine(this.config)`
    after pre-setting Machine._pendingExtras) was silently clobbering the
    pre-set extras. Only overwrite when the caller explicitly passes them.
  * Push port 0 deltas (notifyOutputChanged) after prediction updates so
    dashboards see state + predicted-flow changes as they happen.
- pressure/pressureRouter.js: routing + fallback hardening (the trigger
  for the bep-distance-cascade reproduction).
- display/workingCurves.js: Q-H curve generator extended.
- New tests:
  * test/integration/qh-curve.integration.test.js — Q-H curve shape
  * test/integration/bep-distance-cascade.integration.test.js — reproduces
    the dashboard report (absDistFromPeak=0, NCog=0, efficiency=0 after a
    setpoint move) at the unit level so future regressions fail loudly.

Full suite: 214/214 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:52:24 +02:00

448 lines
23 KiB
JavaScript

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