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._updateMetricDrift('power', measuredCanonical, context);
|
||||||
host._updatePredictionHealth();
|
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;
|
module.exports = MeasurementHandlers;
|
||||||
|
|||||||
478
src/nodeClass.js
478
src/nodeClass.js
@@ -1,433 +1,61 @@
|
|||||||
/**
|
'use strict';
|
||||||
* 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");
|
|
||||||
|
|
||||||
class nodeClass {
|
const { BaseNodeAdapter, convert } = require('generalFunctions');
|
||||||
/**
|
const Machine = require('./specificClass');
|
||||||
* Create a Node.
|
const commands = require('./commands');
|
||||||
* @param {object} uiConfig - Node-RED node configuration.
|
|
||||||
* @param {object} RED - Node-RED runtime API.
|
|
||||||
*/
|
|
||||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
|
||||||
|
|
||||||
// Preserve RED reference for HTTP endpoints if needed
|
// Event-driven: state + measurement events drive recomputes via the
|
||||||
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status
|
// domain emitter. No tick loop. Status badge polled every second.
|
||||||
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed
|
class nodeClass extends BaseNodeAdapter {
|
||||||
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
|
static DomainClass = Machine;
|
||||||
this.source = null; // Will hold the specific class instance
|
static commands = commands;
|
||||||
this.config = null; // Will hold the merged configuration
|
static tickInterval = null;
|
||||||
this._pressureInitWarned = false;
|
static statusInterval = 1000;
|
||||||
|
|
||||||
// Load default & UI config
|
buildDomainConfig(uiConfig) {
|
||||||
this._loadConfig(uiConfig,this.node);
|
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
|
||||||
|
// Stash extras on the Machine class so its constructor (called by
|
||||||
// Instantiate core class
|
// BaseNodeAdapter via DomainClass) picks them up alongside the
|
||||||
this._setupSpecificClass(uiConfig);
|
// machineConfig. Single-threaded JS makes the hand-off race-free.
|
||||||
|
Machine._pendingExtras = {
|
||||||
// Wire up event and lifecycle handlers
|
stateConfig: {
|
||||||
this._bindEvents();
|
general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
|
||||||
this._registerChild();
|
movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode },
|
||||||
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, '%'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
tagNumber: uiConfig.assetTagNumber || null,
|
|
||||||
unit: flowUnit,
|
|
||||||
curveUnits
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_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: {
|
time: {
|
||||||
starting: Number(uiConfig.startup),
|
starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup),
|
||||||
warmingup: Number(uiConfig.warmup),
|
stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown),
|
||||||
stopping: Number(uiConfig.shutdown),
|
},
|
||||||
coolingdown: Number(uiConfig.cooldown)
|
},
|
||||||
}
|
errorMetricsConfig: {},
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
this.source = new Specific(machineConfig, stateConfig);
|
asset: {
|
||||||
|
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
|
||||||
//store in node
|
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
|
||||||
this.node.source = this.source; // Store the source in the node instance for easy access
|
tagNumber: uiConfig.assetTagNumber || null,
|
||||||
|
unit: flowUnit,
|
||||||
}
|
curveUnits: {
|
||||||
|
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
|
||||||
/**
|
flow: _resolveUnit(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit),
|
||||||
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
|
power: _resolveUnit(uiConfig.curvePowerUnit, 'power', 'kW'),
|
||||||
*/
|
control: (typeof uiConfig.curveControlUnit === 'string' && uiConfig.curveControlUnit.trim()) || '%',
|
||||||
_bindEvents() {
|
},
|
||||||
|
},
|
||||||
}
|
general: { unit: flowUnit },
|
||||||
|
flowNumber: uiConfig.flowNumber,
|
||||||
_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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _resolveUnit(candidate, expectedMeasure, fallback) {
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
const fb = String(fallback || '').trim();
|
||||||
|
if (!raw) return fb;
|
||||||
|
try {
|
||||||
|
const desc = convert().describe(raw);
|
||||||
|
if (expectedMeasure && desc.measure !== expectedMeasure) return fb;
|
||||||
|
return raw;
|
||||||
|
} catch (_) { return fb; }
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nodeClass;
|
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 };
|
||||||
1954
src/specificClass.js
1954
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 assert = require('node:assert/strict');
|
||||||
|
|
||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
const { makeNodeStub } = require('../helpers/factories');
|
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 = {}) {
|
function makeUiConfig(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
unit: 'm3/h',
|
unit: 'm3/h',
|
||||||
enableLog: true,
|
enableLog: false,
|
||||||
logLevel: 'debug',
|
logLevel: 'error',
|
||||||
supplier: 'hidrostal',
|
supplier: 'hidrostal',
|
||||||
category: 'machine',
|
category: 'machine',
|
||||||
assetType: 'pump',
|
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);
|
const inst = Object.create(NodeClass.prototype);
|
||||||
inst.node = makeNodeStub();
|
// Clear any leftover pending extras so this test's call is the only one
|
||||||
inst.name = 'rotatingMachine';
|
// that stamps Machine._pendingExtras.
|
||||||
|
Machine._pendingExtras = null;
|
||||||
|
return inst.buildDomainConfig(ui);
|
||||||
|
}
|
||||||
|
|
||||||
inst._loadConfig(
|
test('buildDomainConfig maps legacy editor fields for asset identity', () => {
|
||||||
makeUiConfig({
|
const cfg = callBuildDomainConfig(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
|
||||||
uuid: 'uuid-from-editor',
|
assert.equal(cfg.asset.uuid, 'uuid-from-editor');
|
||||||
assetTagNumber: 'TAG-123',
|
assert.equal(cfg.asset.tagCode, 'TAG-123');
|
||||||
}),
|
assert.equal(cfg.asset.tagNumber, '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('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
test('buildDomainConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const cfg = callBuildDomainConfig(makeUiConfig({
|
||||||
inst.node = makeNodeStub();
|
uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
|
||||||
inst.name = 'rotatingMachine';
|
assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
|
||||||
|
}));
|
||||||
inst._loadConfig(
|
assert.equal(cfg.asset.uuid, 'explicit-uuid');
|
||||||
makeUiConfig({
|
assert.equal(cfg.asset.tagCode, 'explicit-tag');
|
||||||
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('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
test('buildDomainConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const cfg = callBuildDomainConfig(makeUiConfig({
|
||||||
inst.node = makeNodeStub();
|
unit: 'not-a-unit',
|
||||||
inst.name = 'rotatingMachine';
|
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
||||||
|
curvePowerUnit: 'kW', curveControlUnit: '%',
|
||||||
inst._loadConfig(
|
}));
|
||||||
makeUiConfig({
|
assert.equal(cfg.general.unit, 'm3/h');
|
||||||
unit: 'not-a-unit',
|
assert.equal(cfg.asset.unit, 'm3/h');
|
||||||
curvePressureUnit: 'mbar',
|
assert.equal(cfg.asset.curveUnits.pressure, 'mbar');
|
||||||
curveFlowUnit: 'm3/h',
|
assert.equal(cfg.asset.curveUnits.flow, 'm3/h');
|
||||||
curvePowerUnit: 'kW',
|
assert.equal(cfg.asset.curveUnits.power, 'kW');
|
||||||
curveControlUnit: '%',
|
assert.equal(cfg.asset.curveUnits.control, '%');
|
||||||
}),
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
const inst = Object.create(NodeClass.prototype);
|
||||||
inst.node = makeNodeStub();
|
inst.buildDomainConfig(makeUiConfig({ enableLog: true, logLevel: 'warn', speed: 5, startup: 3 }));
|
||||||
inst.name = 'rotatingMachine';
|
const extras = Machine._pendingExtras;
|
||||||
const uiConfig = makeUiConfig({
|
assert.ok(extras, 'Machine._pendingExtras should be set by buildDomainConfig');
|
||||||
enableLog: true,
|
assert.equal(extras.stateConfig.general.logging.enabled, true);
|
||||||
logLevel: 'warn',
|
assert.equal(extras.stateConfig.general.logging.logLevel, 'warn');
|
||||||
uuid: 'uuid-test',
|
assert.equal(extras.stateConfig.movement.speed, 5);
|
||||||
assetTagNumber: 'TAG-9',
|
assert.equal(extras.stateConfig.time.starting, 3);
|
||||||
});
|
Machine._pendingExtras = null;
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,22 +34,20 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
|
|||||||
assert.equal(requested[1], max);
|
assert.equal(requested[1], max);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
|
test('source.getStatusBadge returns error status on internal failure', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// Status badge lives on the domain post-refactor. Build a tiny stub
|
||||||
const node = makeNodeStub();
|
// that throws to verify the error-path returns an error badge.
|
||||||
inst.node = node;
|
const errors = [];
|
||||||
inst.source = {
|
const source = {
|
||||||
currentMode: 'auto',
|
currentMode: 'auto',
|
||||||
state: {
|
state: { getCurrentState() { throw new Error('boom'); } },
|
||||||
getCurrentState() {
|
logger: { error: (m) => errors.push(m) },
|
||||||
throw new Error('boom');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
const { buildStatusBadge } = require('../../src/io/output');
|
||||||
const status = inst._updateNodeStatus();
|
const status = buildStatusBadge(source);
|
||||||
assert.equal(status.text, 'Status Error');
|
assert.match(status.text, /Status Error/);
|
||||||
assert.equal(node._errors.length, 1);
|
assert.equal(status.fill, 'red');
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('measurement handlers reject incompatible units', () => {
|
test('measurement handlers reject incompatible units', () => {
|
||||||
|
|||||||
@@ -2,89 +2,75 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
|
const commands = require('../../src/commands');
|
||||||
|
const { createRegistry } = require('generalFunctions');
|
||||||
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
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 inst = Object.create(NodeClass.prototype);
|
||||||
const node = makeNodeStub();
|
const node = makeNodeStub();
|
||||||
|
const source = makeSourceStub();
|
||||||
const calls = [];
|
|
||||||
inst.node = node;
|
inst.node = node;
|
||||||
inst.RED = makeREDStub({
|
inst.RED = makeREDStub({ child1: { source: { id: 'child-source' } } });
|
||||||
child1: {
|
inst.source = source;
|
||||||
source: { id: 'child-source' },
|
inst._commands = createRegistry(commands, { logger: source.logger });
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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._attachInputHandler();
|
inst._attachInputHandler();
|
||||||
const onInput = node._handlers.input;
|
const onInput = node._handlers.input;
|
||||||
|
|
||||||
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
await onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
||||||
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
|
await onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, () => {}, () => {});
|
||||||
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
await onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
||||||
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
await onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
||||||
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
await onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
await 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: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
||||||
|
|
||||||
assert.deepEqual(calls[0], ['setMode', 'auto']);
|
assert.deepEqual(source.calls[0], ['setMode', 'auto']);
|
||||||
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
assert.deepEqual(source.calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
||||||
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
assert.deepEqual(source.calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
||||||
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
|
// estop handler defaults action to 'emergencystop' even without one
|
||||||
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
// supplied, so the trailing arg is undefined — passed as positional.
|
||||||
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
assert.deepEqual(source.calls[3].slice(0, 3), ['handleInput', 'GUI', 'emergencystop']);
|
||||||
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
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 inst = Object.create(NodeClass.prototype);
|
||||||
const node = makeNodeStub();
|
const node = makeNodeStub();
|
||||||
|
|
||||||
const calls = [];
|
const calls = [];
|
||||||
inst.node = node;
|
inst.node = node;
|
||||||
inst.RED = makeREDStub();
|
inst.RED = makeREDStub();
|
||||||
inst.source = {
|
inst.source = {
|
||||||
|
logger: { warn: (m) => warns.push(m), info: () => {}, debug: () => {}, error: () => {} },
|
||||||
childRegistrationUtils: { registerChild() {} },
|
childRegistrationUtils: { registerChild() {} },
|
||||||
setMode() {},
|
setMode() {},
|
||||||
handleInput() {},
|
handleInput() { return Promise.resolve(); },
|
||||||
showWorkingCurves() { return {}; },
|
showWorkingCurves() { return {}; },
|
||||||
showCoG() { return {}; },
|
showCoG() { return {}; },
|
||||||
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
|
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
|
||||||
@@ -92,90 +78,67 @@ test('simulateMeasurement warns and ignores invalid payloads', () => {
|
|||||||
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
|
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
|
||||||
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
|
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
|
||||||
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
|
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
|
||||||
|
isUnitValidForType() { return true; },
|
||||||
};
|
};
|
||||||
|
inst._commands = createRegistry(commands, { logger: inst.source.logger });
|
||||||
inst._attachInputHandler();
|
inst._attachInputHandler();
|
||||||
const onInput = node._handlers.input;
|
const onInput = node._handlers.input;
|
||||||
|
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
||||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
await 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: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
||||||
|
|
||||||
assert.equal(calls.length, 0);
|
assert.equal(calls.length, 0);
|
||||||
assert.equal(node._warns.length, 3);
|
// Filter out the one-time deprecation warning for the legacy
|
||||||
assert.match(String(node._warns[0]), /finite number/i);
|
// 'simulateMeasurement' alias — only the three invalid-payload warns
|
||||||
assert.match(String(node._warns[1]), /payload\.unit is required/i);
|
// matter for this assertion.
|
||||||
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
|
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', () => {
|
test('source.getStatusBadge shows warning when pressure inputs are not initialized', () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// Status badge now lives on the domain (Machine). Build a tiny stub.
|
||||||
const node = makeNodeStub();
|
const source = {
|
||||||
|
|
||||||
inst.node = node;
|
|
||||||
inst.source = {
|
|
||||||
currentMode: 'virtualControl',
|
currentMode: 'virtualControl',
|
||||||
state: {
|
state: { getCurrentState: () => 'operational', getCurrentPosition: () => 50 },
|
||||||
getCurrentState() {
|
pressureInit: { getStatus: () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false }) },
|
||||||
return 'operational';
|
measurements: { type() { return { variant() { return { position() { return { getCurrentValue() { return 0; } }; } }; } }; } },
|
||||||
},
|
unitPolicyView: { output: { flow: 'm3/h' } },
|
||||||
getCurrentPosition() {
|
logger: { error: () => {} },
|
||||||
return 50;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getPressureInitializationStatus() {
|
|
||||||
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
|
|
||||||
},
|
|
||||||
measurements: {
|
|
||||||
type() {
|
|
||||||
return {
|
|
||||||
variant() {
|
|
||||||
return {
|
|
||||||
position() {
|
|
||||||
return { getCurrentValue() { return 0; } };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
// Import the buildStatusBadge helper directly — it's the same code the
|
||||||
const status = inst._updateNodeStatus();
|
// domain's getStatusBadge() invokes.
|
||||||
const statusAgain = inst._updateNodeStatus();
|
const { buildStatusBadge } = require('../../src/io/output');
|
||||||
|
const status = buildStatusBadge(source);
|
||||||
assert.equal(status.fill, 'yellow');
|
assert.equal(status.fill, 'yellow');
|
||||||
assert.equal(status.shape, 'ring');
|
assert.equal(status.shape, 'ring');
|
||||||
assert.match(status.text, /pressure not initialized/i);
|
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 inst = Object.create(NodeClass.prototype);
|
||||||
const node = makeNodeStub();
|
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.node = node;
|
||||||
inst.RED = makeREDStub();
|
inst.RED = makeREDStub();
|
||||||
inst.source = {
|
inst.source = source;
|
||||||
childRegistrationUtils: { registerChild() {} },
|
inst._commands = createRegistry(commands, { logger: source.logger });
|
||||||
setMode() {},
|
|
||||||
handleInput() {},
|
|
||||||
showWorkingCurves() {
|
|
||||||
return { curve: [1, 2, 3] };
|
|
||||||
},
|
|
||||||
showCoG() {
|
|
||||||
return { cog: 0.77 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
inst._attachInputHandler();
|
inst._attachInputHandler();
|
||||||
const onInput = node._handlers.input;
|
const onInput = node._handlers.input;
|
||||||
const sent = [];
|
const sent = [];
|
||||||
const send = (out) => sent.push(out);
|
const send = (out) => sent.push(out);
|
||||||
|
|
||||||
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
await onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
||||||
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
await onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
||||||
|
|
||||||
assert.equal(sent.length, 2);
|
assert.equal(sent.length, 2);
|
||||||
assert.equal(Array.isArray(sent[0]), true);
|
assert.equal(Array.isArray(sent[0]), true);
|
||||||
|
|||||||
Reference in New Issue
Block a user