1563 lines
58 KiB
JavaScript
1563 lines
58 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;
|
||
}
|
||
}
|
||
|
||
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 -------------//
|
||
|
||
_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 = {};
|
||
for (const [pressureKey, pair] of Object.entries(section || {})) {
|
||
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}'.`);
|
||
}
|
||
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) {
|
||
|
||
const sequence = this.config.sequences[sequenceName];
|
||
|
||
if (!sequence || sequence.size === 0) {
|
||
this.logger.warn(`Sequence '${sequenceName}' not defined.`);
|
||
return;
|
||
}
|
||
|
||
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 cFlow = this.predictFlow.y(x);
|
||
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);
|
||
//this.logger.debug(`Calculated flow: ${cFlow} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
||
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;
|
||
}
|
||
|
||
//this.predictPower.currentX = x; Decrepated
|
||
const cPower = this.predictPower.y(x);
|
||
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power);
|
||
//this.logger.debug(`Calculated power: ${cPower} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
|
||
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;
|
||
|
||
}
|
||
|
||
// 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 === false){
|
||
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} This is less acurate!!`);
|
||
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} This is less acurate!!`);
|
||
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){
|
||
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
|
||
}
|
||
return distance;
|
||
}
|
||
|
||
showWorkingCurves() {
|
||
// 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() {
|
||
|
||
//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 = 0;
|
||
|
||
// Calculate efficiency curve based on power and flow curves
|
||
powerCurve.y.forEach((power, index) => {
|
||
|
||
// Get flow for the current power
|
||
const flow = flowCurve.y[index];
|
||
|
||
// higher efficiency is better
|
||
efficiencyCurve.push( Math.round( ( flow / power ) * 100 ) / 100);
|
||
|
||
// Keep track of peak efficiency
|
||
peak = Math.max(peak, efficiencyCurve[index]);
|
||
peakIndex = peak == efficiencyCurve[index] ? index : peakIndex;
|
||
minEfficiency = Math.min(...efficiencyCurve);
|
||
|
||
});
|
||
|
||
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('predicted').position('atEquipment').getCurrentValue('m3/s');
|
||
const powerWatt = this.measurements.type('power').variant('predicted').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
|
||
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() {
|
||
const powerCurve = this.predictPower.inputCurveData;
|
||
const flowCurve = this.predictFlow.inputCurveData;
|
||
return { powerCurve, flowCurve };
|
||
}
|
||
|
||
getCurrentCurves() {
|
||
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;
|