updates
This commit is contained in:
109
src/hydraulicModel.js
Normal file
109
src/hydraulicModel.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const SERVICE_TYPES = Object.freeze({
|
||||
GAS: 'gas',
|
||||
LIQUID: 'liquid',
|
||||
});
|
||||
|
||||
function normalizeServiceType(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (raw === SERVICE_TYPES.LIQUID) {
|
||||
return SERVICE_TYPES.LIQUID;
|
||||
}
|
||||
return SERVICE_TYPES.GAS;
|
||||
}
|
||||
|
||||
class ValveHydraulicModel {
|
||||
constructor(options = {}, logger = null) {
|
||||
this.logger = logger;
|
||||
this.serviceType = normalizeServiceType(options.serviceType);
|
||||
|
||||
const gasLimit = Number(options.gasChokedRatioLimit);
|
||||
this.gasChokedRatioLimit = Number.isFinite(gasLimit)
|
||||
? Math.min(Math.max(gasLimit, 0), 1)
|
||||
: 0.7;
|
||||
|
||||
this.defaultDensity = this.serviceType === SERVICE_TYPES.LIQUID ? 997 : 1.204;
|
||||
this.defaultTemperatureK = this.serviceType === SERVICE_TYPES.LIQUID ? 293.15 : 293.15;
|
||||
}
|
||||
|
||||
calculateDeltaPMbar({ qM3h, kv, downstreamGaugeMbar, rho, tempK }) {
|
||||
const flow = Number(qM3h);
|
||||
const kvNum = Number(kv);
|
||||
const p2GaugeMbar = Number(downstreamGaugeMbar);
|
||||
const density = Number(rho);
|
||||
const temperatureK = Number(tempK);
|
||||
|
||||
if (!Number.isFinite(flow) || !Number.isFinite(kvNum) || kvNum <= 0 || flow === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.serviceType === SERVICE_TYPES.LIQUID) {
|
||||
return this._calculateLiquidDeltaP(flow, kvNum, density);
|
||||
}
|
||||
return this._calculateGasDeltaP(flow, kvNum, p2GaugeMbar, density, temperatureK);
|
||||
}
|
||||
|
||||
_calculateGasDeltaP(flowM3h, kv, downstreamGaugeMbar, rho, tempK) {
|
||||
const density = Number.isFinite(rho) && rho > 0 ? rho : this.defaultDensity;
|
||||
const temperatureK = Number.isFinite(tempK) && tempK > 0 ? tempK : this.defaultTemperatureK;
|
||||
if (!Number.isFinite(downstreamGaugeMbar)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const p2AbsBar = (downstreamGaugeMbar / 1000) + 1.01325;
|
||||
if (!Number.isFinite(p2AbsBar) || p2AbsBar <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawDeltaPBar = (flowM3h ** 2 * density * temperatureK) / (514 ** 2 * kv ** 2 * p2AbsBar);
|
||||
if (!Number.isFinite(rawDeltaPBar) || rawDeltaPBar < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxNonChokedDeltaPBar = this.gasChokedRatioLimit * p2AbsBar;
|
||||
const isChoked = Number.isFinite(maxNonChokedDeltaPBar) && rawDeltaPBar > maxNonChokedDeltaPBar;
|
||||
const effectiveDeltaPBar = isChoked ? maxNonChokedDeltaPBar : rawDeltaPBar;
|
||||
|
||||
if (isChoked) {
|
||||
this.logger?.debug?.(
|
||||
`Gas flow reached choked limit: rawDeltaPBar=${rawDeltaPBar.toFixed(6)}, cappedTo=${effectiveDeltaPBar.toFixed(6)}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
deltaPMbar: effectiveDeltaPBar * 1000,
|
||||
details: {
|
||||
serviceType: this.serviceType,
|
||||
isChoked,
|
||||
rawDeltaPBar,
|
||||
effectiveDeltaPBar,
|
||||
p2AbsBar,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
_calculateLiquidDeltaP(flowM3h, kv, rho) {
|
||||
const density = Number.isFinite(rho) && rho > 0 ? rho : this.defaultDensity;
|
||||
const specificGravity = density / 1000;
|
||||
const deltaPBar = (flowM3h / kv) ** 2 * specificGravity;
|
||||
|
||||
if (!Number.isFinite(deltaPBar) || deltaPBar < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
deltaPMbar: deltaPBar * 1000,
|
||||
details: {
|
||||
serviceType: this.serviceType,
|
||||
isChoked: false,
|
||||
rawDeltaPBar: deltaPBar,
|
||||
effectiveDeltaPBar: deltaPBar,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ValveHydraulicModel,
|
||||
SERVICE_TYPES,
|
||||
normalizeServiceType,
|
||||
};
|
||||
129
src/nodeClass.js
129
src/nodeClass.js
@@ -2,7 +2,7 @@
|
||||
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
|
||||
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
|
||||
*/
|
||||
const { outputUtils, configManager } = require('generalFunctions');
|
||||
const { outputUtils, configManager, convert } = require('generalFunctions');
|
||||
const Specific = require("./specificClass");
|
||||
|
||||
|
||||
@@ -42,26 +42,27 @@ class nodeClass {
|
||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
||||
*/
|
||||
_loadConfig(uiConfig,node) {
|
||||
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||
|
||||
// Merge UI config over defaults
|
||||
this.config = {
|
||||
general: {
|
||||
name: uiConfig.name,
|
||||
id: node.id, // node.id is for the child registration process
|
||||
unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards)
|
||||
unit: flowUnit,
|
||||
logging: {
|
||||
enabled: uiConfig.enableLog,
|
||||
logLevel: uiConfig.logLevel
|
||||
}
|
||||
},
|
||||
asset: {
|
||||
uuid: uiConfig.assetUuid, //need to add this later to the asset model
|
||||
tagCode: uiConfig.assetTagCode, //need to add this later to the asset model
|
||||
uuid: uiConfig.uuid || uiConfig.assetUuid || null,
|
||||
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null,
|
||||
supplier: uiConfig.supplier,
|
||||
category: uiConfig.category, //add later to define as the software type
|
||||
type: uiConfig.assetType,
|
||||
model: uiConfig.model,
|
||||
unit: uiConfig.unit
|
||||
unit: flowUnit
|
||||
},
|
||||
functionality: {
|
||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment', // Default to 'atEquipment' if not specified
|
||||
@@ -72,32 +73,61 @@ class nodeClass {
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
|
||||
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
|
||||
const raw = typeof candidate === "string" ? candidate.trim() : "";
|
||||
const fallback = String(fallbackUnit || "").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.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the core logic and store as source.
|
||||
*/
|
||||
_setupSpecificClass(uiConfig) {
|
||||
const vconfig = this.config;
|
||||
const asNumberOrUndefined = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
// need extra state for this
|
||||
const stateConfig = {
|
||||
general: {
|
||||
logging: {
|
||||
enabled: vconfig.eneableLog,
|
||||
logLevel: vconfig.logLevel
|
||||
enabled: vconfig.general.logging.enabled,
|
||||
logLevel: vconfig.general.logging.logLevel
|
||||
}
|
||||
},
|
||||
movement: {
|
||||
speed: Number(uiConfig.speed)
|
||||
speed: asNumberOrUndefined(uiConfig.speed)
|
||||
},
|
||||
time: {
|
||||
starting: Number(uiConfig.startup),
|
||||
warmingup: Number(uiConfig.warmup),
|
||||
stopping: Number(uiConfig.shutdown),
|
||||
coolingdown: Number(uiConfig.cooldown)
|
||||
starting: asNumberOrUndefined(uiConfig.startup),
|
||||
warmingup: asNumberOrUndefined(uiConfig.warmup),
|
||||
stopping: asNumberOrUndefined(uiConfig.shutdown),
|
||||
coolingdown: asNumberOrUndefined(uiConfig.cooldown)
|
||||
}
|
||||
};
|
||||
|
||||
this.source = new Specific(vconfig, stateConfig);
|
||||
const runtimeOptions = {
|
||||
serviceType: uiConfig.serviceType,
|
||||
fluidDensity: asNumberOrUndefined(uiConfig.fluidDensity),
|
||||
fluidTemperatureK: asNumberOrUndefined(uiConfig.fluidTemperatureK),
|
||||
gasChokedRatioLimit: asNumberOrUndefined(uiConfig.gasChokedRatioLimit),
|
||||
};
|
||||
|
||||
this.source = new Specific(vconfig, stateConfig, runtimeOptions);
|
||||
|
||||
//store in node
|
||||
this.node.source = this.source; // Store the source in the node instance for easy access
|
||||
@@ -111,16 +141,27 @@ class nodeClass {
|
||||
|
||||
}
|
||||
|
||||
_updateNodeStatus() {
|
||||
const v = this.source;
|
||||
_updateNodeStatus() {
|
||||
const v = this.source;
|
||||
|
||||
try {
|
||||
const mode = v.currentMode; // modus is bijv. auto, manual, etc.
|
||||
const state = v.state.getCurrentState(); //is bijv. operational, idle, off, etc.
|
||||
// check if measured flow is available otherwise use predicted flow
|
||||
const flow = Math.round(v.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue());
|
||||
try {
|
||||
const mode = v.currentMode; // modus is bijv. auto, manual, etc.
|
||||
const state = v.state.getCurrentState(); //is bijv. operational, idle, off, etc.
|
||||
const fluidCompatibility = typeof v.getFluidCompatibility === "function"
|
||||
? v.getFluidCompatibility()
|
||||
: null;
|
||||
const fluidWarningText = (
|
||||
fluidCompatibility
|
||||
&& (fluidCompatibility.status === "mismatch" || fluidCompatibility.status === "conflict")
|
||||
)
|
||||
? fluidCompatibility.message
|
||||
: "";
|
||||
const flowUnit = v?.unitPolicy?.output?.flow || this.config.general.unit || "m3/h";
|
||||
const pressureUnit = v?.unitPolicy?.output?.pressure || "mbar";
|
||||
// check if measured flow is available otherwise use predicted flow
|
||||
const flow = Math.round(v.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(flowUnit));
|
||||
|
||||
let deltaP = v.measurements.type("pressure").variant("predicted").position("delta").getCurrentValue();
|
||||
let deltaP = v.measurements.type("pressure").variant("predicted").position("delta").getCurrentValue(pressureUnit);
|
||||
if (deltaP !== null) {
|
||||
deltaP = parseFloat(deltaP.toFixed(0));
|
||||
} //afronden op 4 decimalen indien geen "null"
|
||||
@@ -169,16 +210,16 @@ class nodeClass {
|
||||
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "operational":
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}m³/h | ΔP${deltaP} mbar`}; //deltaP toegevoegd
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ΔP${deltaP} ${pressureUnit}`}; //deltaP toegevoegd
|
||||
break;
|
||||
case "starting":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "warmingup":
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}m³/h | ΔP${deltaP} mbar`}; //deltaP toegevoegd
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ΔP${deltaP} ${pressureUnit}`}; //deltaP toegevoegd
|
||||
break;
|
||||
case "accelerating":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}m³/h | ΔP${deltaP} mbar` }; //deltaP toegevoegd
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ΔP${deltaP} ${pressureUnit}` }; //deltaP toegevoegd
|
||||
break;
|
||||
case "stopping":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
@@ -187,16 +228,23 @@ class nodeClass {
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "decelerating":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}m³/h | ΔP${deltaP} mbar`}; //deltaP toegevoegd
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ΔP${deltaP} ${pressureUnit}`}; //deltaP toegevoegd
|
||||
break;
|
||||
default:
|
||||
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
}
|
||||
return status;
|
||||
} catch (error) {
|
||||
node.error("Error in updateNodeStatus: " + error.message);
|
||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||
}
|
||||
default:
|
||||
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
}
|
||||
if (fluidWarningText) {
|
||||
status = {
|
||||
fill: "yellow",
|
||||
shape: "ring",
|
||||
text: `${status.text} | ⚠ ${fluidWarningText}`,
|
||||
};
|
||||
}
|
||||
return status;
|
||||
} catch (error) {
|
||||
this.node.error("Error in updateNodeStatus: " + error.message);
|
||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,8 +284,8 @@ class nodeClass {
|
||||
//this.source.tick();
|
||||
|
||||
const raw = this.source.getOutput();
|
||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
||||
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
|
||||
|
||||
// Send only updated outputs on ports 0 & 1
|
||||
this.node.send([processMsg, influxMsg]);
|
||||
@@ -274,16 +322,18 @@ class nodeClass {
|
||||
v.handleInput(mvSource, mvAction, Number(setpoint));
|
||||
break;
|
||||
}
|
||||
case 'emergencystop': {
|
||||
const { source: esSource, action: esAction } = msg.payload;
|
||||
v.handleInput(esSource, esAction);
|
||||
case 'emergencystop':
|
||||
case 'emergencyStop': {
|
||||
const payload = msg.payload || {};
|
||||
const esSource = payload.source || 'parent';
|
||||
v.handleInput(esSource, 'emergencystop');
|
||||
break;
|
||||
}
|
||||
case 'showcurve':
|
||||
send({ topic: 'Showing curve', payload: v.showCurve() });
|
||||
break;
|
||||
case 'updateFlow':
|
||||
v.updateFlow(msg.payload.variant, msg.payload.value, msg.payload.position);
|
||||
v.updateFlow(msg.payload.variant, msg.payload.value, msg.payload.position, msg.payload.unit || this.config.general.unit);
|
||||
break;
|
||||
default:
|
||||
v.logger.warn(`Unknown topic: ${msg.topic}`);
|
||||
@@ -303,6 +353,7 @@ class nodeClass {
|
||||
this.node.on('close', (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
this.source?.destroy?.();
|
||||
if (typeof done === 'function') done();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,10 +47,47 @@
|
||||
|
||||
//load local dependencies
|
||||
const EventEmitter = require('events');
|
||||
const {loadCurve,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils} = require('generalFunctions');
|
||||
const { loadCurve, logger, configUtils, configManager, state, MeasurementContainer, predict, childRegistrationUtils, convert } = require('generalFunctions');
|
||||
const { ValveHydraulicModel, normalizeServiceType } = require('./hydraulicModel');
|
||||
|
||||
const SERVICE_TYPES = new Set(['gas', 'liquid']);
|
||||
const DEFAULT_SOURCE_SERVICE_TYPE = Object.freeze({
|
||||
machine: 'liquid',
|
||||
rotatingmachine: 'liquid',
|
||||
machinegroup: 'liquid',
|
||||
machinegroupcontrol: 'liquid',
|
||||
pumpingstation: 'liquid',
|
||||
});
|
||||
|
||||
const CANONICAL_UNITS = Object.freeze({
|
||||
pressure: 'Pa',
|
||||
flow: 'm3/s',
|
||||
temperature: 'K',
|
||||
});
|
||||
|
||||
const DEFAULT_IO_UNITS = Object.freeze({
|
||||
pressure: 'mbar',
|
||||
flow: 'm3/h',
|
||||
temperature: 'C',
|
||||
});
|
||||
|
||||
const FORMULA_UNITS = Object.freeze({
|
||||
pressure: 'mbar',
|
||||
flow: 'm3/h',
|
||||
temperature: 'K',
|
||||
});
|
||||
|
||||
const FALLBACK_SUPPLIER_CURVE = Object.freeze({
|
||||
'1.204': {
|
||||
'125': {
|
||||
x: [0, 100],
|
||||
y: [0, 1],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
class Valve {
|
||||
constructor(valveConfig = {}, stateConfig = {}) {
|
||||
constructor(valveConfig = {}, stateConfig = {}, runtimeOptions = {}) {
|
||||
//basic setup
|
||||
this.emitter = new EventEmitter(); // nodig voor ontvangen en uitvoeren van events emit() --> Zien als internet berichten (niet bedraad in node-red)
|
||||
|
||||
@@ -59,15 +96,37 @@ class Valve {
|
||||
this.defaultConfig = this.configManager.getConfig('valve'); // Load default config for rotating machine ( use software type name ? )
|
||||
this.configUtils = new configUtils(this.defaultConfig);
|
||||
|
||||
// Load a specific curve
|
||||
// Load supplier-specific curve data (if available for model)
|
||||
this.model = valveConfig.asset.model; // Get the model from the valveConfig
|
||||
this.curve = this.model ? loadCurve(this.model) : null;
|
||||
|
||||
//Init config and check if it is valid
|
||||
this.config = this.configUtils.initConfig(valveConfig);
|
||||
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 },
|
||||
});
|
||||
|
||||
// Initialize measurements
|
||||
this.measurements = new MeasurementContainer();
|
||||
this.measurements = new MeasurementContainer({
|
||||
autoConvert: true,
|
||||
defaultUnits: {
|
||||
pressure: this.unitPolicy.output.pressure,
|
||||
flow: this.unitPolicy.output.flow,
|
||||
temperature: this.unitPolicy.output.temperature,
|
||||
},
|
||||
preferredUnits: {
|
||||
pressure: this.unitPolicy.output.pressure,
|
||||
flow: this.unitPolicy.output.flow,
|
||||
temperature: this.unitPolicy.output.temperature,
|
||||
},
|
||||
canonicalUnits: this.unitPolicy.canonical,
|
||||
storeCanonical: true,
|
||||
strictUnitValidation: true,
|
||||
throwOnInvalidUnit: true,
|
||||
requireUnitForTypes: ['pressure', 'flow', 'temperature'],
|
||||
}, this.logger);
|
||||
this.child = {}; // object to hold child information so we know on what to subscribe
|
||||
|
||||
// Init after config is set
|
||||
@@ -75,22 +134,51 @@ class Valve {
|
||||
|
||||
this.state.stateManager.currentState = "operational"; // Set default state to operational
|
||||
|
||||
this.kv = 0; //default
|
||||
this.rho = 1,225 //dichtheid van lucht standaard
|
||||
this.T = 293; // temperatuur in K standaard
|
||||
this.downstreamP = 0.54 //hardcodes for now --> assumed to be constant watercolumn and deltaP diffuser
|
||||
this.kv = 0; // default
|
||||
const configuredServiceType = this._normalizeOptionalServiceType(runtimeOptions?.serviceType || valveConfig?.asset?.serviceType);
|
||||
this.expectedServiceType = configuredServiceType;
|
||||
this.serviceType = configuredServiceType || normalizeServiceType(runtimeOptions?.serviceType || valveConfig?.asset?.serviceType);
|
||||
this.upstreamFluidSources = new Map();
|
||||
this._fluidContractListeners = new Map();
|
||||
this.fluidCompatibility = {
|
||||
status: configuredServiceType ? 'pending' : 'unknown',
|
||||
expectedServiceType: configuredServiceType || null,
|
||||
receivedServiceType: null,
|
||||
upstreamServiceTypes: [],
|
||||
sourceCount: 0,
|
||||
message: configuredServiceType
|
||||
? `Waiting for upstream fluid contract (${configuredServiceType}).`
|
||||
: 'No upstream fluid contract available.',
|
||||
};
|
||||
this.hydraulicModel = new ValveHydraulicModel(
|
||||
{
|
||||
serviceType: this.serviceType,
|
||||
gasChokedRatioLimit: runtimeOptions?.gasChokedRatioLimit ?? valveConfig?.asset?.gasChokedRatioLimit,
|
||||
},
|
||||
this.logger
|
||||
);
|
||||
this.rho = this._resolvePositiveNumber(
|
||||
runtimeOptions?.fluidDensity,
|
||||
valveConfig?.asset?.fluidDensity,
|
||||
this.hydraulicModel.defaultDensity
|
||||
);
|
||||
this.T = this._resolvePositiveNumber(
|
||||
runtimeOptions?.fluidTemperatureK,
|
||||
valveConfig?.asset?.fluidTemperatureK,
|
||||
this.hydraulicModel.defaultTemperatureK
|
||||
);
|
||||
this.currentMode = this.config.mode.current;
|
||||
|
||||
// wanneer hij deze ontvangt is de positie van de klep verandererd en gaat hij de updateposition functie aanroepen wat dan alle metingen en standen gaat updaten
|
||||
this.state.emitter.on("positionChange", (data) => {
|
||||
this._onPositionChange = (data) => {
|
||||
this.logger.debug(`Position change detected: ${data}`);
|
||||
this.updatePosition()}); //To update deltaP
|
||||
this.updatePosition();
|
||||
};
|
||||
this.state.emitter.on("positionChange", this._onPositionChange); //To update deltaP
|
||||
|
||||
|
||||
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
|
||||
this.vCurve = this.curve[1.204]; // specificy the desired density RECALC THIS AUTOMTICALLY BASED ON DENSITY OF AIR LATER OLIFANT!!
|
||||
this.predictKv = new predict({curve:this.vCurve}); // load valve size (x : ctrl , y : kv relationship)
|
||||
//this.logger.debug(`PredictKv initialized with curve: ${JSON.stringify(this.predictKv)}`);
|
||||
this._initSupplierCurvePredictor();
|
||||
}
|
||||
|
||||
// -------- Config -------- //
|
||||
@@ -121,7 +209,11 @@ class Valve {
|
||||
break;
|
||||
case "emergencyStop":
|
||||
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
||||
await this.executeSequence("emergencyStop");
|
||||
await this.executeSequence("emergencystop");
|
||||
break;
|
||||
case "emergencystop":
|
||||
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
||||
await this.executeSequence("emergencystop");
|
||||
break;
|
||||
case "statusCheck":
|
||||
this.logger.info(`Status Check: Mode = '${this.currentMode}', Source = '${source }'.`);
|
||||
@@ -151,6 +243,413 @@ class Valve {
|
||||
this.logger.info(`Mode successfully changed to '${newMode}'.`);
|
||||
}
|
||||
|
||||
_buildUnitPolicy(config = {}) {
|
||||
const flowUnit = this._resolveUnitOrFallback(
|
||||
config?.general?.unit || config?.asset?.unit,
|
||||
'volumeFlowRate',
|
||||
DEFAULT_IO_UNITS.flow
|
||||
);
|
||||
|
||||
return {
|
||||
canonical: { ...CANONICAL_UNITS },
|
||||
output: {
|
||||
flow: flowUnit,
|
||||
pressure: DEFAULT_IO_UNITS.pressure,
|
||||
temperature: DEFAULT_IO_UNITS.temperature,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit) {
|
||||
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}', got '${desc.measure}'`);
|
||||
}
|
||||
return raw;
|
||||
} catch (error) {
|
||||
this.logger?.warn?.(`Invalid unit '${raw}' (${error.message}); falling back to '${fallback}'.`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
_outputUnitForType(type) {
|
||||
switch (String(type || '').toLowerCase()) {
|
||||
case 'flow':
|
||||
return this.unitPolicy.output.flow;
|
||||
case 'pressure':
|
||||
return this.unitPolicy.output.pressure;
|
||||
case 'temperature':
|
||||
return this.unitPolicy.output.temperature;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_readMeasurement(type, variant, position, unit = null) {
|
||||
const requestedUnit = unit || this._outputUnitForType(type);
|
||||
return this.measurements
|
||||
.type(type)
|
||||
.variant(variant)
|
||||
.position(position)
|
||||
.getCurrentValue(requestedUnit || undefined);
|
||||
}
|
||||
|
||||
_writeMeasurement(type, variant, position, value, unit = null, timestamp = Date.now()) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
this.measurements
|
||||
.type(type)
|
||||
.variant(variant)
|
||||
.position(position)
|
||||
.value(value, timestamp, unit || undefined);
|
||||
}
|
||||
|
||||
_resolvePositiveNumber(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const parsed = Number(candidate);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
_normalizeOptionalServiceType(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (SERVICE_TYPES.has(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_deriveDefaultServiceTypeForSoftwareType(softwareType) {
|
||||
const key = String(softwareType || '').trim().toLowerCase();
|
||||
return DEFAULT_SOURCE_SERVICE_TYPE[key] || null;
|
||||
}
|
||||
|
||||
_extractFluidContractFromChild(child, softwareType) {
|
||||
const sourceType = String(softwareType || child?.config?.functionality?.softwareType || '').trim().toLowerCase();
|
||||
let contractFromChild = null;
|
||||
|
||||
if (typeof child?.getFluidContract === 'function') {
|
||||
try {
|
||||
contractFromChild = child.getFluidContract();
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to read child fluid contract: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const contractStatus = String(contractFromChild?.status || '').trim().toLowerCase();
|
||||
if (contractStatus === 'conflict') {
|
||||
return {
|
||||
status: 'conflict',
|
||||
serviceType: null,
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
const contractType = this._normalizeOptionalServiceType(contractFromChild?.serviceType);
|
||||
if (contractType) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
serviceType: contractType,
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
const directType = this._normalizeOptionalServiceType(
|
||||
child?.serviceType
|
||||
|| child?.expectedServiceType
|
||||
|| child?.config?.asset?.serviceType
|
||||
);
|
||||
if (directType) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
serviceType: directType,
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackType = this._deriveDefaultServiceTypeForSoftwareType(sourceType);
|
||||
if (fallbackType) {
|
||||
return {
|
||||
status: 'inferred',
|
||||
serviceType: fallbackType,
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unknown',
|
||||
serviceType: null,
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
_bindFluidContractListener(sourceId, child, sourceType) {
|
||||
if (!sourceId || this._fluidContractListeners.has(sourceId)) {
|
||||
return;
|
||||
}
|
||||
if (!child?.emitter || typeof child.emitter.on !== 'function') {
|
||||
return;
|
||||
}
|
||||
const handler = () => {
|
||||
const latest = this._extractFluidContractFromChild(child, sourceType);
|
||||
const existing = this.upstreamFluidSources.get(sourceId) || {};
|
||||
existing.contract = latest;
|
||||
this.upstreamFluidSources.set(sourceId, existing);
|
||||
this._updateFluidCompatibilityState();
|
||||
};
|
||||
child.emitter.on('fluidContractChange', handler);
|
||||
this._fluidContractListeners.set(sourceId, {
|
||||
emitter: child.emitter,
|
||||
handler,
|
||||
});
|
||||
}
|
||||
|
||||
_computeFluidCompatibilitySnapshot() {
|
||||
const expectedServiceType = this.expectedServiceType || null;
|
||||
const contracts = Array.from(this.upstreamFluidSources.values())
|
||||
.map((entry) => entry?.contract)
|
||||
.filter(Boolean);
|
||||
const upstreamServiceTypes = Array.from(new Set(
|
||||
contracts
|
||||
.map((contract) => this._normalizeOptionalServiceType(contract.serviceType))
|
||||
.filter(Boolean)
|
||||
));
|
||||
const hasConflict = contracts.some((contract) => String(contract.status || '').toLowerCase() === 'conflict');
|
||||
const sourceCount = this.upstreamFluidSources.size;
|
||||
|
||||
if (hasConflict || upstreamServiceTypes.length > 1) {
|
||||
return {
|
||||
status: 'conflict',
|
||||
expectedServiceType,
|
||||
receivedServiceType: upstreamServiceTypes.length === 1 ? upstreamServiceTypes[0] : null,
|
||||
upstreamServiceTypes,
|
||||
sourceCount,
|
||||
message: `Conflicting upstream fluids detected: ${upstreamServiceTypes.join(', ') || 'unknown'}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (upstreamServiceTypes.length === 1) {
|
||||
const receivedServiceType = upstreamServiceTypes[0];
|
||||
if (expectedServiceType && expectedServiceType !== receivedServiceType) {
|
||||
return {
|
||||
status: 'mismatch',
|
||||
expectedServiceType,
|
||||
receivedServiceType,
|
||||
upstreamServiceTypes,
|
||||
sourceCount,
|
||||
message: `Expected ${expectedServiceType}, received ${receivedServiceType}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: expectedServiceType ? 'match' : 'inferred',
|
||||
expectedServiceType,
|
||||
receivedServiceType,
|
||||
upstreamServiceTypes,
|
||||
sourceCount,
|
||||
message: expectedServiceType
|
||||
? `Fluid contract validated: ${receivedServiceType}.`
|
||||
: `Fluid inferred from upstream: ${receivedServiceType}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: expectedServiceType ? 'pending' : 'unknown',
|
||||
expectedServiceType,
|
||||
receivedServiceType: null,
|
||||
upstreamServiceTypes: [],
|
||||
sourceCount,
|
||||
message: expectedServiceType
|
||||
? `Waiting for upstream fluid contract (${expectedServiceType}).`
|
||||
: 'No upstream fluid contract available.',
|
||||
};
|
||||
}
|
||||
|
||||
_updateFluidCompatibilityState() {
|
||||
const next = this._computeFluidCompatibilitySnapshot();
|
||||
const previous = this.fluidCompatibility || {};
|
||||
const changed = (
|
||||
previous.status !== next.status
|
||||
|| previous.expectedServiceType !== next.expectedServiceType
|
||||
|| previous.receivedServiceType !== next.receivedServiceType
|
||||
|| previous.sourceCount !== next.sourceCount
|
||||
|| (previous.message || '') !== (next.message || '')
|
||||
);
|
||||
this.fluidCompatibility = next;
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
if (next.status === 'mismatch' || next.status === 'conflict') {
|
||||
this.logger.warn(`Fluid compatibility warning: ${next.message}`);
|
||||
} else {
|
||||
this.logger.info(`Fluid compatibility update: ${next.message}`);
|
||||
}
|
||||
this.emitter.emit('fluidCompatibilityChange', this.getFluidCompatibility());
|
||||
this.emitter.emit('fluidContractChange', this.getFluidContract());
|
||||
}
|
||||
|
||||
getFluidCompatibility() {
|
||||
const state = this.fluidCompatibility || {};
|
||||
return {
|
||||
status: state.status || 'unknown',
|
||||
expectedServiceType: state.expectedServiceType || null,
|
||||
receivedServiceType: state.receivedServiceType || null,
|
||||
upstreamServiceTypes: Array.isArray(state.upstreamServiceTypes) ? [...state.upstreamServiceTypes] : [],
|
||||
sourceCount: Number(state.sourceCount) || 0,
|
||||
message: state.message || '',
|
||||
};
|
||||
}
|
||||
|
||||
getFluidContract() {
|
||||
const compatibility = this.getFluidCompatibility();
|
||||
if (compatibility.status === 'conflict') {
|
||||
return {
|
||||
status: 'conflict',
|
||||
serviceType: null,
|
||||
expectedServiceType: compatibility.expectedServiceType,
|
||||
observedServiceType: compatibility.receivedServiceType,
|
||||
source: 'valve',
|
||||
};
|
||||
}
|
||||
|
||||
const advertisedServiceType = compatibility.expectedServiceType || null;
|
||||
return {
|
||||
status: advertisedServiceType ? 'resolved' : 'unknown',
|
||||
serviceType: advertisedServiceType,
|
||||
expectedServiceType: compatibility.expectedServiceType,
|
||||
observedServiceType: compatibility.receivedServiceType,
|
||||
source: 'valve',
|
||||
};
|
||||
}
|
||||
|
||||
registerChild(child, softwareType) {
|
||||
if (!child || typeof child !== 'object') {
|
||||
this.logger.warn('registerChild skipped: invalid child payload');
|
||||
return false;
|
||||
}
|
||||
const sourceType = String(softwareType || child?.config?.functionality?.softwareType || '').trim().toLowerCase();
|
||||
const sourceId = child?.config?.general?.id
|
||||
|| child?.config?.general?.name
|
||||
|| `source-${this.upstreamFluidSources.size + 1}`;
|
||||
const contract = this._extractFluidContractFromChild(child, sourceType);
|
||||
this.upstreamFluidSources.set(sourceId, {
|
||||
child,
|
||||
sourceType,
|
||||
contract,
|
||||
});
|
||||
this._bindFluidContractListener(sourceId, child, sourceType);
|
||||
this._updateFluidCompatibilityState();
|
||||
this.logger.info(`Source '${sourceId}' (${sourceType || 'unknown'}) registered for fluid contract.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
_initSupplierCurvePredictor() {
|
||||
const supplierCurve = this._resolveSupplierCurveData();
|
||||
const densityTarget = Number.isFinite(this.rho) && this.rho > 0 ? this.rho : this.hydraulicModel.defaultDensity;
|
||||
const densityKey = this._pickNearestNumericKey(Object.keys(supplierCurve), densityTarget);
|
||||
const densityCurveFamily = supplierCurve[densityKey];
|
||||
const diameterTarget = Number(this.config?.asset?.valveDiameter);
|
||||
const diameterKey = this._pickNearestNumericKey(
|
||||
Object.keys(densityCurveFamily || {}),
|
||||
Number.isFinite(diameterTarget) && diameterTarget > 0 ? diameterTarget : 125
|
||||
);
|
||||
|
||||
this.curveSelection = {
|
||||
densityKey: Number(densityKey),
|
||||
diameterKey: Number(diameterKey),
|
||||
};
|
||||
this.rho = Number.isFinite(this.rho) && this.rho > 0 ? this.rho : this.hydraulicModel.defaultDensity;
|
||||
this.T = Number.isFinite(this.T) && this.T > 0 ? this.T : this.hydraulicModel.defaultTemperatureK;
|
||||
|
||||
this.predictKv = new predict({ curve: densityCurveFamily || FALLBACK_SUPPLIER_CURVE['1.204'] });
|
||||
this.predictKv.fDimension = this.curveSelection.diameterKey;
|
||||
|
||||
this.logger.info(
|
||||
`Using supplier curve model='${this.model || "inline"}', densityCurve=${this.curveSelection.densityKey}, diameter=${this.curveSelection.diameterKey}, serviceType=${this.serviceType}`
|
||||
);
|
||||
}
|
||||
|
||||
_resolveSupplierCurveData() {
|
||||
if (this._isValidSupplierCurveData(this.curve)) {
|
||||
return this.curve;
|
||||
}
|
||||
if (this._isValidSupplierCurveData(this.config?.asset?.valveCurve)) {
|
||||
return this.config.asset.valveCurve;
|
||||
}
|
||||
this.logger.warn("No valid supplier curve data found, using fallback curve.");
|
||||
return FALLBACK_SUPPLIER_CURVE;
|
||||
}
|
||||
|
||||
_isValidSupplierCurveData(curveData) {
|
||||
if (!curveData || typeof curveData !== "object") {
|
||||
return false;
|
||||
}
|
||||
const densityKeys = Object.keys(curveData);
|
||||
if (!densityKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (const densityKey of densityKeys) {
|
||||
const diameters = curveData[densityKey];
|
||||
if (!diameters || typeof diameters !== "object") {
|
||||
return false;
|
||||
}
|
||||
const diameterKeys = Object.keys(diameters);
|
||||
if (!diameterKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (const diameterKey of diameterKeys) {
|
||||
const curve = diameters[diameterKey];
|
||||
if (!Array.isArray(curve?.x) || !Array.isArray(curve?.y) || curve.x.length < 2 || curve.x.length !== curve.y.length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_pickNearestNumericKey(keys, target) {
|
||||
const numericKeys = keys.map((key) => Number(key)).filter((value) => Number.isFinite(value));
|
||||
if (!numericKeys.length) {
|
||||
return String(target);
|
||||
}
|
||||
let selected = numericKeys[0];
|
||||
let selectedDistance = Math.abs(selected - target);
|
||||
for (const key of numericKeys) {
|
||||
const distance = Math.abs(key - target);
|
||||
if (distance < selectedDistance) {
|
||||
selected = key;
|
||||
selectedDistance = distance;
|
||||
}
|
||||
}
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
_predictKvForPosition(positionPercent) {
|
||||
if (!this.predictKv) {
|
||||
return 0.1;
|
||||
}
|
||||
try {
|
||||
this.predictKv.fDimension = this.curveSelection?.diameterKey || this.predictKv.fDimension;
|
||||
const kv = Number(this.predictKv.y(positionPercent));
|
||||
if (!Number.isFinite(kv)) {
|
||||
return 0.1;
|
||||
}
|
||||
return Math.max(0.1, kv);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to predict Kv for position=${positionPercent}: ${error.message}`);
|
||||
return 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Sequence Handlers -------- //
|
||||
async executeSequence(sequenceName) {
|
||||
|
||||
@@ -196,9 +695,9 @@ class Valve {
|
||||
}
|
||||
}
|
||||
|
||||
updatePressure(variant,value,position) {
|
||||
updatePressure(variant,value,position,unit = this.unitPolicy.output.pressure) {
|
||||
if( value === null || value === undefined) {
|
||||
this.logger.warn(`Received null or undefined value for flow update. Variant: ${variant}, Position: ${position}`);
|
||||
this.logger.warn(`Received null or undefined value for pressure update. Variant: ${variant}, Position: ${position}`);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Updating pressure: variant=${variant}, value=${value}, position=${position}`);
|
||||
@@ -206,18 +705,24 @@ class Valve {
|
||||
switch (variant) {
|
||||
case ("measured"):
|
||||
// put value in measurements container
|
||||
this.measurements.type("pressure").variant("measured").position(position).value(value);
|
||||
this._writeMeasurement("pressure", "measured", position, Number(value), unit);
|
||||
// get latest downstream pressure measurement
|
||||
const measuredDownStreamP = this.measurements.type("pressure").variant("measured").position("downstream").getCurrentValue(); //update downstream pressure measurement
|
||||
const measuredDownStreamP = this._readMeasurement("pressure", "measured", "downstream", FORMULA_UNITS.pressure);
|
||||
const measuredFlow = this._readMeasurement("flow", "measured", "downstream", FORMULA_UNITS.flow);
|
||||
const predictedFlow = this._readMeasurement("flow", "predicted", "downstream", FORMULA_UNITS.flow);
|
||||
const activeFlow = Number.isFinite(predictedFlow) ? predictedFlow : measuredFlow;
|
||||
// update predicted flow measurement
|
||||
this.updateDeltaPKlep(value,this.kv,measuredDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
this.updateDeltaPKlep(activeFlow,this.kv,measuredDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
break;
|
||||
|
||||
case ("predicted"):
|
||||
// put value in measurements container
|
||||
this.measurements.type("pressure").variant("predicted").position(position).value(value);
|
||||
const predictedDownStreamP = this.measurements.type("pressure").variant("measured").position("downstream").getCurrentValue(); //update downstream pressure measurement
|
||||
this.updateDeltaPKlep(value,this.kv,predictedDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
this._writeMeasurement("pressure", "predicted", position, Number(value), unit);
|
||||
const predictedDownStreamP = this._readMeasurement("pressure", "predicted", "downstream", FORMULA_UNITS.pressure);
|
||||
const measuredFlowFromPred = this._readMeasurement("flow", "measured", "downstream", FORMULA_UNITS.flow);
|
||||
const predictedFlowFromPred = this._readMeasurement("flow", "predicted", "downstream", FORMULA_UNITS.flow);
|
||||
const activeFlowFromPred = Number.isFinite(predictedFlowFromPred) ? predictedFlowFromPred : measuredFlowFromPred;
|
||||
this.updateDeltaPKlep(activeFlowFromPred,this.kv,predictedDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -226,15 +731,15 @@ class Valve {
|
||||
}
|
||||
}
|
||||
|
||||
updateMeasurement(variant, subType, value, position) {
|
||||
updateMeasurement(variant, subType, value, position, unit) {
|
||||
this.logger.debug(`---------------------- updating ${subType} ------------------ `);
|
||||
switch (subType) {
|
||||
case "pressure":
|
||||
// Update pressure measurement
|
||||
this.updatePressure(variant,value,position);
|
||||
this.updatePressure(variant,value,position, unit || this.unitPolicy.output.pressure);
|
||||
break;
|
||||
case "flow":
|
||||
this.updateFlow(variant,value,position);
|
||||
this.updateFlow(variant,value,position, unit || this.unitPolicy.output.flow);
|
||||
break;
|
||||
case "power":
|
||||
// Update power measurement
|
||||
@@ -245,41 +750,33 @@ class Valve {
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Omdat met zeer kleine getallen wordt gewerkt en er kwadraten in de formule zitten kan het zijn dat we alles *1000 moeten doen
|
||||
// NOTE: q in m3/h (normalized basis), downstreamP in mbar(g), temp in K
|
||||
updateDeltaPKlep(q,kv,downstreamP,rho,temp){
|
||||
//q must be in Nm3/h
|
||||
//temp must be in K
|
||||
//q must be in m3/h
|
||||
|
||||
//downstreamP must be in bar so transfer from mbar to bar
|
||||
downstreamP = downstreamP / 1000;
|
||||
//convert downstreamP to absolute bar
|
||||
downstreamP += 1.01325;
|
||||
|
||||
if( kv !== 0 && downstreamP != 0 && q != 0) { //check if kv and downstreamP are not zero to avoid division by zero
|
||||
|
||||
//calculate deltaP
|
||||
let deltaP = ( q**2 * rho * temp ) / ( 514**2 * kv**2 * downstreamP);
|
||||
|
||||
//convert deltaP to mbar
|
||||
deltaP = deltaP * 1000;
|
||||
|
||||
// Synchroniseer deltaP met het Valve-object
|
||||
this.deltaPKlep = deltaP
|
||||
|
||||
// Opslaan in measurement container
|
||||
this.measurements.type("pressure").variant("predicted").position("delta").value(deltaP);
|
||||
this.logger.info('DeltaP updated to: ' + deltaP);
|
||||
|
||||
this.emitter.emit('deltaPChange', deltaP); // Emit event to notify valveGroupController of deltaP change
|
||||
this.logger.info('DeltaPChange emitted to valveGroupController');
|
||||
const result = this.hydraulicModel.calculateDeltaPMbar({
|
||||
qM3h: q,
|
||||
kv,
|
||||
downstreamGaugeMbar: downstreamP,
|
||||
rho,
|
||||
tempK: temp,
|
||||
});
|
||||
if (!result || !Number.isFinite(result.deltaPMbar)) {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
const deltaP = result.deltaPMbar;
|
||||
this.deltaPKlep = deltaP;
|
||||
this.hydraulicDiagnostics = result.details || null;
|
||||
|
||||
this._writeMeasurement("pressure", "predicted", "delta", deltaP, this.unitPolicy.output.pressure);
|
||||
this.logger.info('DeltaP updated to: ' + deltaP);
|
||||
|
||||
this.emitter.emit('deltaPChange', deltaP); // Emit event to notify valveGroupController of deltaP change
|
||||
this.logger.info('DeltaPChange emitted to valveGroupController');
|
||||
}
|
||||
|
||||
|
||||
// Als er een nieuwe flow door de klep komt doordat de machines harder zijn gaan werken, dan update deze functie dit ook in de valve attributes en measurements
|
||||
updateFlow(variant,value,position) {
|
||||
updateFlow(variant,value,position,unit = this.unitPolicy.output.flow) {
|
||||
if( value === null || value === undefined) {
|
||||
this.logger.warn(`Received null or undefined value for flow update. Variant: ${variant}, Position: ${position}`);
|
||||
return;
|
||||
@@ -289,18 +786,20 @@ class Valve {
|
||||
switch (variant) {
|
||||
case ("measured"):
|
||||
// put value in measurements container
|
||||
this.measurements.type("flow").variant("measured").position(position).value(value);
|
||||
this._writeMeasurement("flow", "measured", position, Number(value), unit);
|
||||
// get latest downstream pressure measurement
|
||||
const measuredDownStreamP = this.measurements.type("pressure").variant("measured").position("downstream").getCurrentValue(); //update downstream pressure measurement
|
||||
const measuredDownStreamP = this._readMeasurement("pressure", "measured", "downstream", FORMULA_UNITS.pressure);
|
||||
const measuredFlow = this._readMeasurement("flow", "measured", position, FORMULA_UNITS.flow);
|
||||
// update predicted flow measurement
|
||||
this.updateDeltaPKlep(value,this.kv,measuredDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
this.updateDeltaPKlep(measuredFlow,this.kv,measuredDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
break;
|
||||
|
||||
case ("predicted"):
|
||||
// put value in measurements container
|
||||
this.measurements.type("flow").variant("predicted").position(position).value(value);
|
||||
const predictedDownStreamP = this.measurements.type("pressure").variant("measured").position("downstream").getCurrentValue(); //update downstream pressure measurement
|
||||
this.updateDeltaPKlep(value,this.kv,predictedDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
this._writeMeasurement("flow", "predicted", position, Number(value), unit);
|
||||
const predictedDownStreamP = this._readMeasurement("pressure", "measured", "downstream", FORMULA_UNITS.pressure);
|
||||
const predictedFlow = this._readMeasurement("flow", "predicted", position, FORMULA_UNITS.flow);
|
||||
this.updateDeltaPKlep(predictedFlow,this.kv,predictedDownStreamP,this.rho,this.T); //update deltaP based on new flow
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -314,20 +813,15 @@ class Valve {
|
||||
|
||||
this.logger.debug('Calculating new deltaP');
|
||||
const currentPosition = this.state.getCurrentPosition();
|
||||
const measuredFlow = this.measurements.type("flow").variant("measured").position("downstream").getCurrentValue(); // haal de flow op uit de measurement containe
|
||||
const predictedFlow = this.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); // haal de predicted flow op uit de measurement container
|
||||
const currentFlow = predictedFlow ;
|
||||
const measuredFlow = this._readMeasurement("flow", "measured", "downstream", FORMULA_UNITS.flow);
|
||||
const predictedFlow = this._readMeasurement("flow", "predicted", "downstream", FORMULA_UNITS.flow);
|
||||
const currentFlow = Number.isFinite(predictedFlow) ? predictedFlow : measuredFlow;
|
||||
|
||||
const downstreamP = this.measurements.type("pressure").variant("measured").position("downstream").getCurrentValue(); // haal de downstream pressure op uit de measurement container
|
||||
//const valveSize = 125; //NOTE: nu nog hardcoded maar moet een attribute van de valve worden
|
||||
this.predictKv.fDimension = 125; //load valve size by defining fdimension in predict class
|
||||
const downstreamP = this._readMeasurement("pressure", "measured", "downstream", FORMULA_UNITS.pressure);
|
||||
const x = currentPosition; // dit is de positie van de klep waarvoor we delta P willen berekenen
|
||||
const y = this.predictKv.y(x); // haal de waarde van kv op uit de spline
|
||||
const y = this._predictKvForPosition(x); // haal de waarde van kv op uit de supplierscurve
|
||||
|
||||
this.kv = y; //update de kv waarde in de valve class
|
||||
if (this.kv < 0.1){
|
||||
this.kv = 0.1; //minimum waarde voor kv
|
||||
}
|
||||
this.logger.debug(`Kv value for position valve ${x} is ${this.kv}`); // log de waarde van kv
|
||||
|
||||
this.updateDeltaPKlep(currentFlow,this.kv,downstreamP,this.rho,this.T); //update deltaP
|
||||
@@ -335,22 +829,46 @@ class Valve {
|
||||
}
|
||||
}
|
||||
|
||||
showCurve() {
|
||||
return {
|
||||
model: this.model || null,
|
||||
serviceType: this.serviceType,
|
||||
expectedServiceType: this.expectedServiceType,
|
||||
gasChokedRatioLimit: this.hydraulicModel?.gasChokedRatioLimit,
|
||||
selectedDensity: this.curveSelection?.densityKey ?? null,
|
||||
selectedDiameter: this.curveSelection?.diameterKey ?? null,
|
||||
curve: this.predictKv?.currentFxyCurve?.[this.predictKv?.fDimension] || null,
|
||||
hydraulics: this.hydraulicDiagnostics || null,
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._onPositionChange && this.state?.emitter?.off) {
|
||||
this.state.emitter.off("positionChange", this._onPositionChange);
|
||||
}
|
||||
for (const { emitter, handler } of this._fluidContractListeners.values()) {
|
||||
if (typeof emitter?.off === 'function') {
|
||||
emitter.off('fluidContractChange', handler);
|
||||
} else if (typeof emitter?.removeListener === 'function') {
|
||||
emitter.removeListener('fluidContractChange', handler);
|
||||
}
|
||||
}
|
||||
this._fluidContractListeners.clear();
|
||||
}
|
||||
|
||||
getOutput() {
|
||||
|
||||
// Improved output object generation
|
||||
const output = {};
|
||||
//build the output object
|
||||
this.measurements.getTypes().forEach(type => {
|
||||
this.measurements.getVariants().forEach(variant => {
|
||||
this.measurements.getPositions().forEach(position => {
|
||||
|
||||
const value = this.measurements.type(type).variant(variant).position(position).getCurrentValue(); //get the current value of the measurement
|
||||
|
||||
|
||||
if (value != null) {
|
||||
output[`${position}_${variant}_${type}`] = value;
|
||||
}
|
||||
});
|
||||
Object.entries(this.measurements.measurements || {}).forEach(([type, variants]) => {
|
||||
Object.entries(variants || {}).forEach(([variant, positions]) => {
|
||||
Object.keys(positions || {}).forEach((position) => {
|
||||
const value = this._readMeasurement(type, variant, position, this._outputUnitForType(type));
|
||||
if (value != null) {
|
||||
output[`${position}_${variant}_${type}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
57
test/basic/hydraulic-model.basic.test.js
Normal file
57
test/basic/hydraulic-model.basic.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { ValveHydraulicModel } = require('../../src/hydraulicModel');
|
||||
|
||||
test('hydraulic model gas branch keeps existing formula when not choked', () => {
|
||||
const model = new ValveHydraulicModel({ serviceType: 'gas', gasChokedRatioLimit: 0.9 });
|
||||
const result = model.calculateDeltaPMbar({
|
||||
qM3h: 36,
|
||||
kv: 10,
|
||||
downstreamGaugeMbar: 500,
|
||||
rho: 1.204,
|
||||
tempK: 293.15,
|
||||
});
|
||||
|
||||
const p2AbsBar = (500 / 1000) + 1.01325;
|
||||
const expectedDeltaPMbar = ((36 ** 2 * 1.204 * 293.15) / (514 ** 2 * 10 ** 2 * p2AbsBar)) * 1000;
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(Math.abs(result.deltaPMbar - expectedDeltaPMbar) < 0.05, `expected ${expectedDeltaPMbar}, got ${result.deltaPMbar}`);
|
||||
assert.equal(result.details.isChoked, false);
|
||||
});
|
||||
|
||||
test('hydraulic model gas branch applies choked-flow cap', () => {
|
||||
const model = new ValveHydraulicModel({ serviceType: 'gas', gasChokedRatioLimit: 0.2 });
|
||||
const result = model.calculateDeltaPMbar({
|
||||
qM3h: 1000,
|
||||
kv: 1,
|
||||
downstreamGaugeMbar: 500,
|
||||
rho: 1.204,
|
||||
tempK: 293.15,
|
||||
});
|
||||
|
||||
const p2AbsBar = (500 / 1000) + 1.01325;
|
||||
const expectedCappedDeltaPMbar = p2AbsBar * 0.2 * 1000;
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.details.isChoked, true);
|
||||
assert.ok(Math.abs(result.deltaPMbar - expectedCappedDeltaPMbar) < 0.0001, `expected ${expectedCappedDeltaPMbar}, got ${result.deltaPMbar}`);
|
||||
});
|
||||
|
||||
test('hydraulic model liquid branch uses liquid Kv equation', () => {
|
||||
const model = new ValveHydraulicModel({ serviceType: 'liquid' });
|
||||
const result = model.calculateDeltaPMbar({
|
||||
qM3h: 100,
|
||||
kv: 50,
|
||||
downstreamGaugeMbar: 500,
|
||||
rho: 998,
|
||||
tempK: 293.15,
|
||||
});
|
||||
|
||||
const expectedDeltaPMbar = (((100 / 50) ** 2) * (998 / 1000)) * 1000;
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.details.isChoked, false);
|
||||
assert.ok(Math.abs(result.deltaPMbar - expectedDeltaPMbar) < 0.0001, `expected ${expectedDeltaPMbar}, got ${result.deltaPMbar}`);
|
||||
});
|
||||
124
test/integration/fluid-compatibility.integration.test.js
Normal file
124
test/integration/fluid-compatibility.integration.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const Valve = require('../../src/specificClass');
|
||||
|
||||
function buildValve({ runtimeOptions = {} } = {}) {
|
||||
return new Valve(
|
||||
{
|
||||
general: {
|
||||
name: 'valve-fluid-test',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
},
|
||||
asset: {
|
||||
supplier: 'binder',
|
||||
category: 'valve',
|
||||
type: 'control',
|
||||
model: 'ECDV',
|
||||
unit: 'm3/h',
|
||||
},
|
||||
functionality: {
|
||||
positionVsParent: 'atEquipment',
|
||||
},
|
||||
},
|
||||
{
|
||||
general: {
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
},
|
||||
movement: { speed: 1 },
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
},
|
||||
runtimeOptions
|
||||
);
|
||||
}
|
||||
|
||||
function buildFluidSource({
|
||||
id,
|
||||
softwareType,
|
||||
serviceType = null,
|
||||
status = 'resolved',
|
||||
}) {
|
||||
const emitter = new EventEmitter();
|
||||
let contract = { status, serviceType };
|
||||
return {
|
||||
emitter,
|
||||
config: {
|
||||
general: { id, name: id },
|
||||
functionality: { softwareType },
|
||||
asset: {
|
||||
serviceType: serviceType || undefined,
|
||||
},
|
||||
},
|
||||
getFluidContract() {
|
||||
return { ...contract };
|
||||
},
|
||||
setFluidContract(next) {
|
||||
contract = { ...contract, ...next };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('valve flags mismatch for direct machine source with incompatible fluid', () => {
|
||||
const valve = buildValve({ runtimeOptions: { serviceType: 'gas' } });
|
||||
const source = buildFluidSource({
|
||||
id: 'machine-1',
|
||||
softwareType: 'machine',
|
||||
serviceType: 'liquid',
|
||||
});
|
||||
|
||||
assert.equal(valve.registerChild(source, 'machine'), true);
|
||||
const compatibility = valve.getFluidCompatibility();
|
||||
assert.equal(compatibility.status, 'mismatch');
|
||||
assert.equal(compatibility.expectedServiceType, 'gas');
|
||||
assert.equal(compatibility.receivedServiceType, 'liquid');
|
||||
|
||||
valve.destroy();
|
||||
});
|
||||
|
||||
test('valve flags conflict when grouped upstream sources expose mixed fluids', () => {
|
||||
const valve = buildValve();
|
||||
const machine = buildFluidSource({
|
||||
id: 'machine-1',
|
||||
softwareType: 'machine',
|
||||
serviceType: 'liquid',
|
||||
});
|
||||
const group = buildFluidSource({
|
||||
id: 'vgc-1',
|
||||
softwareType: 'valvegroupcontrol',
|
||||
serviceType: 'gas',
|
||||
});
|
||||
|
||||
assert.equal(valve.registerChild(machine, 'machine'), true);
|
||||
assert.equal(valve.registerChild(group, 'valvegroupcontrol'), true);
|
||||
|
||||
const compatibility = valve.getFluidCompatibility();
|
||||
assert.equal(compatibility.status, 'conflict');
|
||||
assert.deepEqual(new Set(compatibility.upstreamServiceTypes), new Set(['liquid', 'gas']));
|
||||
|
||||
valve.destroy();
|
||||
});
|
||||
|
||||
test('valve updates compatibility when upstream group fluid contract changes', async () => {
|
||||
const valve = buildValve({ runtimeOptions: { serviceType: 'gas' } });
|
||||
const group = buildFluidSource({
|
||||
id: 'vgc-1',
|
||||
softwareType: 'valvegroupcontrol',
|
||||
serviceType: 'gas',
|
||||
});
|
||||
|
||||
assert.equal(valve.registerChild(group, 'valvegroupcontrol'), true);
|
||||
assert.equal(valve.getFluidCompatibility().status, 'match');
|
||||
|
||||
group.setFluidContract({ serviceType: 'liquid' });
|
||||
group.emitter.emit('fluidContractChange');
|
||||
|
||||
// Event handlers run synchronously; await microtask for deterministic test sequencing.
|
||||
await Promise.resolve();
|
||||
|
||||
const compatibility = valve.getFluidCompatibility();
|
||||
assert.equal(compatibility.status, 'mismatch');
|
||||
assert.equal(compatibility.receivedServiceType, 'liquid');
|
||||
|
||||
valve.destroy();
|
||||
});
|
||||
117
test/integration/valve-physics-and-curve.integration.test.js
Normal file
117
test/integration/valve-physics-and-curve.integration.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Valve = require('../../src/specificClass');
|
||||
const supplierCurve = require('../../../generalFunctions/datasets/assetData/curves/ECDV.json');
|
||||
|
||||
function buildValve({ asset = {}, runtimeOptions = {} } = {}) {
|
||||
return new Valve(
|
||||
{
|
||||
general: {
|
||||
name: 'valve-test',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
},
|
||||
asset: {
|
||||
supplier: 'binder',
|
||||
category: 'valve',
|
||||
type: 'control',
|
||||
model: 'ECDV',
|
||||
unit: 'm3/h',
|
||||
...asset,
|
||||
},
|
||||
functionality: {
|
||||
positionVsParent: 'atEquipment',
|
||||
},
|
||||
},
|
||||
{
|
||||
general: {
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
},
|
||||
movement: { speed: 1 },
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
},
|
||||
runtimeOptions
|
||||
);
|
||||
}
|
||||
|
||||
test('valve selects supplier curve and predicts Kv from supplier data', () => {
|
||||
const valve = buildValve();
|
||||
|
||||
valve.updatePressure('measured', 500, 'downstream', 'mbar');
|
||||
valve.updateFlow('predicted', 100, 'downstream', 'm3/h');
|
||||
valve.state.movementManager.currentPosition = 50;
|
||||
valve.updatePosition();
|
||||
|
||||
const expectedKv = supplierCurve['1.204']['125'].y[5];
|
||||
assert.equal(valve.curveSelection.densityKey, 1.204);
|
||||
assert.equal(valve.curveSelection.diameterKey, 125);
|
||||
assert.ok(Math.abs(valve.kv - expectedKv) < 0.01, `expected Kv ${expectedKv}, got ${valve.kv}`);
|
||||
|
||||
valve.destroy();
|
||||
});
|
||||
|
||||
test('valve deltaP math uses converted flow units in formula path', () => {
|
||||
const valve = buildValve();
|
||||
|
||||
valve.kv = 10;
|
||||
valve.rho = 1.204;
|
||||
valve.T = 293.15;
|
||||
valve.updatePressure('measured', 500, 'downstream', 'mbar');
|
||||
|
||||
// 10 l/s equals 36 m3/h; formula path should use 36 m3/h.
|
||||
valve.updateFlow('predicted', 10, 'downstream', 'l/s');
|
||||
|
||||
const downstreamAbsBar = (500 / 1000) + 1.01325;
|
||||
const qM3h = 36;
|
||||
const expectedDeltaPMbar = ((qM3h ** 2 * valve.rho * valve.T) / (514 ** 2 * valve.kv ** 2 * downstreamAbsBar)) * 1000;
|
||||
const actualDeltaP = valve.measurements
|
||||
.type('pressure')
|
||||
.variant('predicted')
|
||||
.position('delta')
|
||||
.getCurrentValue('mbar');
|
||||
|
||||
assert.ok(Number.isFinite(actualDeltaP), 'deltaP should be finite');
|
||||
assert.ok(Math.abs(actualDeltaP - expectedDeltaPMbar) < 0.05, `expected ${expectedDeltaPMbar}, got ${actualDeltaP}`);
|
||||
|
||||
valve.destroy();
|
||||
});
|
||||
|
||||
test('valve liquid mode uses liquid Kv equation through update loop', () => {
|
||||
const valve = buildValve({ runtimeOptions: { serviceType: 'liquid', fluidDensity: 998 } });
|
||||
|
||||
valve.kv = 50;
|
||||
valve.updatePressure('measured', 500, 'downstream', 'mbar');
|
||||
valve.updateFlow('predicted', 100, 'downstream', 'm3/h');
|
||||
|
||||
const expectedDeltaPMbar = (((100 / 50) ** 2) * (998 / 1000)) * 1000;
|
||||
const actualDeltaP = valve.measurements
|
||||
.type('pressure')
|
||||
.variant('predicted')
|
||||
.position('delta')
|
||||
.getCurrentValue('mbar');
|
||||
|
||||
assert.ok(Math.abs(actualDeltaP - expectedDeltaPMbar) < 0.01, `expected ${expectedDeltaPMbar}, got ${actualDeltaP}`);
|
||||
valve.destroy();
|
||||
});
|
||||
|
||||
test('valve gas mode applies choked cap in update loop', () => {
|
||||
const valve = buildValve({ runtimeOptions: { serviceType: 'gas', gasChokedRatioLimit: 0.2 } });
|
||||
|
||||
valve.kv = 1;
|
||||
valve.rho = 1.204;
|
||||
valve.T = 293.15;
|
||||
valve.updatePressure('measured', 500, 'downstream', 'mbar');
|
||||
valve.updateFlow('predicted', 1000, 'downstream', 'm3/h');
|
||||
|
||||
const downstreamAbsBar = (500 / 1000) + 1.01325;
|
||||
const expectedDeltaPMbar = downstreamAbsBar * 0.2 * 1000;
|
||||
const actualDeltaP = valve.measurements
|
||||
.type('pressure')
|
||||
.variant('predicted')
|
||||
.position('delta')
|
||||
.getCurrentValue('mbar');
|
||||
|
||||
assert.ok(Math.abs(actualDeltaP - expectedDeltaPMbar) < 0.0001, `expected ${expectedDeltaPMbar}, got ${actualDeltaP}`);
|
||||
assert.equal(valve.hydraulicDiagnostics?.isChoked, true);
|
||||
valve.destroy();
|
||||
});
|
||||
@@ -52,13 +52,13 @@
|
||||
icon: "font-awesome/fa-toggle-on",
|
||||
|
||||
label: function () {
|
||||
return this.positionIcon + " " + this.category.slice(0, -1) || "Valve";
|
||||
return (this.positionIcon || "") + " " + (this.category ? this.category.slice(0, -1) : "Valve");
|
||||
},
|
||||
|
||||
oneditprepare: function() {
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.measurement?.initEditor) {
|
||||
window.EVOLV.nodes.measurement.initEditor(this);
|
||||
if (window.EVOLV?.nodes?.valve?.initEditor) {
|
||||
window.EVOLV.nodes.valve.initEditor(this);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
},
|
||||
oneditsave: function () {
|
||||
const node = this;
|
||||
let success = true;
|
||||
|
||||
// Validate asset properties using the asset menu
|
||||
if (window.EVOLV?.nodes?.valve?.assetMenu?.saveEditor) {
|
||||
@@ -95,6 +96,8 @@
|
||||
node[field] = value;
|
||||
});
|
||||
|
||||
return success;
|
||||
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user