This commit is contained in:
znetsixe
2026-03-11 11:13:17 +01:00
parent d56f8a382c
commit 6287708c1e
7 changed files with 1102 additions and 123 deletions

109
src/hydraulicModel.js Normal file
View 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,
};

View File

@@ -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();
});
}

View File

@@ -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;
}
});
});
});

View 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}`);
});

View 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();
});

View 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();
});

View File

@@ -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>