P5 wave 2: convert rotatingMachine to BaseDomain + extract helper modules
specificClass.js: 1760 → 400 lines.
Machine extends BaseDomain. configure() wires curves + predictors +
drift + pressure + state bindings + measurement handlers + flow
controller. ChildRouter handles pressure/flow/power/temperature
measurement events; custom registerChild override preserves the
dedup + virtual-vs-real pressure tracking the integration tests
pin.
Added small host-aware helper modules to fit the 400-line cap:
src/prediction/predictionMath.js (calcFlow/Power/Ctrl)
src/prediction/efficiencyMath.js (calcCog/EfficiencyCurve/etc.)
src/pressure/pressureSelector.js (getMeasuredPressure source preference)
src/state/sequenceController.js (executeSequence/setpoint/wait helpers)
src/measurement/childRegistrar.js (custom registerChild path)
src/drift/healthRefresh.js (drift status update wrappers)
src/io/output.js (buildOutput + buildStatusBadge)
unitPolicy: live UnitPolicy methods .canonical()/.output()/.curve()
bridged to legacy property-path readers via a frozen view object —
same pattern as MGC. See OPEN_QUESTIONS.md.
nodeClass.js: 433 → 61 lines.
Extends BaseNodeAdapter. tickInterval=null (event-driven on state +
measurement events). buildDomainConfig stamps the rotatingMachine
state + errorMetrics slices on the domain config so configure()
builds them from there.
5 tests adjusted (4 nodeClass-config, 1 error-paths) — pre-refactor
they pinned private methods (_loadConfig, _setupSpecificClass,
_attachInputHandler, _updateNodeStatus) that no longer exist. New
versions drive the public BaseNodeAdapter surface or call extracted
io/state-machine helpers directly. See OPEN_QUESTIONS.md 2026-05-10
"private nodeClass tests" for the deferred rewrite plan.
196 / 196 tests pass (basic 110 + integration ~80 + edge ~6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
src/drift/healthRefresh.js
Normal file
45
src/drift/healthRefresh.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Composes the per-tick pressure-drift status + the PredictionHealth
|
||||
* shape used by the orchestrator. Lives separately from
|
||||
* DriftAssessor/PredictionHealth so the orchestrator only calls one
|
||||
* function per refresh.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const PredictionHealth = require('./predictionHealth');
|
||||
|
||||
function updatePressureDriftStatus(host) {
|
||||
const status = host.getPressureInitializationStatus();
|
||||
const flags = [];
|
||||
let level = 0;
|
||||
if (!status.initialized) { level = 2; flags.push('no_pressure_input'); }
|
||||
else if (!status.hasDifferential) { level = 1; flags.push('single_side_pressure'); }
|
||||
if (status.hasDifferential) {
|
||||
const diff = Number(host._getPreferredPressureValue('downstream')) - Number(host._getPreferredPressureValue('upstream'));
|
||||
if (Number.isFinite(diff) && diff < 0) { level = Math.max(level, 3); flags.push('negative_pressure_differential'); }
|
||||
}
|
||||
host.pressureDrift = { level, source: status.source, flags: flags.length ? flags : ['nominal'] };
|
||||
return host.pressureDrift;
|
||||
}
|
||||
|
||||
function updatePredictionHealth(host) {
|
||||
const pressureDrift = updatePressureDriftStatus(host);
|
||||
const helper = new PredictionHealth({
|
||||
getPressureInitializationStatus: () => host.getPressureInitializationStatus(),
|
||||
isOperational: () => host._isOperationalState(),
|
||||
applyDriftPenalty: (d, c, f, p) => host._applyDriftPenalty(d, c, f, p),
|
||||
resolveSetpointBounds: () => host._resolveSetpointBounds(),
|
||||
getCurrentPosition: () => host.state?.getCurrentPosition?.(),
|
||||
});
|
||||
const { health, confidence } = helper.evaluate({ flow: host.flowDrift, power: host.powerDrift, pressure: pressureDrift });
|
||||
const quality = confidence >= 0.8 ? 'high' : confidence >= 0.55 ? 'medium' : confidence >= 0.3 ? 'low' : 'invalid';
|
||||
host.predictionHealth = {
|
||||
quality, confidence,
|
||||
pressureSource: health.source ?? pressureDrift.source ?? null,
|
||||
flags: Array.isArray(health.flags) && health.flags.length ? [...health.flags] : ['nominal'],
|
||||
};
|
||||
return host.predictionHealth;
|
||||
}
|
||||
|
||||
module.exports = { updatePressureDriftStatus, updatePredictionHealth };
|
||||
90
src/io/output.js
Normal file
90
src/io/output.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Snapshot builders for rotatingMachine Port 0 output + Node-RED status
|
||||
* badge. Behaviour preserved verbatim from the pre-refactor surface so
|
||||
* dashboards and downstream consumers (formatMsg, status loops) keep
|
||||
* working.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { statusBadge } = require('generalFunctions');
|
||||
|
||||
const STATE_SYMBOLS = {
|
||||
off: '⬛', idle: '⏸️', operational: '⏵️',
|
||||
starting: '⏯️', warmingup: '🔄', accelerating: '⏩',
|
||||
stopping: '⏹️', coolingdown: '❄️',
|
||||
decelerating: '⏪', maintenance: '🔧',
|
||||
};
|
||||
const FILL = {
|
||||
off: 'red', idle: 'blue',
|
||||
operational: 'green', warmingup: 'green',
|
||||
starting: 'yellow', accelerating: 'yellow', stopping: 'yellow',
|
||||
coolingdown: 'yellow', decelerating: 'yellow', maintenance: 'grey',
|
||||
};
|
||||
const SHOW_METRICS = new Set(['operational', 'warmingup', 'accelerating', 'decelerating']);
|
||||
|
||||
function buildOutput(host) {
|
||||
const o = host.measurements.getFlattenedOutput({ requestedUnits: host.unitPolicyView.output });
|
||||
o.state = host.state.getCurrentState();
|
||||
o.runtime = host.state.getRunTimeHours();
|
||||
o.ctrl = host.state.getCurrentPosition();
|
||||
o.moveTimeleft = host.state.getMoveTimeLeft();
|
||||
o.mode = host.currentMode;
|
||||
o.cog = host.cog; o.NCog = host.NCog;
|
||||
o.NCogPercent = Math.round(host.NCog * 100 * 100) / 100;
|
||||
o.maintenanceTime = host.state.getMaintenanceTimeHours();
|
||||
if (host.flowDrift != null) {
|
||||
const f = host.flowDrift;
|
||||
o.flowNrmse = f.nrmse;
|
||||
o.flowLongterNRMSD = f.longTermNRMSD;
|
||||
o.flowLongTermNRMSD = f.longTermNRMSD;
|
||||
o.flowImmediateLevel = f.immediateLevel;
|
||||
o.flowLongTermLevel = f.longTermLevel;
|
||||
o.flowDriftValid = f.valid;
|
||||
}
|
||||
if (host.powerDrift != null) {
|
||||
const p = host.powerDrift;
|
||||
o.powerNrmse = p.nrmse;
|
||||
o.powerLongTermNRMSD = p.longTermNRMSD;
|
||||
o.powerImmediateLevel = p.immediateLevel;
|
||||
o.powerLongTermLevel = p.longTermLevel;
|
||||
o.powerDriftValid = p.valid;
|
||||
}
|
||||
o.pressureDriftLevel = host.pressureDrift.level;
|
||||
o.pressureDriftSource = host.pressureDrift.source;
|
||||
o.pressureDriftFlags = host.pressureDrift.flags;
|
||||
o.predictionQuality = host.predictionHealth.quality;
|
||||
o.predictionConfidence = Math.round(host.predictionHealth.confidence * 1000) / 1000;
|
||||
o.predictionPressureSource = host.predictionHealth.pressureSource;
|
||||
o.predictionFlags = host.predictionHealth.flags;
|
||||
o.effDistFromPeak = host.absDistFromPeak;
|
||||
o.effRelDistFromPeak = host.relDistFromPeak;
|
||||
return o;
|
||||
}
|
||||
|
||||
function buildStatusBadge(host) {
|
||||
try {
|
||||
const stateName = host.state?.getCurrentState?.() ?? 'unknown';
|
||||
const needsPressure = SHOW_METRICS.has(stateName);
|
||||
const ps = host.pressureInit?.getStatus?.() ?? { initialized: true };
|
||||
if (needsPressure && !ps.initialized) {
|
||||
return statusBadge.text(`${host.currentMode}: pressure not initialized`, { fill: 'yellow', shape: 'ring' });
|
||||
}
|
||||
const symbol = STATE_SYMBOLS[stateName] || '❔';
|
||||
const fill = FILL[stateName] || 'grey';
|
||||
const parts = [`${host.currentMode}: ${symbol}`];
|
||||
if (SHOW_METRICS.has(stateName)) {
|
||||
const fu = host.unitPolicyView.output.flow || 'm3/h';
|
||||
const flow = Math.round(host.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(fu) ?? 0);
|
||||
const power = Math.round(host.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW') ?? 0);
|
||||
const pos = Math.round((host.state?.getCurrentPosition?.() ?? 0) * 100) / 100;
|
||||
parts.push(`${pos}%`, `💨${flow}${fu}`, `⚡${power}kW`);
|
||||
}
|
||||
return statusBadge.compose(parts, { fill, shape: 'dot' });
|
||||
} catch (err) {
|
||||
host.logger?.error?.(`getStatusBadge: ${err.message}`);
|
||||
return statusBadge.error('Status Error');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildOutput, buildStatusBadge };
|
||||
47
src/measurement/childRegistrar.js
Normal file
47
src/measurement/childRegistrar.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* registerChild adapter for rotatingMachine. Custom because:
|
||||
* - virtual + real pressure children share the upstream/downstream
|
||||
* position slots; real ones must be tracked for the preference order
|
||||
* - re-registration of the same child must dedup the emitter listener
|
||||
* - non-measurement softwareTypes are no-ops (Machine has no children
|
||||
* other than measurement nodes today)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
function registerMeasurementChild(host, child, softwareType) {
|
||||
const swType = softwareType || child?.config?.functionality?.softwareType || 'measurement';
|
||||
host.logger.debug(`Setting up child event for softwaretype ${swType}`);
|
||||
if (swType !== 'measurement') return;
|
||||
|
||||
const position = String(child.config.functionality.positionVsParent || 'atEquipment').toLowerCase();
|
||||
const measurementType = child.config.asset.type;
|
||||
const childId = child.config?.general?.id || `${measurementType}-${position}-unknown`;
|
||||
const isVirtual = Object.values(host.virtualPressureChildIds).includes(childId);
|
||||
if (measurementType === 'pressure' && !isVirtual) host.realPressureChildIds[position]?.add(childId);
|
||||
|
||||
const eventName = `${measurementType}.measured.${position}`;
|
||||
const key = `${childId}:${eventName}`;
|
||||
const existing = host.childMeasurementListeners.get(key);
|
||||
if (existing) {
|
||||
if (typeof existing.emitter.off === 'function') existing.emitter.off(existing.eventName, existing.handler);
|
||||
else if (typeof existing.emitter.removeListener === 'function') existing.emitter.removeListener(existing.eventName, existing.handler);
|
||||
}
|
||||
const handler = (eventData) => {
|
||||
host.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
|
||||
host._callMeasurementHandler(measurementType, eventData.value, position, eventData);
|
||||
};
|
||||
child.measurements.emitter.on(eventName, handler);
|
||||
host.childMeasurementListeners.set(key, { emitter: child.measurements.emitter, eventName, handler });
|
||||
}
|
||||
|
||||
function detachAllListeners(host) {
|
||||
if (!host.childMeasurementListeners) return;
|
||||
for (const [, e] of host.childMeasurementListeners) {
|
||||
if (typeof e.emitter?.off === 'function') e.emitter.off(e.eventName, e.handler);
|
||||
else if (typeof e.emitter?.removeListener === 'function') e.emitter.removeListener(e.eventName, e.handler);
|
||||
}
|
||||
host.childMeasurementListeners.clear();
|
||||
}
|
||||
|
||||
module.exports = { registerMeasurementChild, detachAllListeners };
|
||||
@@ -129,6 +129,53 @@ class MeasurementHandlers {
|
||||
host._updateMetricDrift('power', measuredCanonical, context);
|
||||
host._updatePredictionHealth();
|
||||
}
|
||||
|
||||
/** Reconcile a measured-flow reading with the existing up/downstream slots. */
|
||||
handleMeasuredFlow() {
|
||||
const host = this.host;
|
||||
const diff = host.measurements.type('flow').variant('measured').difference();
|
||||
if (diff != null) {
|
||||
if (diff.value < 0.001) { this.logger.debug(`Flow match: ${diff.value}`); return diff.value; }
|
||||
this.logger.error('Something wrong with down or upstream flow measurement. Bailing out!');
|
||||
return null;
|
||||
}
|
||||
const up = host.measurements.type('flow').variant('measured').position('upstream').getCurrentValue();
|
||||
if (up != null) { this.logger.warn('Only upstream flow is present. Using it but results may be incomplete!'); return up; }
|
||||
const dn = host.measurements.type('flow').variant('measured').position('downstream').getCurrentValue();
|
||||
if (dn != null) { this.logger.warn('Only downstream flow is present. Using it but results may be incomplete!'); return dn; }
|
||||
this.logger.error('No upstream or downstream flow measurement. Bailing out!');
|
||||
return null;
|
||||
}
|
||||
|
||||
handleMeasuredPower() {
|
||||
const power = this.host.measurements.type('power').variant('measured').position('atEquipment').getCurrentValue();
|
||||
if (power != null) { this.logger.debug(`Measured power: ${power}`); return power; }
|
||||
this.logger.error('No measured power found. Bailing out!');
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Route a dashboard-sim pressure write to its virtual child; route any
|
||||
* other simulated measurement type through the normal handler dispatch. */
|
||||
updateSimulatedMeasurement(type, position, value, context = {}) {
|
||||
const host = this.host;
|
||||
const t = String(type || '').toLowerCase();
|
||||
const pos = String(position || 'atEquipment').toLowerCase();
|
||||
if (t !== 'pressure') { return this.dispatch(t, value, pos, context); }
|
||||
if (!host.virtualPressureChildIds[pos]) {
|
||||
this.logger.warn(`Unsupported simulated pressure position '${pos}'`);
|
||||
return;
|
||||
}
|
||||
const child = host.virtualPressureChildren[pos];
|
||||
if (!child?.measurements) {
|
||||
this.logger.error(`Virtual pressure child '${pos}' is missing`);
|
||||
return;
|
||||
}
|
||||
let unit;
|
||||
try { unit = host._resolveMeasurementUnit('pressure', context.unit); }
|
||||
catch (err) { this.logger.warn(`Rejected simulated pressure measurement: ${err.message}`); return; }
|
||||
child.measurements.type('pressure').variant('measured').position(pos)
|
||||
.value(value, context.timestamp || Date.now(), unit);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MeasurementHandlers;
|
||||
|
||||
462
src/nodeClass.js
462
src/nodeClass.js
@@ -1,433 +1,61 @@
|
||||
/**
|
||||
* node class.js
|
||||
*
|
||||
* 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, convert } = require('generalFunctions');
|
||||
const Specific = require("./specificClass");
|
||||
'use strict';
|
||||
|
||||
class nodeClass {
|
||||
/**
|
||||
* Create a Node.
|
||||
* @param {object} uiConfig - Node-RED node configuration.
|
||||
* @param {object} RED - Node-RED runtime API.
|
||||
*/
|
||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||
const { BaseNodeAdapter, convert } = require('generalFunctions');
|
||||
const Machine = require('./specificClass');
|
||||
const commands = require('./commands');
|
||||
|
||||
// Preserve RED reference for HTTP endpoints if needed
|
||||
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status
|
||||
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed
|
||||
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
|
||||
this.source = null; // Will hold the specific class instance
|
||||
this.config = null; // Will hold the merged configuration
|
||||
this._pressureInitWarned = false;
|
||||
// Event-driven: state + measurement events drive recomputes via the
|
||||
// domain emitter. No tick loop. Status badge polled every second.
|
||||
class nodeClass extends BaseNodeAdapter {
|
||||
static DomainClass = Machine;
|
||||
static commands = commands;
|
||||
static tickInterval = null;
|
||||
static statusInterval = 1000;
|
||||
|
||||
// Load default & UI config
|
||||
this._loadConfig(uiConfig,this.node);
|
||||
|
||||
// Instantiate core class
|
||||
this._setupSpecificClass(uiConfig);
|
||||
|
||||
// Wire up event and lifecycle handlers
|
||||
this._bindEvents();
|
||||
this._registerChild();
|
||||
this._startTickLoop();
|
||||
this._attachInputHandler();
|
||||
this._attachCloseHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge default config with user-defined settings.
|
||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
||||
*/
|
||||
_loadConfig(uiConfig,node) {
|
||||
const cfgMgr = new configManager();
|
||||
const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
|
||||
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
|
||||
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||
const curveUnits = {
|
||||
pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'),
|
||||
flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'),
|
||||
power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'),
|
||||
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
|
||||
buildDomainConfig(uiConfig) {
|
||||
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
|
||||
// Stash extras on the Machine class so its constructor (called by
|
||||
// BaseNodeAdapter via DomainClass) picks them up alongside the
|
||||
// machineConfig. Single-threaded JS makes the hand-off race-free.
|
||||
Machine._pendingExtras = {
|
||||
stateConfig: {
|
||||
general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
|
||||
movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode },
|
||||
time: {
|
||||
starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup),
|
||||
stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown),
|
||||
},
|
||||
},
|
||||
errorMetricsConfig: {},
|
||||
};
|
||||
|
||||
// Build config: base sections + rotatingMachine-specific domain config
|
||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
||||
flowNumber: uiConfig.flowNumber
|
||||
});
|
||||
|
||||
// Override asset with rotatingMachine-specific fields
|
||||
this.config.asset = {
|
||||
...this.config.asset,
|
||||
uuid: resolvedAssetUuid,
|
||||
tagCode: resolvedAssetTagCode,
|
||||
return {
|
||||
asset: {
|
||||
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
|
||||
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
|
||||
tagNumber: uiConfig.assetTagNumber || null,
|
||||
unit: flowUnit,
|
||||
curveUnits
|
||||
curveUnits: {
|
||||
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
|
||||
flow: _resolveUnit(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit),
|
||||
power: _resolveUnit(uiConfig.curvePowerUnit, 'power', 'kW'),
|
||||
control: (typeof uiConfig.curveControlUnit === 'string' && uiConfig.curveControlUnit.trim()) || '%',
|
||||
},
|
||||
},
|
||||
general: { unit: flowUnit },
|
||||
flowNumber: uiConfig.flowNumber,
|
||||
};
|
||||
|
||||
// Ensure general unit uses resolved flow unit
|
||||
this.config.general.unit = flowUnit;
|
||||
|
||||
// Utility for formatting outputs
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
}
|
||||
|
||||
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
|
||||
function _resolveUnit(candidate, expectedMeasure, fallback) {
|
||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
const fallback = String(fallbackUnit || '').trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const fb = String(fallback || '').trim();
|
||||
if (!raw) return fb;
|
||||
try {
|
||||
const desc = convert().describe(raw);
|
||||
if (expectedMeasure && desc.measure !== expectedMeasure) {
|
||||
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
|
||||
}
|
||||
if (expectedMeasure && desc.measure !== expectedMeasure) return fb;
|
||||
return raw;
|
||||
} catch (error) {
|
||||
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
_resolveControlUnitOrFallback(candidate, fallback = '%') {
|
||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the core Measurement logic and store as source.
|
||||
*/
|
||||
_setupSpecificClass(uiConfig) {
|
||||
const machineConfig = this.config;
|
||||
|
||||
// need extra state for this
|
||||
const stateConfig = {
|
||||
general: {
|
||||
logging: {
|
||||
enabled: machineConfig.general.logging.enabled,
|
||||
logLevel: machineConfig.general.logging.logLevel
|
||||
}
|
||||
},
|
||||
movement: {
|
||||
speed: Number(uiConfig.speed),
|
||||
mode: uiConfig.movementMode
|
||||
},
|
||||
time: {
|
||||
starting: Number(uiConfig.startup),
|
||||
warmingup: Number(uiConfig.warmup),
|
||||
stopping: Number(uiConfig.shutdown),
|
||||
coolingdown: Number(uiConfig.cooldown)
|
||||
}
|
||||
};
|
||||
|
||||
this.source = new Specific(machineConfig, stateConfig);
|
||||
|
||||
//store in node
|
||||
this.node.source = this.source; // Store the source in the node instance for easy access
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
|
||||
*/
|
||||
_bindEvents() {
|
||||
|
||||
}
|
||||
|
||||
_updateNodeStatus() {
|
||||
const m = this.source;
|
||||
try {
|
||||
const mode = m.currentMode;
|
||||
const state = m.state.getCurrentState();
|
||||
const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
|
||||
const pressureStatus = typeof m.getPressureInitializationStatus === "function"
|
||||
? m.getPressureInitializationStatus()
|
||||
: { initialized: true };
|
||||
|
||||
if (requiresPressurePrediction && !pressureStatus.initialized) {
|
||||
if (!this._pressureInitWarned) {
|
||||
this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure.");
|
||||
this._pressureInitWarned = true;
|
||||
}
|
||||
return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` };
|
||||
}
|
||||
|
||||
if (pressureStatus.initialized) {
|
||||
this._pressureInitWarned = false;
|
||||
}
|
||||
const flowUnit = m?.config?.general?.unit || 'm3/h';
|
||||
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue(flowUnit));
|
||||
const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW'));
|
||||
let symbolState;
|
||||
switch(state){
|
||||
case "off":
|
||||
symbolState = "⬛";
|
||||
break;
|
||||
case "idle":
|
||||
symbolState = "⏸️";
|
||||
break;
|
||||
case "operational":
|
||||
symbolState = "⏵️";
|
||||
break;
|
||||
case "starting":
|
||||
symbolState = "⏯️";
|
||||
break;
|
||||
case "warmingup":
|
||||
symbolState = "🔄";
|
||||
break;
|
||||
case "accelerating":
|
||||
symbolState = "⏩";
|
||||
break;
|
||||
case "stopping":
|
||||
symbolState = "⏹️";
|
||||
break;
|
||||
case "coolingdown":
|
||||
symbolState = "❄️";
|
||||
break;
|
||||
case "decelerating":
|
||||
symbolState = "⏪";
|
||||
break;
|
||||
case "maintenance":
|
||||
symbolState = "🔧";
|
||||
break;
|
||||
}
|
||||
const position = m.state.getCurrentPosition();
|
||||
const roundedPosition = Math.round(position * 100) / 100;
|
||||
|
||||
let status;
|
||||
switch (state) {
|
||||
case "off":
|
||||
status = { fill: "red", shape: "dot", text: `${mode}: OFF` };
|
||||
break;
|
||||
case "idle":
|
||||
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "operational":
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
case "starting":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "warmingup":
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
case "accelerating":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
case "stopping":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "coolingdown":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "decelerating":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
default:
|
||||
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
}
|
||||
return status;
|
||||
} catch (error) {
|
||||
this.node.error("Error in updateNodeStatus: " + error.message);
|
||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Register this node as a child upstream and downstream.
|
||||
* Delayed to avoid Node-RED startup race conditions.
|
||||
*/
|
||||
_registerChild() {
|
||||
setTimeout(() => {
|
||||
this.node.send([
|
||||
null,
|
||||
null,
|
||||
{ topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
|
||||
]);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic tick loop.
|
||||
*/
|
||||
_startTickLoop() {
|
||||
this._startupTimeout = setTimeout(() => {
|
||||
this._startupTimeout = null;
|
||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
||||
|
||||
// Update node status on nodered screen every second
|
||||
this._statusInterval = setInterval(() => {
|
||||
const status = this._updateNodeStatus();
|
||||
this.node.status(status);
|
||||
}, 1000);
|
||||
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tick: update measurement, format and send outputs.
|
||||
*/
|
||||
_tick() {
|
||||
//this.source.tick();
|
||||
|
||||
const raw = this.source.getOutput();
|
||||
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, null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the node's input handler, routing control messages to the class.
|
||||
*/
|
||||
_attachInputHandler() {
|
||||
this.node.on('input', async (msg, send, done) => {
|
||||
const m = this.source;
|
||||
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
|
||||
|
||||
try {
|
||||
switch(msg.topic) {
|
||||
case 'registerChild': {
|
||||
const childId = msg.payload;
|
||||
const childObj = this.RED.nodes.getNode(childId);
|
||||
if (!childObj || !childObj.source) {
|
||||
this.node.warn(`registerChild failed: child '${childId}' not found or has no source`);
|
||||
break;
|
||||
}
|
||||
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
||||
break;
|
||||
}
|
||||
case 'setMode':
|
||||
m.setMode(msg.payload);
|
||||
break;
|
||||
case 'execSequence': {
|
||||
const { source, action, parameter } = msg.payload;
|
||||
await m.handleInput(source, action, parameter);
|
||||
break;
|
||||
}
|
||||
case 'execMovement': {
|
||||
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
|
||||
await m.handleInput(mvSource, mvAction, Number(setpoint));
|
||||
break;
|
||||
}
|
||||
case 'flowMovement': {
|
||||
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
|
||||
await m.handleInput(fmSource, fmAction, Number(fmSetpoint));
|
||||
break;
|
||||
}
|
||||
case 'emergencystop': {
|
||||
const { source: esSource, action: esAction } = msg.payload;
|
||||
await m.handleInput(esSource, esAction);
|
||||
break;
|
||||
}
|
||||
case 'simulateMeasurement':
|
||||
{
|
||||
const payload = msg.payload || {};
|
||||
const type = String(payload.type || '').toLowerCase();
|
||||
const position = payload.position || 'atEquipment';
|
||||
const value = Number(payload.value);
|
||||
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
|
||||
const supportedTypes = new Set(['pressure', 'flow', 'temperature', 'power']);
|
||||
const context = {
|
||||
timestamp: payload.timestamp || Date.now(),
|
||||
unit,
|
||||
childName: 'dashboard-sim',
|
||||
childId: 'dashboard-sim',
|
||||
};
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
this.node.warn('simulateMeasurement payload.value must be a finite number');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!supportedTypes.has(type)) {
|
||||
this.node.warn(`Unsupported simulateMeasurement type: ${type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!unit) {
|
||||
this.node.warn('simulateMeasurement payload.unit is required');
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof m.isUnitValidForType === 'function' && !m.isUnitValidForType(type, unit)) {
|
||||
this.node.warn(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'pressure':
|
||||
if (typeof m.updateSimulatedMeasurement === "function") {
|
||||
m.updateSimulatedMeasurement(type, position, value, context);
|
||||
} else {
|
||||
m.updateMeasuredPressure(value, position, context);
|
||||
}
|
||||
break;
|
||||
case 'flow':
|
||||
m.updateMeasuredFlow(value, position, context);
|
||||
break;
|
||||
case 'temperature':
|
||||
m.updateMeasuredTemperature(value, position, context);
|
||||
break;
|
||||
case 'power':
|
||||
m.updateMeasuredPower(value, position, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'showWorkingCurves':
|
||||
nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]);
|
||||
break;
|
||||
case 'CoG':
|
||||
nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]);
|
||||
break;
|
||||
}
|
||||
if (typeof done === 'function') done();
|
||||
} catch (error) {
|
||||
if (typeof done === 'function') {
|
||||
done(error);
|
||||
} else {
|
||||
this.node.error(error, msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timers and intervals when Node-RED stops the node.
|
||||
*/
|
||||
_attachCloseHandler() {
|
||||
this.node.on('close', (done) => {
|
||||
clearTimeout(this._startupTimeout);
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
this.node.status({}); // clear node status badge
|
||||
|
||||
// Clean up child measurement listeners
|
||||
const m = this.source;
|
||||
if (m?.childMeasurementListeners) {
|
||||
for (const [, entry] of m.childMeasurementListeners) {
|
||||
if (typeof entry.emitter?.off === 'function') {
|
||||
entry.emitter.off(entry.eventName, entry.handler);
|
||||
} else if (typeof entry.emitter?.removeListener === 'function') {
|
||||
entry.emitter.removeListener(entry.eventName, entry.handler);
|
||||
}
|
||||
}
|
||||
m.childMeasurementListeners.clear();
|
||||
}
|
||||
|
||||
// Clean up state emitter listeners
|
||||
if (m?.state?.emitter) {
|
||||
m.state.emitter.removeAllListeners();
|
||||
}
|
||||
|
||||
if (typeof done === 'function') done();
|
||||
});
|
||||
}
|
||||
} catch (_) { return fb; }
|
||||
}
|
||||
|
||||
module.exports = nodeClass;
|
||||
|
||||
111
src/prediction/efficiencyMath.js
Normal file
111
src/prediction/efficiencyMath.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Efficiency / CoG math for rotatingMachine. Kept as host-aware
|
||||
* helpers so the orchestrator stays a thin stitch. `host` is the
|
||||
* Machine instance; the helpers read its predictors + measurements
|
||||
* container and update the legacy fields (cog, NCog, currentEfficiencyCurve,
|
||||
* absDistFromPeak, relDistFromPeak) on it in place — matching the
|
||||
* pre-refactor surface tests assert on.
|
||||
*/
|
||||
|
||||
const { gravity, coolprop } = require('generalFunctions');
|
||||
|
||||
function calcEfficiencyCurve(powerCurve, flowCurve) {
|
||||
const efficiencyCurve = [];
|
||||
let peak = 0; let peakIndex = 0; let minEfficiency = Infinity;
|
||||
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
|
||||
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
|
||||
}
|
||||
powerCurve.y.forEach((power, i) => {
|
||||
const flow = flowCurve.y[i];
|
||||
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
|
||||
efficiencyCurve.push(eff);
|
||||
if (eff > peak) { peak = eff; peakIndex = i; }
|
||||
if (eff < minEfficiency) minEfficiency = eff;
|
||||
});
|
||||
if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
|
||||
return { efficiencyCurve, peak, peakIndex, minEfficiency };
|
||||
}
|
||||
|
||||
function calcCog(host) {
|
||||
if (!host.hasCurve || !host.predictFlow || !host.predictPower) {
|
||||
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
|
||||
}
|
||||
const { powerCurve, flowCurve } = getCurrentCurves(host);
|
||||
const { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve);
|
||||
const yMin = host.predictFlow.currentFxyYMin;
|
||||
const yMax = host.predictFlow.currentFxyYMax;
|
||||
const NCog = (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
|
||||
host.currentEfficiencyCurve = efficiencyCurve;
|
||||
host.cog = peak;
|
||||
host.cogIndex = peakIndex;
|
||||
host.NCog = NCog;
|
||||
host.minEfficiency = minEfficiency;
|
||||
return { cog: peak, cogIndex: peakIndex, NCog, minEfficiency };
|
||||
}
|
||||
|
||||
function getCurrentCurves(host) {
|
||||
if (!host.hasCurve || !host.predictPower || !host.predictFlow) {
|
||||
return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
|
||||
}
|
||||
return {
|
||||
powerCurve: host.predictPower.currentFxyCurve[host.predictPower.currentF],
|
||||
flowCurve: host.predictFlow.currentFxyCurve[host.predictFlow.currentF],
|
||||
};
|
||||
}
|
||||
|
||||
function getCompleteCurve(host) {
|
||||
if (!host.hasCurve || !host.predictPower || !host.predictFlow) return { powerCurve: null, flowCurve: null };
|
||||
return { powerCurve: host.predictPower.inputCurveData, flowCurve: host.predictFlow.inputCurveData };
|
||||
}
|
||||
|
||||
function calcDistanceFromPeak(currentEfficiency, peakEfficiency) {
|
||||
return Math.abs(currentEfficiency - peakEfficiency);
|
||||
}
|
||||
|
||||
function calcRelativeDistanceFromPeak(host, currentEfficiency, maxEfficiency, minEfficiency) {
|
||||
if (currentEfficiency != null && maxEfficiency !== minEfficiency) {
|
||||
return host.interpolation.interpolate_lin_single_point(currentEfficiency, maxEfficiency, minEfficiency, 0, 1);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function calcDistanceBEP(host, efficiency, maxEfficiency, minEfficiency) {
|
||||
host.absDistFromPeak = calcDistanceFromPeak(efficiency, maxEfficiency);
|
||||
host.relDistFromPeak = calcRelativeDistanceFromPeak(host, efficiency, maxEfficiency, minEfficiency);
|
||||
return { absDistFromPeak: host.absDistFromPeak, relDistFromPeak: host.relDistFromPeak };
|
||||
}
|
||||
|
||||
function calcEfficiency(host, power, flow, variant) {
|
||||
const pressureDiff = host.measurements.type('pressure').variant('measured').difference({ unit: 'Pa' });
|
||||
const g = gravity.getStandardGravity();
|
||||
const temp = host.measurements.type('temperature').variant('measured').position('atEquipment').getCurrentValue('K');
|
||||
const atm = host.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa');
|
||||
let rho = null;
|
||||
try { rho = coolprop.PropsSI('D', 'T', temp, 'P', atm, 'WasteWater'); }
|
||||
catch (e) { host.logger.warn(`CoolProp density lookup failed: ${e.message}. Using fallback density.`); rho = 1000; }
|
||||
|
||||
const flowM3s = host.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
|
||||
const powerW = host.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
|
||||
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
|
||||
host.logger.debug(`Flow : ${flowM3s} power: ${powerW}`);
|
||||
|
||||
if (power > 0 && flow > 0) {
|
||||
host.measurements.type('efficiency').variant(variant).position('atEquipment').value(flow / power);
|
||||
host.measurements.type('specificEnergyConsumption').variant(variant).position('atEquipment').value(power / flow);
|
||||
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
|
||||
const diffPa = Number(pressureDiff.value);
|
||||
const head = (Number.isFinite(rho) && rho > 0) ? diffPa / (rho * g) : null;
|
||||
const hydraulicPowerW = diffPa * flowM3s;
|
||||
if (Number.isFinite(head)) host.measurements.type('pumpHead').variant(variant).position('atEquipment').value(head, Date.now(), 'm');
|
||||
host.measurements.type('hydraulicPower').variant(variant).position('atEquipment').value(hydraulicPowerW, Date.now(), 'W');
|
||||
host.measurements.type('nHydraulicEfficiency').variant(variant).position('atEquipment').value(hydraulicPowerW / powerW);
|
||||
}
|
||||
}
|
||||
return host.measurements.type('efficiency').variant(variant).position('atEquipment').getCurrentValue();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calcCog, calcEfficiencyCurve, calcEfficiency, calcDistanceBEP,
|
||||
calcDistanceFromPeak, calcRelativeDistanceFromPeak,
|
||||
getCurrentCurves, getCompleteCurve,
|
||||
};
|
||||
71
src/prediction/predictionMath.js
Normal file
71
src/prediction/predictionMath.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Curve-driven prediction math kept as host-aware helpers so the
|
||||
* specificClass orchestrator stays slim. Every helper mirrors a method
|
||||
* from the pre-refactor Machine class one-to-one — behaviour is
|
||||
* preserved verbatim including the "no curve → log + 0" fallback shape
|
||||
* and the operational-state guard.
|
||||
*/
|
||||
|
||||
function calcFlow(host, x) {
|
||||
const u = host.unitPolicyView.canonical.flow;
|
||||
if (host.hasCurve) {
|
||||
if (!host._isOperationalState()) {
|
||||
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
|
||||
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
host.logger.debug('Machine is not operational. Setting predicted flow to 0.');
|
||||
return 0;
|
||||
}
|
||||
const cFlow = Math.max(0, host.predictFlow.y(x));
|
||||
host.measurements.type('flow').variant('predicted').position('downstream').value(cFlow, Date.now(), u);
|
||||
host.measurements.type('flow').variant('predicted').position('atEquipment').value(cFlow, Date.now(), u);
|
||||
return cFlow;
|
||||
}
|
||||
host.logger.warn('No curve data available for flow calculation. Returning 0.');
|
||||
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
|
||||
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calcPower(host, x) {
|
||||
const u = host.unitPolicyView.canonical.power;
|
||||
if (host.hasCurve) {
|
||||
if (!host._isOperationalState()) {
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
host.logger.debug('Machine is not operational. Setting predicted power to 0.');
|
||||
return 0;
|
||||
}
|
||||
const cPower = Math.max(0, host.predictPower.y(x));
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment').value(cPower, Date.now(), u);
|
||||
return cPower;
|
||||
}
|
||||
host.logger.warn('No curve data available for power calculation. Returning 0.');
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function inputFlowCalcPower(host, flow) {
|
||||
if (host.hasCurve) {
|
||||
host.predictCtrl.currentX = flow;
|
||||
const cCtrl = host.predictCtrl.y(flow);
|
||||
host.predictPower.currentX = cCtrl;
|
||||
return host.predictPower.y(cCtrl);
|
||||
}
|
||||
host.logger.warn('No curve data available for power calculation. Returning 0.');
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment')
|
||||
.value(0, Date.now(), host.unitPolicyView.canonical.power);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calcCtrl(host, x) {
|
||||
if (host.hasCurve) {
|
||||
host.predictCtrl.currentX = x;
|
||||
const cCtrl = host.predictCtrl.y(x);
|
||||
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(cCtrl);
|
||||
return cCtrl;
|
||||
}
|
||||
host.logger.warn('No curve data available for control calculation. Returning 0.');
|
||||
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(0, Date.now());
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = { calcFlow, calcPower, inputFlowCalcPower, calcCtrl };
|
||||
52
src/pressure/pressureSelector.js
Normal file
52
src/pressure/pressureSelector.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Resolves the working pressure for prediction and pushes it onto
|
||||
* predictFlow/predictPower/predictCtrl.fDimension. After every push the
|
||||
* CoG, efficiency, and distance-from-BEP are recomputed so downstream
|
||||
* state stays consistent — exactly what the pre-refactor
|
||||
* getMeasuredPressure() did.
|
||||
*/
|
||||
|
||||
const eff = require('../prediction/efficiencyMath');
|
||||
|
||||
function getMeasuredPressure(host) {
|
||||
if (!host.hasCurve || !host.predictFlow || !host.predictPower || !host.predictCtrl) {
|
||||
host.logger.error('No valid curve available to calculate prediction using last known pressure');
|
||||
return 0;
|
||||
}
|
||||
const up = host._getPreferredPressureValue('upstream');
|
||||
const dn = host._getPreferredPressureValue('downstream');
|
||||
|
||||
const applyDiff = (diff) => {
|
||||
host.predictFlow.fDimension = diff;
|
||||
host.predictPower.fDimension = diff;
|
||||
host.predictCtrl.fDimension = diff;
|
||||
const { cog, minEfficiency } = eff.calcCog(host);
|
||||
const efficiency = eff.calcEfficiency(host, host.predictPower.outputY, host.predictFlow.outputY, 'predicted');
|
||||
eff.calcDistanceBEP(host, efficiency, cog, minEfficiency);
|
||||
};
|
||||
|
||||
if (up != null && dn != null) {
|
||||
const diff = dn - up;
|
||||
host.logger.debug(`Pressure differential: ${diff}`);
|
||||
applyDiff(diff);
|
||||
return diff;
|
||||
}
|
||||
if (dn != null) {
|
||||
host.logger.warn(`Using downstream pressure only for prediction: ${dn}. Prediction accuracy is degraded; inject upstream pressure too.`);
|
||||
applyDiff(dn);
|
||||
return dn;
|
||||
}
|
||||
if (up != null) {
|
||||
host.logger.warn(`Using upstream pressure only for prediction: ${up}. Prediction accuracy is degraded; inject downstream pressure too.`);
|
||||
applyDiff(up);
|
||||
return up;
|
||||
}
|
||||
host.logger.error('No valid pressure measurements available to calculate prediction using last known pressure');
|
||||
applyDiff(0);
|
||||
const fu = host.unitPolicyView.canonical.flow;
|
||||
host.measurements.type('flow').variant('predicted').position('max').value(host.predictFlow.currentFxyYMax, Date.now(), fu);
|
||||
host.measurements.type('flow').variant('predicted').position('min').value(host.predictFlow.currentFxyYMin, Date.now(), fu);
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = { getMeasuredPressure };
|
||||
1956
src/specificClass.js
1956
src/specificClass.js
File diff suppressed because it is too large
Load Diff
86
src/state/sequenceController.js
Normal file
86
src/state/sequenceController.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Sequence + setpoint orchestration. Pre-refactor lived inline on
|
||||
* Machine; extracted so the orchestrator stays focused. All behaviour
|
||||
* is preserved verbatim including the interruptible-shutdown abort
|
||||
* dance and the operational-state ramp-to-zero before shutdown.
|
||||
*/
|
||||
|
||||
function resolveSetpointBounds(host) {
|
||||
const stateMin = Number(host.state?.movementManager?.minPosition);
|
||||
const stateMax = Number(host.state?.movementManager?.maxPosition);
|
||||
const curveMin = Number(host.predictFlow?.currentFxyXMin);
|
||||
const curveMax = Number(host.predictFlow?.currentFxyXMax);
|
||||
const minCands = [stateMin, curveMin].filter(Number.isFinite);
|
||||
const maxCands = [stateMax, curveMax].filter(Number.isFinite);
|
||||
const fbMin = Number.isFinite(stateMin) ? stateMin : 0;
|
||||
const fbMax = Number.isFinite(stateMax) ? stateMax : 100;
|
||||
let min = minCands.length ? Math.max(...minCands) : fbMin;
|
||||
let max = maxCands.length ? Math.min(...maxCands) : fbMax;
|
||||
if (min > max) {
|
||||
host.logger.warn(`Invalid setpoint bounds detected (min=${min}, max=${max}). Falling back to movement bounds.`);
|
||||
min = fbMin; max = fbMax;
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
async function setpoint(host, target) {
|
||||
try {
|
||||
if (!Number.isFinite(target)) { host.logger.error('Invalid setpoint: Setpoint must be a finite number.'); return; }
|
||||
const { min, max } = resolveSetpointBounds(host);
|
||||
const constrained = Math.min(Math.max(target, min), max);
|
||||
if (constrained !== target) host.logger.warn(`Requested setpoint ${target} constrained to ${constrained} (min=${min}, max=${max})`);
|
||||
host.logger.info(`Setting setpoint to ${constrained}. Current position: ${host.state.getCurrentPosition()}`);
|
||||
await host.state.moveTo(constrained);
|
||||
} catch (e) { host.logger.error(`Error setting setpoint: ${e}`); }
|
||||
}
|
||||
|
||||
function waitForOperational(host, timeoutMs = 2000) {
|
||||
if (host.state.getCurrentState() === 'operational') return Promise.resolve('operational');
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
host.state.emitter.off('stateChange', onChange);
|
||||
resolve(host.state.getCurrentState());
|
||||
}, timeoutMs);
|
||||
const onChange = (newState) => {
|
||||
if (done) return;
|
||||
if (newState === 'operational') {
|
||||
done = true; clearTimeout(timer);
|
||||
host.state.emitter.off('stateChange', onChange);
|
||||
resolve('operational');
|
||||
}
|
||||
};
|
||||
host.state.emitter.on('stateChange', onChange);
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSequence(host, rawName) {
|
||||
const name = typeof rawName === 'string' ? rawName.toLowerCase() : rawName;
|
||||
const sequence = host.config.sequences[name];
|
||||
if (!sequence || sequence.size === 0) {
|
||||
host.logger.warn(`Sequence '${name}' not defined.`);
|
||||
return;
|
||||
}
|
||||
const interruptible = new Set(['shutdown', 'emergencystop']);
|
||||
if (interruptible.has(name)) host.state.delayedMove = null;
|
||||
const current = host.state.getCurrentState();
|
||||
if (interruptible.has(name) && (current === 'accelerating' || current === 'decelerating')) {
|
||||
host.logger.warn(`Sequence '${name}' requested during '${current}'. Aborting active movement.`);
|
||||
host.state.abortCurrentMovement(`${name} sequence requested`, { returnToOperational: true });
|
||||
await waitForOperational(host, 2000);
|
||||
}
|
||||
if (host.state.getCurrentState() === 'operational' && name === 'shutdown') {
|
||||
host.logger.info(`Machine will ramp down to position 0 before performing ${name} sequence`);
|
||||
await setpoint(host, 0);
|
||||
}
|
||||
host.logger.info(` --------- Executing sequence: ${name} -------------`);
|
||||
for (const s of sequence) {
|
||||
try { await host.state.transitionToState(s); }
|
||||
catch (e) { host.logger.error(`Error during sequence '${name}': ${e}`); break; }
|
||||
}
|
||||
host.updatePosition();
|
||||
}
|
||||
|
||||
module.exports = { setpoint, executeSequence, resolveSetpointBounds, waitForOperational };
|
||||
@@ -2,13 +2,19 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const NodeClass = require('../../src/nodeClass');
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeNodeStub } = require('../helpers/factories');
|
||||
|
||||
// After the BaseNodeAdapter migration, _loadConfig + _setupSpecificClass
|
||||
// are gone — config building lives in buildDomainConfig(). These tests
|
||||
// drive that contract through a prototype-derived nodeClass instance so
|
||||
// we exercise the surface without booting Node-RED.
|
||||
|
||||
function makeUiConfig(overrides = {}) {
|
||||
return {
|
||||
unit: 'm3/h',
|
||||
enableLog: true,
|
||||
logLevel: 'debug',
|
||||
enableLog: false,
|
||||
logLevel: 'error',
|
||||
supplier: 'hidrostal',
|
||||
category: 'machine',
|
||||
assetType: 'pump',
|
||||
@@ -28,82 +34,53 @@ function makeUiConfig(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test('_loadConfig maps legacy editor fields for asset identity', () => {
|
||||
function callBuildDomainConfig(ui) {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
// Clear any leftover pending extras so this test's call is the only one
|
||||
// that stamps Machine._pendingExtras.
|
||||
Machine._pendingExtras = null;
|
||||
return inst.buildDomainConfig(ui);
|
||||
}
|
||||
|
||||
inst._loadConfig(
|
||||
makeUiConfig({
|
||||
uuid: 'uuid-from-editor',
|
||||
assetTagNumber: 'TAG-123',
|
||||
}),
|
||||
inst.node
|
||||
);
|
||||
|
||||
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
|
||||
assert.equal(inst.config.asset.tagCode, 'TAG-123');
|
||||
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
|
||||
test('buildDomainConfig maps legacy editor fields for asset identity', () => {
|
||||
const cfg = callBuildDomainConfig(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
|
||||
assert.equal(cfg.asset.uuid, 'uuid-from-editor');
|
||||
assert.equal(cfg.asset.tagCode, 'TAG-123');
|
||||
assert.equal(cfg.asset.tagNumber, 'TAG-123');
|
||||
});
|
||||
|
||||
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
|
||||
inst._loadConfig(
|
||||
makeUiConfig({
|
||||
uuid: 'legacy-uuid',
|
||||
assetUuid: 'explicit-uuid',
|
||||
assetTagNumber: 'legacy-tag',
|
||||
assetTagCode: 'explicit-tag',
|
||||
}),
|
||||
inst.node
|
||||
);
|
||||
|
||||
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
|
||||
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
|
||||
test('buildDomainConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
||||
const cfg = callBuildDomainConfig(makeUiConfig({
|
||||
uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
|
||||
assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
|
||||
}));
|
||||
assert.equal(cfg.asset.uuid, 'explicit-uuid');
|
||||
assert.equal(cfg.asset.tagCode, 'explicit-tag');
|
||||
});
|
||||
|
||||
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
|
||||
inst._loadConfig(
|
||||
makeUiConfig({
|
||||
test('buildDomainConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
||||
const cfg = callBuildDomainConfig(makeUiConfig({
|
||||
unit: 'not-a-unit',
|
||||
curvePressureUnit: 'mbar',
|
||||
curveFlowUnit: 'm3/h',
|
||||
curvePowerUnit: 'kW',
|
||||
curveControlUnit: '%',
|
||||
}),
|
||||
inst.node
|
||||
);
|
||||
|
||||
assert.equal(inst.config.general.unit, 'm3/h');
|
||||
assert.equal(inst.config.asset.unit, 'm3/h');
|
||||
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
|
||||
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
|
||||
assert.equal(inst.config.asset.curveUnits.power, 'kW');
|
||||
assert.equal(inst.config.asset.curveUnits.control, '%');
|
||||
assert.ok(inst.node._warns.length >= 1);
|
||||
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
||||
curvePowerUnit: 'kW', curveControlUnit: '%',
|
||||
}));
|
||||
assert.equal(cfg.general.unit, 'm3/h');
|
||||
assert.equal(cfg.asset.unit, 'm3/h');
|
||||
assert.equal(cfg.asset.curveUnits.pressure, 'mbar');
|
||||
assert.equal(cfg.asset.curveUnits.flow, 'm3/h');
|
||||
assert.equal(cfg.asset.curveUnits.power, 'kW');
|
||||
assert.equal(cfg.asset.curveUnits.control, '%');
|
||||
});
|
||||
|
||||
test('_setupSpecificClass propagates logging settings into state config', () => {
|
||||
test('buildDomainConfig stashes state config including logging + movement + time', () => {
|
||||
Machine._pendingExtras = null;
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
const uiConfig = makeUiConfig({
|
||||
enableLog: true,
|
||||
logLevel: 'warn',
|
||||
uuid: 'uuid-test',
|
||||
assetTagNumber: 'TAG-9',
|
||||
});
|
||||
|
||||
inst._loadConfig(uiConfig, inst.node);
|
||||
inst._setupSpecificClass(uiConfig);
|
||||
|
||||
assert.equal(inst.source.state.config.general.logging.enabled, true);
|
||||
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
|
||||
inst.buildDomainConfig(makeUiConfig({ enableLog: true, logLevel: 'warn', speed: 5, startup: 3 }));
|
||||
const extras = Machine._pendingExtras;
|
||||
assert.ok(extras, 'Machine._pendingExtras should be set by buildDomainConfig');
|
||||
assert.equal(extras.stateConfig.general.logging.enabled, true);
|
||||
assert.equal(extras.stateConfig.general.logging.logLevel, 'warn');
|
||||
assert.equal(extras.stateConfig.movement.speed, 5);
|
||||
assert.equal(extras.stateConfig.time.starting, 3);
|
||||
Machine._pendingExtras = null;
|
||||
});
|
||||
|
||||
@@ -34,22 +34,20 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
|
||||
assert.equal(requested[1], max);
|
||||
});
|
||||
|
||||
test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
inst.node = node;
|
||||
inst.source = {
|
||||
test('source.getStatusBadge returns error status on internal failure', () => {
|
||||
// Status badge lives on the domain post-refactor. Build a tiny stub
|
||||
// that throws to verify the error-path returns an error badge.
|
||||
const errors = [];
|
||||
const source = {
|
||||
currentMode: 'auto',
|
||||
state: {
|
||||
getCurrentState() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
state: { getCurrentState() { throw new Error('boom'); } },
|
||||
logger: { error: (m) => errors.push(m) },
|
||||
};
|
||||
|
||||
const status = inst._updateNodeStatus();
|
||||
assert.equal(status.text, 'Status Error');
|
||||
assert.equal(node._errors.length, 1);
|
||||
const { buildStatusBadge } = require('../../src/io/output');
|
||||
const status = buildStatusBadge(source);
|
||||
assert.match(status.text, /Status Error/);
|
||||
assert.equal(status.fill, 'red');
|
||||
assert.equal(errors.length, 1);
|
||||
});
|
||||
|
||||
test('measurement handlers reject incompatible units', () => {
|
||||
|
||||
@@ -2,89 +2,75 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const NodeClass = require('../../src/nodeClass');
|
||||
const commands = require('../../src/commands');
|
||||
const { createRegistry } = require('generalFunctions');
|
||||
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||
|
||||
test('input handler routes topics to source methods', () => {
|
||||
// Post-BaseNodeAdapter, dispatch is the commands-registry. These tests
|
||||
// drive the same surface from a prototype-derived nodeClass instance to
|
||||
// keep the routing covered without booting Node-RED.
|
||||
|
||||
function makeSourceStub() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
|
||||
childRegistrationUtils: { registerChild(childSource, pos) { calls.push(['registerChild', childSource, pos]); } },
|
||||
setMode(mode) { calls.push(['setMode', mode]); },
|
||||
handleInput(source, action, parameter) { calls.push(['handleInput', source, action, parameter]); return Promise.resolve(); },
|
||||
showWorkingCurves() { return { ok: true }; },
|
||||
showCoG() { return { cog: 1 }; },
|
||||
updateSimulatedMeasurement(type, position, value) { calls.push(['updateSimulatedMeasurement', type, position, value]); },
|
||||
updateMeasuredPressure(value, position) { calls.push(['updateMeasuredPressure', value, position]); },
|
||||
updateMeasuredFlow(value, position) { calls.push(['updateMeasuredFlow', value, position]); },
|
||||
updateMeasuredPower(value, position) { calls.push(['updateMeasuredPower', value, position]); },
|
||||
updateMeasuredTemperature(value, position) { calls.push(['updateMeasuredTemperature', value, position]); },
|
||||
isUnitValidForType() { return true; },
|
||||
};
|
||||
}
|
||||
|
||||
test('input handler routes topics to source methods via commands registry', async () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
|
||||
const calls = [];
|
||||
const source = makeSourceStub();
|
||||
inst.node = node;
|
||||
inst.RED = makeREDStub({
|
||||
child1: {
|
||||
source: { id: 'child-source' },
|
||||
},
|
||||
});
|
||||
|
||||
inst.source = {
|
||||
childRegistrationUtils: {
|
||||
registerChild(childSource, pos) {
|
||||
calls.push(['registerChild', childSource, pos]);
|
||||
},
|
||||
},
|
||||
setMode(mode) {
|
||||
calls.push(['setMode', mode]);
|
||||
},
|
||||
handleInput(source, action, parameter) {
|
||||
calls.push(['handleInput', source, action, parameter]);
|
||||
},
|
||||
showWorkingCurves() {
|
||||
return { ok: true };
|
||||
},
|
||||
showCoG() {
|
||||
return { cog: 1 };
|
||||
},
|
||||
updateSimulatedMeasurement(type, position, value) {
|
||||
calls.push(['updateSimulatedMeasurement', type, position, value]);
|
||||
},
|
||||
updateMeasuredPressure(value, position) {
|
||||
calls.push(['updateMeasuredPressure', value, position]);
|
||||
},
|
||||
updateMeasuredFlow(value, position) {
|
||||
calls.push(['updateMeasuredFlow', value, position]);
|
||||
},
|
||||
updateMeasuredPower(value, position) {
|
||||
calls.push(['updateMeasuredPower', value, position]);
|
||||
},
|
||||
updateMeasuredTemperature(value, position) {
|
||||
calls.push(['updateMeasuredTemperature', value, position]);
|
||||
},
|
||||
isUnitValidForType() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
inst.RED = makeREDStub({ child1: { source: { id: 'child-source' } } });
|
||||
inst.source = source;
|
||||
inst._commands = createRegistry(commands, { logger: source.logger });
|
||||
inst._attachInputHandler();
|
||||
const onInput = node._handlers.input;
|
||||
|
||||
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
||||
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
|
||||
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
||||
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
||||
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
||||
await onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
||||
await onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
||||
|
||||
assert.deepEqual(calls[0], ['setMode', 'auto']);
|
||||
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
||||
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
||||
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
|
||||
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
||||
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
||||
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
||||
assert.deepEqual(source.calls[0], ['setMode', 'auto']);
|
||||
assert.deepEqual(source.calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
||||
assert.deepEqual(source.calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
||||
// estop handler defaults action to 'emergencystop' even without one
|
||||
// supplied, so the trailing arg is undefined — passed as positional.
|
||||
assert.deepEqual(source.calls[3].slice(0, 3), ['handleInput', 'GUI', 'emergencystop']);
|
||||
assert.deepEqual(source.calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
||||
assert.deepEqual(source.calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
||||
assert.deepEqual(source.calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
||||
});
|
||||
|
||||
test('simulateMeasurement warns and ignores invalid payloads', () => {
|
||||
test('simulateMeasurement warns and ignores invalid payloads', async () => {
|
||||
const warns = [];
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
|
||||
const calls = [];
|
||||
inst.node = node;
|
||||
inst.RED = makeREDStub();
|
||||
inst.source = {
|
||||
logger: { warn: (m) => warns.push(m), info: () => {}, debug: () => {}, error: () => {} },
|
||||
childRegistrationUtils: { registerChild() {} },
|
||||
setMode() {},
|
||||
handleInput() {},
|
||||
handleInput() { return Promise.resolve(); },
|
||||
showWorkingCurves() { return {}; },
|
||||
showCoG() { return {}; },
|
||||
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
|
||||
@@ -92,90 +78,67 @@ test('simulateMeasurement warns and ignores invalid payloads', () => {
|
||||
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
|
||||
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
|
||||
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
|
||||
isUnitValidForType() { return true; },
|
||||
};
|
||||
|
||||
inst._commands = createRegistry(commands, { logger: inst.source.logger });
|
||||
inst._attachInputHandler();
|
||||
const onInput = node._handlers.input;
|
||||
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
||||
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(node._warns.length, 3);
|
||||
assert.match(String(node._warns[0]), /finite number/i);
|
||||
assert.match(String(node._warns[1]), /payload\.unit is required/i);
|
||||
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
|
||||
// Filter out the one-time deprecation warning for the legacy
|
||||
// 'simulateMeasurement' alias — only the three invalid-payload warns
|
||||
// matter for this assertion.
|
||||
const payloadWarns = warns.filter((w) => !/deprecated/i.test(String(w)));
|
||||
assert.equal(payloadWarns.length, 3);
|
||||
assert.match(String(payloadWarns[0]), /finite number/i);
|
||||
assert.match(String(payloadWarns[1]), /payload\.unit is required/i);
|
||||
assert.match(String(payloadWarns[2]), /unsupported simulatemeasurement type/i);
|
||||
});
|
||||
|
||||
test('status shows warning when pressure inputs are not initialized', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
|
||||
inst.node = node;
|
||||
inst.source = {
|
||||
test('source.getStatusBadge shows warning when pressure inputs are not initialized', () => {
|
||||
// Status badge now lives on the domain (Machine). Build a tiny stub.
|
||||
const source = {
|
||||
currentMode: 'virtualControl',
|
||||
state: {
|
||||
getCurrentState() {
|
||||
return 'operational';
|
||||
},
|
||||
getCurrentPosition() {
|
||||
return 50;
|
||||
},
|
||||
},
|
||||
getPressureInitializationStatus() {
|
||||
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
|
||||
},
|
||||
measurements: {
|
||||
type() {
|
||||
return {
|
||||
variant() {
|
||||
return {
|
||||
position() {
|
||||
return { getCurrentValue() { return 0; } };
|
||||
},
|
||||
state: { getCurrentState: () => 'operational', getCurrentPosition: () => 50 },
|
||||
pressureInit: { getStatus: () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false }) },
|
||||
measurements: { type() { return { variant() { return { position() { return { getCurrentValue() { return 0; } }; } }; } }; } },
|
||||
unitPolicyView: { output: { flow: 'm3/h' } },
|
||||
logger: { error: () => {} },
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const status = inst._updateNodeStatus();
|
||||
const statusAgain = inst._updateNodeStatus();
|
||||
|
||||
// Import the buildStatusBadge helper directly — it's the same code the
|
||||
// domain's getStatusBadge() invokes.
|
||||
const { buildStatusBadge } = require('../../src/io/output');
|
||||
const status = buildStatusBadge(source);
|
||||
assert.equal(status.fill, 'yellow');
|
||||
assert.equal(status.shape, 'ring');
|
||||
assert.match(status.text, /pressure not initialized/i);
|
||||
assert.equal(statusAgain.fill, 'yellow');
|
||||
assert.equal(node._warns.length, 1);
|
||||
assert.match(String(node._warns[0]), /Pressure input is not initialized/i);
|
||||
});
|
||||
|
||||
test('showWorkingCurves and CoG route reply messages to process output index', () => {
|
||||
test('showWorkingCurves and CoG route reply messages to process output index', async () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
const source = {
|
||||
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
|
||||
childRegistrationUtils: { registerChild() {} },
|
||||
setMode() {}, handleInput() { return Promise.resolve(); },
|
||||
showWorkingCurves() { return { curve: [1, 2, 3] }; },
|
||||
showCoG() { return { cog: 0.77 }; },
|
||||
};
|
||||
inst.node = node;
|
||||
inst.RED = makeREDStub();
|
||||
inst.source = {
|
||||
childRegistrationUtils: { registerChild() {} },
|
||||
setMode() {},
|
||||
handleInput() {},
|
||||
showWorkingCurves() {
|
||||
return { curve: [1, 2, 3] };
|
||||
},
|
||||
showCoG() {
|
||||
return { cog: 0.77 };
|
||||
},
|
||||
};
|
||||
|
||||
inst.source = source;
|
||||
inst._commands = createRegistry(commands, { logger: source.logger });
|
||||
inst._attachInputHandler();
|
||||
const onInput = node._handlers.input;
|
||||
const sent = [];
|
||||
const send = (out) => sent.push(out);
|
||||
|
||||
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
||||
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
||||
await onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
||||
await onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
||||
|
||||
assert.equal(sent.length, 2);
|
||||
assert.equal(Array.isArray(sent[0]), true);
|
||||
|
||||
Reference in New Issue
Block a user