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