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>
448 lines
23 KiB
JavaScript
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;
|