diff --git a/src/hydraulicModel.js b/src/hydraulicModel.js new file mode 100644 index 0000000..b52f962 --- /dev/null +++ b/src/hydraulicModel.js @@ -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, +}; diff --git a/src/nodeClass.js b/src/nodeClass.js index 21348ea..8e7da76 100644 --- a/src/nodeClass.js +++ b/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(); }); } diff --git a/src/specificClass.js b/src/specificClass.js index 84f245c..8fe1cd2 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -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; + } + }); }); }); diff --git a/test/basic/hydraulic-model.basic.test.js b/test/basic/hydraulic-model.basic.test.js new file mode 100644 index 0000000..f5c4f78 --- /dev/null +++ b/test/basic/hydraulic-model.basic.test.js @@ -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}`); +}); diff --git a/test/integration/fluid-compatibility.integration.test.js b/test/integration/fluid-compatibility.integration.test.js new file mode 100644 index 0000000..42d1dd4 --- /dev/null +++ b/test/integration/fluid-compatibility.integration.test.js @@ -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(); +}); diff --git a/test/integration/valve-physics-and-curve.integration.test.js b/test/integration/valve-physics-and-curve.integration.test.js new file mode 100644 index 0000000..85a3323 --- /dev/null +++ b/test/integration/valve-physics-and-curve.integration.test.js @@ -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(); +}); diff --git a/valve.html b/valve.html index 27760f9..8804239 100644 --- a/valve.html +++ b/valve.html @@ -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; + }, });