Compare commits

..

7 Commits

Author SHA1 Message Date
znetsixe
8c2b2c0f9c docs: add Folder & File Layout section per EVOLV convention
Each repo can now be read standalone for the file-naming convention. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:33 +02:00
znetsixe
68ebe4ebce feat(valve): resolve supplier+type from asset registry, reject legacy asset fields
Mirrors the rotatingMachine cutover: assetResolver derives supplier/type/
units from the model id; nodeClass throws a clear "re-select model and
save" error if the saved node still carries denormalized supplier/
category/assetType strings. valve.html defaults trimmed accordingly.

14/14 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:12:47 +02:00
znetsixe
95ccc77b25 docs(wiki): rewrite Home.md — correct FSM states + config keys for Section 9/10
Section 10 (State chart): replace invented opening/closing/closed states with
the real shared FSM states (accelerating/decelerating for moves; idle/starting/
warmingup/operational/stopping/coolingdown/emergencystop/off/maintenance for
lifecycle). Show all valid transitions from stateConfig.json allowedTransitions.
Document protected transitions (warmingup, coolingdown) and valve-specific
pre-shutdown ramp-to-zero behaviour.

Section 9 (Config): add missing editor fields from nodeClass.buildDomainConfig
(startup/warmup/shutdown/cooldown times, speed, serviceType, fluidDensity,
fluidTemperatureK, gasChokedRatioLimit). Correct config paths to match actual
stateConfig / runtimeOptions split.

Section 7 (Lifecycle): add FSM state labels to sequence diagram; show
accelerating → operational final step.

Sections 2/6/12/14: minor precision improvements (Port-2 note, abort-deadlock
recipe, execSequence Phase-7 removal warning).

Re-ran npm run wiki:all; AUTOGEN blocks intact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:06:26 +02:00
znetsixe
43a17ad83f P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:14 +02:00
znetsixe
63b5f946e2 P11.5 + B2.1/B2.2: per-command units + description (where applicable)
Adds  to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:41:10 +02:00
znetsixe
8aa5b5e23e P9.3: wiki/Home.md following 14-section visual-first template + wiki:* scripts
Auto-generated topic-contract + data-model sections via shared wikiGen
script. Hand-written Mermaid diagrams for position-in-platform, code
map, child registration, lifecycle, configuration, state chart (where
applicable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:38 +02:00
znetsixe
e27135bdc4 P6: convert valve to BaseDomain + BaseNodeAdapter + concern split
Refactor of valve to use the platform infrastructure (BaseDomain, BaseNodeAdapter,
ChildRouter, commandRegistry, statusBadge). Extracts concerns into
focused modules per .claude/refactor/MODULE_SPLIT.md generic template.
Tests stay green; CONTRACT.md generated; legacy aliases preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:09:22 +02:00
16 changed files with 1417 additions and 1199 deletions

View File

@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).
## Folder & File Layout
Every per-node file MUST use the folder name (`valve`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `valve.js` |
| Editor HTML | `valve.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

95
CONTRACT.md Normal file
View File

@@ -0,0 +1,95 @@
# valve — Contract
Generated from `src/commands/index.js` (canonical topic + alias list) plus
the hand-written events section. Keep ≤ 100 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `set.mode` | `setMode` | `string` — one of the allowed mode names | Calls `source.setMode(payload)`. Invalid mode logs `warn` and is dropped. |
| `cmd.startup` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'startup')`. |
| `cmd.shutdown` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'shutdown')`. Pre-shutdown the valve ramps to position 0 if currently operational. |
| `cmd.estop` | `emergencystop`, `emergencyStop` | `{ source?: string, action?: string }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'emergencystop')`. |
| `execSequence` | — (legacy umbrella) | `{ source, action, parameter }` with `action ∈ {'startup','shutdown','emergencyStop','emergencystop'}` | Content-based router: forwards to canonical `cmd.startup` / `cmd.shutdown` / `cmd.estop` based on `payload.action`. Unknown action logs `warn`. Prefer the canonical `cmd.*` topics. |
| `set.position` | `execMovement` | `{ source, action, setpoint }` — setpoint coerced to `Number`; valve position percent in `[0, 100]` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'execMovement', Number(payload.setpoint))`. |
| `data.flow` | `updateFlow` | `{ variant, value, position, unit? }``variant ∈ {'measured','predicted'}` | Pushes a flow value into the measurement container at `<position>` and triggers a deltaP recompute through the hydraulic model. |
| `query.curve` | `showcurve` | none | Calls `source.showCurve()` and replies on **Port 0** with `{ topic: 'Showing curve', payload: <result> }` via `ctx.send`. |
| `child.register` | `registerChild` | `string` — child Node-RED id; `msg.positionVsParent` carries position | Resolves child via `RED.nodes.getNode(payload)` and registers it through `childRegistrationUtils.registerChild(child.source, msg.positionVsParent)`. The valve's `registerChild` records the child for fluid-contract tracking. |
Aliases log a one-time deprecation warning the first time they fire.
### `execSequence` demux
The pre-refactor topic `execSequence` carried `{ source, action, parameter }`
where `action` selected the verb. The command registry does not natively
dispatch by payload content, so `execSequence` keeps its own descriptor
whose handler forwards directly to the canonical `cmd.startup` /
`cmd.shutdown` / `cmd.estop` handler based on `payload.action`. The
deprecation warning fires once. Future-Phase-7 removal of `execSequence`
is a behavioural change — callers must migrate to the canonical topics.
## Outputs (msg.topic on Port 0/1/2)
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
`outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed
(only changed fields are emitted). On `query.curve` the node additionally
emits `{ topic: 'Showing curve', payload: <result> }` as a synchronous
reply on Port 0.
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
`'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
to its upstream parent (typically a `valveGroupControl`).
`positionVsParent` defaults to `'atEquipment'`.
`getOutput()` keys per tick include: `<position>_<variant>_<type>` slots
from the measurement container (e.g. `delta_predicted_pressure`,
`downstream_measured_flow`), plus `state`, `percentageOpen`, `moveTimeleft`,
`mode`.
## Events emitted by `source.emitter`
- `deltaPChange` — fires whenever the hydraulic model recomputes a finite
deltaP. Data: the deltaP value in `unitPolicy.output.pressure` (default
`mbar`). Consumed by `valveGroupControl` to update group totals.
- `fluidCompatibilityChange` — fires when the upstream fluid-contract
status changes (status / expected / received / sourceCount / message).
Data: `FluidCompatibility.getCompatibility()`.
- `fluidContractChange` — fires whenever the fluid contract that this valve
advertises downstream changes. Data: `FluidCompatibility.getContract()`.
## Events emitted by `source.state.emitter`
- `positionChange` — fires when the position percentage changes (per
movement tick). Data: `{ position, state, mode, timestamp }`. The valve
itself listens and triggers a Kv lookup + deltaP recompute.
- `stateChange` — fires on transitions of the operating state machine
(`idle → starting → warmingup → operational → accelerating →
decelerating → stopping → coolingdown → idle`, plus `off`).
## Events emitted by `source.measurements.emitter`
The `MeasurementContainer` fires `<type>.<variant>.<position>` whenever
a series receives a new value. Parents subscribe via the generic
`child.measurements.emitter.on(eventName, ...)` handshake. valve
publishes:
- `pressure.predicted.delta` — predicted pressure drop across the valve.
- `pressure.measured.<position>`, `pressure.predicted.<position>` — when
upstream pressure data arrives via `data.flow`-driven recompute or
direct measurement pushes.
- `flow.measured.<position>`, `flow.predicted.<position>` — mirrored from
upstream sources via `data.flow`.
Position labels are normalised to lowercase in the event name.
## Children registered by this node
valve accepts upstream sources (`machine`, `rotatingmachine`,
`machinegroup`, `machinegroupcontrol`, `pumpingstation`, `valvegroupcontrol`,
…) via `child.register`. The handler records each child for fluid-contract
tracking: the valve reads either the child's `getFluidContract()` result,
its `asset.serviceType` field, or a default per software type
(`liquid` for the rotating-equipment family). It then subscribes to the
child's `fluidContractChange` so re-keyed contracts propagate.

View File

@@ -4,7 +4,10 @@
"description": "Control module valve",
"main": "valve.js",
"scripts": {
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
},
"repository": {
"type": "git",

66
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,66 @@
'use strict';
// Valve command handlers. Each receives (source, msg, ctx) where source is
// the domain instance, msg is the incoming Node-RED message, and ctx carries
// { node, RED, send, logger } per BaseNodeAdapter.
function _logger(source, ctx) { return ctx?.logger || source?.logger || null; }
function _send(ctx, ports) { if (typeof ctx?.send === 'function') ctx.send(ports); }
exports.setMode = (source, msg) => {
source.setMode(msg.payload);
};
exports.startup = async (source, msg) => {
const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'startup');
};
exports.shutdown = async (source, msg) => {
const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', 'execSequence', 'shutdown');
};
exports.estop = async (source, msg) => {
const p = msg.payload || {};
await source.handleInput(p.source ?? 'parent', p.action ?? 'emergencystop');
};
// Legacy umbrella: payload.action selects the canonical verb.
exports.execSequenceAlias = async (source, msg, ctx) => {
const log = _logger(source, ctx);
const action = msg?.payload?.action;
if (action === 'startup') return exports.startup(source, msg, ctx);
if (action === 'shutdown') return exports.shutdown(source, msg, ctx);
if (action === 'emergencyStop' || action === 'emergencystop') {
return exports.estop(source, msg, ctx);
}
log?.warn?.(`execSequence: unsupported action '${action}'`);
};
exports.setPosition = async (source, msg) => {
const p = msg.payload || {};
const action = p.action ?? 'execMovement';
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
};
exports.dataFlow = (source, msg) => {
const p = msg.payload || {};
source.updateFlow(p.variant, p.value, p.position, p.unit || source.unitPolicyView?.output?.flow);
};
exports.queryCurve = (source, msg, ctx) => {
const reply = Object.assign({}, msg, { topic: 'Showing curve', payload: source.showCurve() });
_send(ctx, [reply, null, null]);
};
exports.registerChild = (source, msg, ctx) => {
const log = _logger(source, ctx);
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj || !childObj.source) {
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};

75
src/commands/index.js Normal file
View File

@@ -0,0 +1,75 @@
'use strict';
// valve command registry — consumed by BaseNodeAdapter. Canonical topics
// follow CONTRACTS.md §1; legacy names are kept as aliases (one-time
// deprecation warning when fired).
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
description: 'Switch the valve between auto / manual control modes.',
handler: handlers.setMode,
},
{
topic: 'cmd.startup',
payloadSchema: { type: 'any' },
description: 'Initiate the valve startup sequence.',
handler: handlers.startup,
},
{
topic: 'cmd.shutdown',
payloadSchema: { type: 'any' },
description: 'Initiate the valve shutdown sequence.',
handler: handlers.shutdown,
},
{
topic: 'cmd.estop',
aliases: ['emergencystop', 'emergencyStop'],
payloadSchema: { type: 'any' },
description: 'Trigger an emergency stop on the valve.',
handler: handlers.estop,
},
// Content-based demux; behaviour matches cmd.startup/cmd.shutdown exactly.
{
topic: 'execSequence',
payloadSchema: { type: 'object' },
description: 'Legacy umbrella that demuxes payload.action to startup / shutdown / estop.',
handler: handlers.execSequenceAlias,
_legacy: true,
},
{
topic: 'set.position',
aliases: ['execMovement'],
payloadSchema: { type: 'object' },
// Control-percent position — no units field (no `percent` measure in convert).
description: 'Move the valve to a control-% position via execMovement.',
handler: handlers.setPosition,
},
{
topic: 'data.flow',
aliases: ['updateFlow'],
payloadSchema: { type: 'object' },
// Compound payload `{variant, value, position, unit}` — handler converts
// internally via unitPolicy; registry normalisation is skipped.
description: 'Push a measured flow into the valve (variant + position + unit).',
handler: handlers.dataFlow,
},
{
topic: 'query.curve',
aliases: ['showcurve'],
payloadSchema: { type: 'any' },
description: 'Return the valve characteristic curve on the reply port.',
handler: handlers.queryCurve,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'string' },
description: 'Register a child measurement with this valve.',
handler: handlers.registerChild,
},
];

View File

@@ -0,0 +1,95 @@
'use strict';
const { loadCurve, predict } = require('generalFunctions');
const FALLBACK_SUPPLIER_CURVE = Object.freeze({
'1.204': { '125': { x: [0, 100], y: [0, 1] } },
});
function isValidCurveData(curveData) {
if (!curveData || typeof curveData !== 'object') return false;
const dKeys = Object.keys(curveData);
if (!dKeys.length) return false;
for (const dk of dKeys) {
const diameters = curveData[dk];
if (!diameters || typeof diameters !== 'object') return false;
const diaKeys = Object.keys(diameters);
if (!diaKeys.length) return false;
for (const k of diaKeys) {
const c = diameters[k];
if (!Array.isArray(c?.x) || !Array.isArray(c?.y) || c.x.length < 2 || c.x.length !== c.y.length) return false;
}
}
return true;
}
function pickNearestNumericKey(keys, target) {
const numeric = keys.map((k) => Number(k)).filter((v) => Number.isFinite(v));
if (!numeric.length) return String(target);
let selected = numeric[0];
let dist = Math.abs(selected - target);
for (const k of numeric) {
const d = Math.abs(k - target);
if (d < dist) { selected = k; dist = d; }
}
return String(selected);
}
class SupplierCurvePredictor {
constructor({ logger, model, configCurve, defaultDensity, defaultTemperatureK, rho, temperatureK, valveDiameter }) {
this.logger = logger;
this.model = model;
this.curve = model ? loadCurve(model) : null;
this._configCurve = configCurve;
this.defaultDensity = defaultDensity;
this.defaultTemperatureK = defaultTemperatureK;
this.rho = Number.isFinite(rho) && rho > 0 ? rho : defaultDensity;
this.T = Number.isFinite(temperatureK) && temperatureK > 0 ? temperatureK : defaultTemperatureK;
this._init(valveDiameter);
}
_init(valveDiameter) {
const supplierCurve = this._resolveData();
const densityTarget = Number.isFinite(this.rho) && this.rho > 0 ? this.rho : this.defaultDensity;
const densityKey = pickNearestNumericKey(Object.keys(supplierCurve), densityTarget);
const densityCurveFamily = supplierCurve[densityKey];
const diaTarget = Number(valveDiameter);
const diameterKey = pickNearestNumericKey(
Object.keys(densityCurveFamily || {}),
Number.isFinite(diaTarget) && diaTarget > 0 ? diaTarget : 125
);
this.curveSelection = { densityKey: Number(densityKey), diameterKey: Number(diameterKey) };
this.predictKv = new predict({ curve: densityCurveFamily || FALLBACK_SUPPLIER_CURVE['1.204'] });
this.predictKv.fDimension = this.curveSelection.diameterKey;
}
_resolveData() {
if (isValidCurveData(this.curve)) return this.curve;
if (isValidCurveData(this._configCurve)) return this._configCurve;
this.logger.warn('No valid supplier curve data found, using fallback curve.');
return FALLBACK_SUPPLIER_CURVE;
}
predictKvForPosition(positionPercent) {
if (!this.predictKv) return 0.1;
try {
this.predictKv.fDimension = this.curveSelection?.diameterKey || this.predictKv.fDimension;
const kv = Number(this.predictKv.y(positionPercent));
if (!Number.isFinite(kv)) return 0.1;
return Math.max(0.1, kv);
} catch (error) {
this.logger.warn(`Failed to predict Kv for position=${positionPercent}: ${error.message}`);
return 0.1;
}
}
snapshot() {
return {
selectedDensity: this.curveSelection?.densityKey ?? null,
selectedDiameter: this.curveSelection?.diameterKey ?? null,
curve: this.predictKv?.currentFxyCurve?.[this.predictKv?.fDimension] || null,
};
}
}
module.exports = { SupplierCurvePredictor, FALLBACK_SUPPLIER_CURVE };

View File

@@ -0,0 +1,84 @@
'use strict';
// Sequence + setpoint execution. Mirrors the pre-refactor Valve.handleInput
// switch but delegates state transitions to host.state. Pre-shutdown ramp-down
// to 0 happens here so the existing test contract holds.
class FlowController {
constructor(host) {
this.host = host;
this.logger = host.logger;
}
isValidSourceForMode(source, mode) {
const allowed = this.host.config.mode.allowedSources[mode] || [];
return allowed.has(source);
}
async handleInput(source, action, parameter) {
if (!this.isValidSourceForMode(source, this.host.currentMode)) {
const msg = `Source '${source}' is not valid for mode '${this.host.currentMode}'.`;
this.logger.warn(msg);
return { status: false, feedback: msg };
}
this.logger.info(`Handling input from source '${source}' with action '${action}' in mode '${this.host.currentMode}'.`);
try {
switch (action) {
case 'execSequence':
await this.executeSequence(parameter);
break;
case 'execMovement':
await this.setpoint(parameter);
break;
case 'emergencyStop':
case 'emergencystop':
this.logger.warn(`Emergency stop activated by '${source}'.`);
await this.executeSequence('emergencystop');
break;
case 'statusCheck':
this.logger.info(`Status Check: Mode = '${this.host.currentMode}', Source = '${source}'.`);
break;
default:
this.logger.warn(`Action '${action}' is not implemented.`);
}
this.logger.debug(`Action '${action}' successfully executed`);
return { status: true, feedback: `Action '${action}' successfully executed.` };
} catch (error) {
this.logger.error(`Error handling input: ${error}`);
}
}
async executeSequence(sequenceName) {
const sequence = this.host.config.sequences[sequenceName];
if (!sequence || sequence.size === 0) {
this.logger.warn(`Sequence '${sequenceName}' not defined.`);
return;
}
if (this.host.state.getCurrentState() === 'operational' && sequenceName === 'shutdown') {
this.logger.info(`Machine will ramp down to position 0 before performing ${sequenceName} sequence`);
await this.setpoint(0);
}
this.logger.info(` --------- Executing sequence: ${sequenceName} -------------`);
for (const stateName of sequence) {
try {
await this.host.state.transitionToState(stateName);
} catch (error) {
this.logger.error(`Error during sequence '${sequenceName}': ${error}`);
break;
}
}
}
async setpoint(value) {
try {
if (typeof value !== 'number' || value < 0) {
throw new Error('Invalid setpoint: Setpoint must be a non-negative number.');
}
await this.host.state.moveTo(value);
} catch (error) {
this.logger.error(`Error setting setpoint: ${error}`);
}
}
}
module.exports = FlowController;

View File

@@ -0,0 +1,201 @@
'use strict';
const SERVICE_TYPES = new Set(['gas', 'liquid']);
const DEFAULT_SOURCE_SERVICE_TYPE = Object.freeze({
machine: 'liquid',
rotatingmachine: 'liquid',
machinegroup: 'liquid',
machinegroupcontrol: 'liquid',
pumpingstation: 'liquid',
});
function normalizeOptional(value) {
const raw = String(value || '').trim().toLowerCase();
return SERVICE_TYPES.has(raw) ? raw : null;
}
function defaultForSoftwareType(softwareType) {
const key = String(softwareType || '').trim().toLowerCase();
return DEFAULT_SOURCE_SERVICE_TYPE[key] || null;
}
class FluidCompatibility {
constructor({ logger, emitter, expectedServiceType }) {
this.logger = logger;
this.emitter = emitter;
this.expectedServiceType = expectedServiceType || null;
this.upstreamFluidSources = new Map();
this._fluidContractListeners = new Map();
this.state = {
status: this.expectedServiceType ? 'pending' : 'unknown',
expectedServiceType: this.expectedServiceType,
receivedServiceType: null,
upstreamServiceTypes: [],
sourceCount: 0,
message: this.expectedServiceType
? `Waiting for upstream fluid contract (${this.expectedServiceType}).`
: 'No upstream fluid contract available.',
};
}
registerChild(child, softwareType) {
if (!child || typeof child !== 'object') {
this.logger.warn('registerChild skipped: invalid child payload');
return false;
}
const sourceType = String(softwareType || child?.config?.functionality?.softwareType || '').trim().toLowerCase();
const sourceId = child?.config?.general?.id
|| child?.config?.general?.name
|| `source-${this.upstreamFluidSources.size + 1}`;
const contract = this._extractContract(child, sourceType);
this.upstreamFluidSources.set(sourceId, { child, sourceType, contract });
this._bindListener(sourceId, child, sourceType);
this._updateState();
this.logger.info(`Source '${sourceId}' (${sourceType || 'unknown'}) registered for fluid contract.`);
return true;
}
_extractContract(child, softwareType) {
const sourceType = String(softwareType || child?.config?.functionality?.softwareType || '').trim().toLowerCase();
let fromChild = null;
if (typeof child?.getFluidContract === 'function') {
try { fromChild = child.getFluidContract(); }
catch (error) { this.logger.warn(`Failed to read child fluid contract: ${error.message}`); }
}
const status = String(fromChild?.status || '').trim().toLowerCase();
if (status === 'conflict') return { status: 'conflict', serviceType: null, sourceType };
const contractType = normalizeOptional(fromChild?.serviceType);
if (contractType) return { status: 'resolved', serviceType: contractType, sourceType };
const directType = normalizeOptional(
child?.serviceType || child?.expectedServiceType || child?.config?.asset?.serviceType
);
if (directType) return { status: 'resolved', serviceType: directType, sourceType };
const fallback = defaultForSoftwareType(sourceType);
if (fallback) return { status: 'inferred', serviceType: fallback, sourceType };
return { status: 'unknown', serviceType: null, sourceType };
}
_bindListener(sourceId, child, sourceType) {
if (!sourceId || this._fluidContractListeners.has(sourceId)) return;
if (!child?.emitter || typeof child.emitter.on !== 'function') return;
const handler = () => {
const latest = this._extractContract(child, sourceType);
const existing = this.upstreamFluidSources.get(sourceId) || {};
existing.contract = latest;
this.upstreamFluidSources.set(sourceId, existing);
this._updateState();
};
child.emitter.on('fluidContractChange', handler);
this._fluidContractListeners.set(sourceId, { emitter: child.emitter, handler });
}
_computeSnapshot() {
const expectedServiceType = this.expectedServiceType || null;
const contracts = Array.from(this.upstreamFluidSources.values())
.map((entry) => entry?.contract)
.filter(Boolean);
const upstreamServiceTypes = Array.from(new Set(
contracts.map((c) => normalizeOptional(c.serviceType)).filter(Boolean)
));
const hasConflict = contracts.some((c) => String(c.status || '').toLowerCase() === 'conflict');
const sourceCount = this.upstreamFluidSources.size;
if (hasConflict || upstreamServiceTypes.length > 1) {
return {
status: 'conflict', expectedServiceType,
receivedServiceType: upstreamServiceTypes.length === 1 ? upstreamServiceTypes[0] : null,
upstreamServiceTypes, sourceCount,
message: `Conflicting upstream fluids detected: ${upstreamServiceTypes.join(', ') || 'unknown'}.`,
};
}
if (upstreamServiceTypes.length === 1) {
const receivedServiceType = upstreamServiceTypes[0];
if (expectedServiceType && expectedServiceType !== receivedServiceType) {
return {
status: 'mismatch', expectedServiceType, receivedServiceType,
upstreamServiceTypes, sourceCount,
message: `Expected ${expectedServiceType}, received ${receivedServiceType}.`,
};
}
return {
status: expectedServiceType ? 'match' : 'inferred',
expectedServiceType, receivedServiceType,
upstreamServiceTypes, sourceCount,
message: expectedServiceType
? `Fluid contract validated: ${receivedServiceType}.`
: `Fluid inferred from upstream: ${receivedServiceType}.`,
};
}
return {
status: expectedServiceType ? 'pending' : 'unknown',
expectedServiceType, receivedServiceType: null,
upstreamServiceTypes: [], sourceCount,
message: expectedServiceType
? `Waiting for upstream fluid contract (${expectedServiceType}).`
: 'No upstream fluid contract available.',
};
}
_updateState() {
const next = this._computeSnapshot();
const prev = this.state || {};
const changed = (
prev.status !== next.status
|| prev.expectedServiceType !== next.expectedServiceType
|| prev.receivedServiceType !== next.receivedServiceType
|| prev.sourceCount !== next.sourceCount
|| (prev.message || '') !== (next.message || '')
);
this.state = next;
if (!changed) return;
if (next.status === 'mismatch' || next.status === 'conflict') {
this.logger.warn(`Fluid compatibility warning: ${next.message}`);
} else {
this.logger.info(`Fluid compatibility update: ${next.message}`);
}
this.emitter.emit('fluidCompatibilityChange', this.getCompatibility());
this.emitter.emit('fluidContractChange', this.getContract());
}
getCompatibility() {
const s = this.state || {};
return {
status: s.status || 'unknown',
expectedServiceType: s.expectedServiceType || null,
receivedServiceType: s.receivedServiceType || null,
upstreamServiceTypes: Array.isArray(s.upstreamServiceTypes) ? [...s.upstreamServiceTypes] : [],
sourceCount: Number(s.sourceCount) || 0,
message: s.message || '',
};
}
getContract() {
const c = this.getCompatibility();
if (c.status === 'conflict') {
return {
status: 'conflict', serviceType: null,
expectedServiceType: c.expectedServiceType,
observedServiceType: c.receivedServiceType,
source: 'valve',
};
}
const advertised = c.expectedServiceType || null;
return {
status: advertised ? 'resolved' : 'unknown',
serviceType: advertised,
expectedServiceType: c.expectedServiceType,
observedServiceType: c.receivedServiceType,
source: 'valve',
};
}
destroy() {
for (const { emitter, handler } of this._fluidContractListeners.values()) {
if (typeof emitter?.off === 'function') emitter.off('fluidContractChange', handler);
else if (typeof emitter?.removeListener === 'function') emitter.removeListener('fluidContractChange', handler);
}
this._fluidContractListeners.clear();
}
}
module.exports = { FluidCompatibility, normalizeOptional, defaultForSoftwareType };

73
src/io/output.js Normal file
View File

@@ -0,0 +1,73 @@
'use strict';
const { statusBadge } = require('generalFunctions');
const STATE_SYMBOLS = {
off: '⬛', idle: '⏸️', operational: '⏵️',
starting: '⏯️', warmingup: '🔄', accelerating: '⏩',
stopping: '⏹️', coolingdown: '❄️', decelerating: '⏪',
};
const STATE_FILL = {
off: 'red', idle: 'blue',
operational: 'green', warmingup: 'green',
starting: 'yellow', accelerating: 'yellow',
stopping: 'yellow', coolingdown: 'yellow', decelerating: 'yellow',
};
const SHOW_METRICS = new Set(['operational', 'warmingup', 'accelerating', 'decelerating']);
function buildOutput(host) {
const output = {};
Object.entries(host.measurements.measurements || {}).forEach(([type, variants]) => {
Object.entries(variants || {}).forEach(([variant, positions]) => {
Object.keys(positions || {}).forEach((position) => {
const unit = host._outputUnitForType(type);
const value = host._readMeasurement(type, variant, position, unit);
if (value != null) output[`${position}_${variant}_${type}`] = value;
});
});
});
output.state = host.state.getCurrentState();
output.percentageOpen = host.state.getCurrentPosition();
output.moveTimeleft = host.state.getMoveTimeLeft();
output.mode = host.currentMode;
return output;
}
function buildStatusBadge(host) {
try {
const mode = host.currentMode;
const stateName = host.state.getCurrentState();
const flowUnit = host.unitPolicyView.output.flow || 'm3/h';
const pressureUnit = host.unitPolicyView.output.pressure || 'mbar';
const flow = Math.round(host.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(flowUnit));
let deltaP = host.measurements.type('pressure').variant('predicted').position('delta').getCurrentValue(pressureUnit);
if (deltaP !== null && deltaP !== undefined) deltaP = parseFloat(deltaP.toFixed(0));
if (Number.isNaN(deltaP)) deltaP = '∞';
const pos = Math.round(host.state.getCurrentPosition() * 100) / 100;
const symbol = STATE_SYMBOLS[stateName] || '❔';
const fill = STATE_FILL[stateName] || 'grey';
let badge;
if (SHOW_METRICS.has(stateName)) {
badge = statusBadge.compose(
[`${mode}: ${symbol}`, `${pos}%`, `💨${flow}${flowUnit}`, `ΔP${deltaP} ${pressureUnit}`],
{ fill, shape: 'dot' }
);
} else {
badge = statusBadge.compose([`${mode}: ${symbol}`], { fill, shape: 'dot' });
}
const fc = typeof host.getFluidCompatibility === 'function' ? host.getFluidCompatibility() : null;
if (fc && (fc.status === 'mismatch' || fc.status === 'conflict')) {
return { fill: 'yellow', shape: 'ring', text: `${badge.text} | ⚠ ${fc.message}` };
}
return badge;
} catch (err) {
host.logger?.error?.(`getStatusBadge: ${err.message}`);
return statusBadge.error('Status Error');
}
}
module.exports = { buildOutput, buildStatusBadge };

View File

@@ -0,0 +1,120 @@
'use strict';
// Routes incoming pressure/flow measurement updates and triggers the
// hydraulic deltaP recompute. The formula path uses fixed FORMULA_UNITS
// (mbar / m3/h / K) — the hydraulic model multiplies q^2 with rho * T
// and divides by an absolute-pressure term, so unit choices are pinned.
const FORMULA_UNITS = Object.freeze({ pressure: 'mbar', flow: 'm3/h', temperature: 'K' });
class MeasurementRouter {
constructor(host) {
this.host = host;
this.logger = host.logger;
}
updatePressure(variant, value, position, unit) {
const h = this.host;
if (value === null || value === undefined) {
this.logger.warn(`Received null or undefined value for pressure update. Variant: ${variant}, Position: ${position}`);
return;
}
this.logger.debug(`Updating pressure: variant=${variant}, value=${value}, position=${position}`);
const u = unit || h.unitPolicyView.output.pressure;
if (variant === 'measured') {
h._writeMeasurement('pressure', 'measured', position, Number(value), u);
const downstreamP = h._readMeasurement('pressure', 'measured', 'downstream', FORMULA_UNITS.pressure);
const measuredFlow = h._readMeasurement('flow', 'measured', 'downstream', FORMULA_UNITS.flow);
const predictedFlow = h._readMeasurement('flow', 'predicted', 'downstream', FORMULA_UNITS.flow);
const activeFlow = Number.isFinite(predictedFlow) ? predictedFlow : measuredFlow;
this.updateDeltaP(activeFlow, h.kv, downstreamP);
return;
}
if (variant === 'predicted') {
h._writeMeasurement('pressure', 'predicted', position, Number(value), u);
const downstreamP = h._readMeasurement('pressure', 'predicted', 'downstream', FORMULA_UNITS.pressure);
const measuredFlow = h._readMeasurement('flow', 'measured', 'downstream', FORMULA_UNITS.flow);
const predictedFlow = h._readMeasurement('flow', 'predicted', 'downstream', FORMULA_UNITS.flow);
const activeFlow = Number.isFinite(predictedFlow) ? predictedFlow : measuredFlow;
this.updateDeltaP(activeFlow, h.kv, downstreamP);
return;
}
this.logger.warn(`Unrecognized variant '${variant}' for flow update.`);
}
updateFlow(variant, value, position, unit) {
const h = this.host;
if (value === null || value === undefined) {
this.logger.warn(`Received null or undefined value for flow update. Variant: ${variant}, Position: ${position}`);
return;
}
this.logger.debug(`Updating flow: variant=${variant}, value=${value}, position=${position}`);
const u = unit || h.unitPolicyView.output.flow;
if (variant === 'measured') {
h._writeMeasurement('flow', 'measured', position, Number(value), u);
const downstreamP = h._readMeasurement('pressure', 'measured', 'downstream', FORMULA_UNITS.pressure);
const measuredFlow = h._readMeasurement('flow', 'measured', position, FORMULA_UNITS.flow);
this.updateDeltaP(measuredFlow, h.kv, downstreamP);
return;
}
if (variant === 'predicted') {
h._writeMeasurement('flow', 'predicted', position, Number(value), u);
const downstreamP = h._readMeasurement('pressure', 'measured', 'downstream', FORMULA_UNITS.pressure);
const predictedFlow = h._readMeasurement('flow', 'predicted', position, FORMULA_UNITS.flow);
this.updateDeltaP(predictedFlow, h.kv, downstreamP);
return;
}
this.logger.warn(`Unrecognized variant '${variant}' for flow update.`);
}
updateMeasurement(variant, subType, value, position, unit) {
this.logger.debug(`---------------------- updating ${subType} ------------------ `);
switch (subType) {
case 'pressure':
this.updatePressure(variant, value, position, unit || this.host.unitPolicyView.output.pressure);
return;
case 'flow':
this.updateFlow(variant, value, position, unit || this.host.unitPolicyView.output.flow);
return;
case 'power':
return;
default:
this.logger.error(`Type '${subType}' not recognized for measured update.`);
}
}
// q in m3/h, downstreamP in mbar(g), temp in K
updateDeltaP(q, kv, downstreamP) {
const h = this.host;
const result = h.hydraulicModel.calculateDeltaPMbar({
qM3h: q, kv, downstreamGaugeMbar: downstreamP, rho: h.rho, tempK: h.T,
});
if (!result || !Number.isFinite(result.deltaPMbar)) return;
const deltaP = result.deltaPMbar;
h.deltaPKlep = deltaP;
h.hydraulicDiagnostics = result.details || null;
h._writeMeasurement('pressure', 'predicted', 'delta', deltaP, h.unitPolicyView.output.pressure);
this.logger.info('DeltaP updated to: ' + deltaP);
h.emitter.emit('deltaPChange', deltaP);
this.logger.info('DeltaPChange emitted to valveGroupController');
}
updatePositionDependent() {
const h = this.host;
const s = h.state.getCurrentState();
if (s !== 'operational' && s !== 'accelerating' && s !== 'decelerating') return;
this.logger.debug('Calculating new deltaP');
const x = h.state.getCurrentPosition();
const measuredFlow = h._readMeasurement('flow', 'measured', 'downstream', FORMULA_UNITS.flow);
const predictedFlow = h._readMeasurement('flow', 'predicted', 'downstream', FORMULA_UNITS.flow);
const currentFlow = Number.isFinite(predictedFlow) ? predictedFlow : measuredFlow;
const downstreamP = h._readMeasurement('pressure', 'measured', 'downstream', FORMULA_UNITS.pressure);
h.kv = h.curvePredictor.predictKvForPosition(x);
this.logger.debug(`Kv value for position valve ${x} is ${h.kv}`);
this.updateDeltaP(currentFlow, h.kv, downstreamP);
}
}
module.exports = { MeasurementRouter, FORMULA_UNITS };

View File

@@ -1,341 +1,67 @@
/**
* 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';
const { BaseNodeAdapter, convert } = require('generalFunctions');
const Valve = require('./specificClass');
const commands = require('./commands');
class nodeClass {
/**
* Create a MeasurementNode.
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
* @param {object} nodeInstance - The Node-RED node instance.
* @param {string} nameOfNode - The name of the node, used for
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
class nodeClass extends BaseNodeAdapter {
static DomainClass = Valve;
static commands = commands;
static tickInterval = null;
static statusInterval = 1000;
// Preserve RED reference for HTTP endpoints if needed
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this.source = null; // Will hold the specific class instance
this.config = null; // Will hold the merged configuration
buildDomainConfig(uiConfig) {
_rejectLegacyAssetFields(uiConfig);
// Load default & UI config
this._loadConfig(uiConfig,this.node);
// Instantiate core Measurement 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) {
// Resolve flow unit with validation before building config
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
const resolvedUiConfig = { ...uiConfig, unit: flowUnit };
// Build config: base sections handle general, asset, functionality
const cfgMgr = new configManager();
this.config = cfgMgr.buildConfig(this.name, resolvedUiConfig, node.id);
// 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;
}
}
/**
* Instantiate the core logic and store as source.
*/
_setupSpecificClass(uiConfig) {
const vconfig = this.config;
const asNumberOrUndefined = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
// need extra state for this
const stateConfig = {
general: {
logging: {
enabled: vconfig.general.logging.enabled,
logLevel: vconfig.general.logging.logLevel
}
},
movement: {
speed: asNumberOrUndefined(uiConfig.speed)
},
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
const asNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : undefined; };
Valve._pendingExtras = {
stateConfig: {
general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
movement: { speed: asNum(uiConfig.speed) },
time: {
starting: asNumberOrUndefined(uiConfig.startup),
warmingup: asNumberOrUndefined(uiConfig.warmup),
stopping: asNumberOrUndefined(uiConfig.shutdown),
coolingdown: asNumberOrUndefined(uiConfig.cooldown)
}
};
const runtimeOptions = {
starting: asNum(uiConfig.startup), warmingup: asNum(uiConfig.warmup),
stopping: asNum(uiConfig.shutdown), coolingdown: asNum(uiConfig.cooldown),
},
},
runtimeOptions: {
serviceType: uiConfig.serviceType,
fluidDensity: asNumberOrUndefined(uiConfig.fluidDensity),
fluidTemperatureK: asNumberOrUndefined(uiConfig.fluidTemperatureK),
gasChokedRatioLimit: asNumberOrUndefined(uiConfig.gasChokedRatioLimit),
fluidDensity: asNum(uiConfig.fluidDensity),
fluidTemperatureK: asNum(uiConfig.fluidTemperatureK),
gasChokedRatioLimit: asNum(uiConfig.gasChokedRatioLimit),
},
};
this.source = new Specific(vconfig, stateConfig, runtimeOptions);
//store in node
this.node.source = this.source; // Store the source in the node instance for easy access
}
/**
* Bind Measurement events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
*/
_bindEvents() {
}
_updateNodeStatus() {
const v = this.source;
try {
const mode = v.currentMode;
const state = v.state.getCurrentState();
const fluidCompatibility = typeof v.getFluidCompatibility === "function"
? v.getFluidCompatibility()
: null;
const fluidWarningText = (
fluidCompatibility
&& (fluidCompatibility.status === "mismatch" || fluidCompatibility.status === "conflict")
)
? fluidCompatibility.message
: "";
const flowUnit = v?.unitPolicy?.output?.flow || this.config.general.unit || "m3/h";
const pressureUnit = v?.unitPolicy?.output?.pressure || "mbar";
const flow = Math.round(v.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(flowUnit));
let deltaP = v.measurements.type("pressure").variant("predicted").position("delta").getCurrentValue(pressureUnit);
if (deltaP !== null) {
deltaP = parseFloat(deltaP.toFixed(0));
}
if(isNaN(deltaP)) {
deltaP = "∞";
}
const roundedPosition = Math.round(v.state.getCurrentPosition() * 100) / 100;
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;
}
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} | ΔP${deltaP} ${pressureUnit}`};
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} | ΔP${deltaP} ${pressureUnit}`};
break;
case "accelerating":
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ΔP${deltaP} ${pressureUnit}` };
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} | ΔP${deltaP} ${pressureUnit}`};
break;
default:
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
}
if (fluidWarningText) {
status = {
fill: "yellow",
shape: "ring",
text: `${status.text} | ⚠ ${fluidWarningText}`,
return {
general: { unit: flowUnit },
asset: { model: uiConfig.model || null, unit: flowUnit },
};
}
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() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
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]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
const v = this.source;
try {
switch(msg.topic) {
case 'registerChild': {
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
v.logger.warn(`registerChild skipped: missing child/source for id=${childId}`);
break;
}
v.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
break;
}
case 'setMode':
v.setMode(msg.payload);
break;
case 'execSequence': {
const { source: seqSource, action: seqAction, parameter } = msg.payload;
v.handleInput(seqSource, seqAction, parameter);
break;
}
case 'execMovement': {
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
v.handleInput(mvSource, mvAction, Number(setpoint));
break;
}
case 'emergencystop':
case 'emergencyStop': {
const payload = msg.payload || {};
const esSource = payload.source || 'parent';
v.handleInput(esSource, 'emergencystop');
break;
}
case 'showcurve':
send({ topic: 'Showing curve', payload: v.showCurve() });
break;
case 'updateFlow':
v.updateFlow(msg.payload.variant, msg.payload.value, msg.payload.position, msg.payload.unit || this.config.general.unit);
break;
default:
v.logger.warn(`Unknown topic: ${msg.topic}`);
}
} catch (error) {
v.logger.error(`Input handler failure: ${error.message}`);
}
if (typeof done === 'function') done();
});
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.source?.destroy?.();
if (typeof done === 'function') done();
});
}
}
// See rotatingMachine/src/nodeClass.js for the rationale. Same cutover rule.
function _rejectLegacyAssetFields(uiConfig) {
const offenders = ['supplier', 'category', 'assetType'].filter((k) => {
const v = uiConfig[k];
return typeof v === 'string' && v.trim() !== '';
});
if (offenders.length > 0) {
throw new Error(
`valve: legacy asset field(s) [${offenders.join(', ')}] are saved on this node. ` +
`After the AssetResolver refactor these are derived from the model id. ` +
`Open the node in the editor, re-select the model, and save to migrate.`,
);
}
}
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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
'use strict';
// Bind the underlying state machine's positionChange event to the host's
// updatePosition() hook. Returns an unbind function for clean teardown.
function bindStateEvents({ state, onPositionChange }) {
const handler = (data) => onPositionChange?.(data);
state.emitter.on('positionChange', handler);
return () => {
if (typeof state.emitter.off === 'function') state.emitter.off('positionChange', handler);
else if (typeof state.emitter.removeListener === 'function') {
state.emitter.removeListener('positionChange', handler);
}
};
}
module.exports = { bindStateEvents };

View File

@@ -25,11 +25,11 @@
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
//define asset properties
// Asset identifier surface. supplier/category/assetType are derived
// at runtime via assetResolver.resolveAssetMetadata(model). Do NOT
// add them back here. See generalFunctions/src/registry/README.md.
uuid: { value: "" },
supplier: { value: "" },
category: { value: "" },
assetType: { value: "" },
assetTagNumber: { value: "" },
model: { value: "" },
unit: { value: "" },
@@ -54,7 +54,10 @@
icon: "font-awesome/fa-toggle-on",
label: function () {
return (this.positionIcon || "") + " " + (this.category ? this.category.slice(0, -1) : "Valve");
// No more `this.category` on the node — derive from the model if needed,
// else fall back to a generic name.
const stem = this.model ? this.model : "Valve";
return (this.positionIcon || "") + " " + stem;
},
oneditprepare: function() {

313
wiki/Home.md Normal file
View File

@@ -0,0 +1,313 @@
# valve
> **Reflects code as of `e27135b` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is
**valve** models a single actuated throttling valve at the S88 Equipment Module level. It loads a supplier Kv-vs-position characteristic curve, drives an FSM for open/close move sequences (using `accelerating`/`decelerating` states shared with `rotatingMachine`), and recomputes pressure drop from flow + Kv via a hydraulic model. Used standalone or as a child of `valveGroupControl`.
## 2. Position in the platform
```mermaid
flowchart LR
vgc[valveGroupControl<br/>Unit]:::unit -->|set.position| this[valve<br/>Equipment]:::equip
src["machine / MGC / PS<br/>(upstream source)"]:::unit -->|child.register| this
meas[measurement<br/>type=pressure / flow]:::ctrl -.->|data| this
this -->|child.register Port 2| vgc
this -->|evt.deltaPChange| vgc
this -->|evt.fluidCompatibilityChange| vgc
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source: `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| Predicts deltaP from flow + Kv | ✅ | Hydraulic model picks liquid vs gas formula per `serviceType`. |
| Loads supplier curve by model name | ✅ | `asset.model` resolved through `loadModel`; inline `valveCurve` override supported. |
| Position move FSM | ✅ | `accelerating` / `decelerating` states with interruptible setpoints; `moveTo` uses shared state machine. |
| Startup / shutdown sequences | ✅ | Pre-shutdown ramps valve to position 0 before executing stop sequence. |
| Emergency-stop sequence | ✅ | `cmd.estop``emergencystop → off` sequence. |
| Fluid-contract aggregation | ✅ | Tracks upstream service type via registered sources through `FluidCompatibility`. |
| Gas-choke detection | ⚠️ | Hard cap at `gasChokedRatioLimit`; surfaced in `hydraulicDiagnostics`. |
| Multi-parent registration | ⚠️ | Allowed but not exercised in production tests. |
## 4. Code map
```mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass=Valve, commands<br/>tickInterval=null (event-driven)"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["Valve.configure()<br/>wires concern modules<br/>overrides registerChild → FluidCompatibility"]
end
subgraph concerns["src/ concern modules"]
state["state/<br/>stateBindings → positionChange"]
fluid["fluid/<br/>FluidCompatibility"]
curve["curve/<br/>SupplierCurvePredictor"]
meas["measurement/<br/>MeasurementRouter + FORMULA_UNITS"]
flow["flow/<br/>FlowController (setpoint, sequences)"]
io["io/<br/>buildOutput + buildStatusBadge"]
hyd["hydraulicModel.js<br/>ValveHydraulicModel"]
end
nc --> sc
sc --> state
sc --> fluid
sc --> curve
sc --> meas
sc --> flow
sc --> io
sc --> hyd
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `state/` | Bindings from state-machine `positionChange``updatePosition()` | Move-finished triggers, position callbacks. |
| `fluid/` | Service-type compatibility, contract aggregation | Gas-vs-liquid mismatch warnings, upstream fluid tracking. |
| `curve/` | Supplier Kv curve load + interpolation | Curve fitting, model selection, density keys. |
| `measurement/` | Pressure/flow routing + deltaP recompute | What triggers a recalc, measurement container writes. |
| `flow/` | Sequence + setpoint execution, mode validation | Startup / shutdown / move semantics, allowed-source checks. |
| `io/` | Port-0 output shape + status badge | What lands on the wire each tick. |
| `hydraulicModel.js` | Liquid + gas deltaP formulas, choke detection | Hydraulic calculation errors, choke ratio behaviour. |
## 5. Topic contract
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `set.mode` | `setMode` | `string` | — | Switch the valve between auto / manual control modes. |
| `cmd.startup` | _(none)_ | `any` | — | Initiate the valve startup sequence. |
| `cmd.shutdown` | _(none)_ | `any` | — | Initiate the valve shutdown sequence. |
| `cmd.estop` | `emergencystop`, `emergencyStop` | `any` | — | Trigger an emergency stop on the valve. |
| `execSequence` | _(none)_ | `object` | — | Legacy umbrella that demuxes payload.action to startup / shutdown / estop. |
| `set.position` | `execMovement` | `object` | — | Move the valve to a control-% position via execMovement. |
| `data.flow` | `updateFlow` | `object` | — | Push a measured flow into the valve (variant + position + unit). |
| `query.curve` | `showcurve` | `any` | — | Return the valve characteristic curve on the reply port. |
| `child.register` | `registerChild` | `string` | — | Register a child measurement with this valve. |
<!-- END AUTOGEN: topic-contract -->
## 6. Child registration
valve overrides `BaseDomain.registerChild` with `FluidCompatibility.registerChild`. Upstream sources feed the fluid-contract aggregator; measurement children attach through the standard measurement handshake and land in `MeasurementRouter`.
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
src["machine / rotatingmachine<br/>machinegroup / pumpingstation<br/>valvegroupcontrol"]:::unit
m["measurement<br/>type=pressure or flow"]:::ctrl
end
src -->|getFluidContract| fluid[FluidCompatibility<br/>aggregates serviceType]
m -->|"&lt;type&gt;.measured.&lt;position&gt;"| router[MeasurementRouter<br/>updatePressure / updateFlow]
router --> deltaP[updateDeltaP<br/>writes pressure.predicted.delta]
fluid --> evt1["evt.fluidCompatibilityChange<br/>evt.fluidContractChange"]
deltaP --> evt2[evt.deltaPChange]
classDef unit fill:#50a8d9,color:#000
classDef ctrl fill:#a9daee,color:#000
```
| softwareType | onRegister side-effect | Subscribed events |
|---|---|---|
| `machine` / `rotatingmachine` | Stored as upstream source; reads `getFluidContract()` or defaults to `liquid`. | `fluidContractChange` |
| `machinegroup` / `machinegroupcontrol` | Same; recomputes aggregate service type. | `fluidContractChange` |
| `pumpingstation` | Same. | `fluidContractChange` |
| `valvegroupcontrol` | Same. | `fluidContractChange` |
| `measurement` | Routed via measurement handshake; values land in `MeasurementContainer`. | `<type>.measured.<position>` |
## 7. Lifecycle — what one event does
```mermaid
sequenceDiagram
participant parent as valveGroupControl
participant valve as valve
participant fsm as state FSM
participant hyd as hydraulicModel
participant out as Port-0
parent->>valve: set.position { setpoint: 60 }
valve->>fsm: moveTo(60)
fsm-->>fsm: operational → accelerating
fsm-->>valve: positionChange ticks (stateBindings)
valve->>valve: predictKv(position)
valve->>hyd: calculateDeltaPMbar(q, kv, downP, rho, T)
hyd-->>valve: { deltaPMbar, diagnostics }
valve->>valve: write pressure.predicted.delta
valve->>parent: emitter.emit('deltaPChange', deltaP)
valve->>out: msg { topic, payload (delta-compressed) }
fsm-->>fsm: accelerating → operational (setpoint reached)
```
## 8. Data model — `getOutput()`
What lands on Port 0. Composed in `io/output.buildOutput`, then delta-compressed by `outputUtils.formatMsg`.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `state` | string | — | `"operational"` |
| `percentageOpen` | number | % | `0` |
| `moveTimeleft` | number | s | `0` |
| `mode` | string | — | `"auto"` |
| `downstream_predicted_flow` | number | m3/h | `0` |
| `downstream_measured_flow` | number | m3/h | _(emitted when measurement child present)_ |
| `downstream_predicted_pressure` | number | mbar | _(emitted when upstream pressure present)_ |
| `downstream_measured_pressure` | number | mbar | _(emitted when measurement child present)_ |
| `delta_predicted_pressure` | number | mbar | `0` |
<!-- END AUTOGEN: data-model -->
Measurement keys follow the legacy `<position>_<variant>_<type>` shape (e.g. `downstream_predicted_flow`, `delta_predicted_pressure`). Only keys with finite values are emitted — consumers must cache and merge (delta-compression is active).
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Reaction Speed]
f2[Asset model / supplier / category]
f3[Service type]
f4[Fluid density / temperature K]
f5[Gas choke ratio limit]
f6[Startup / warmup / shutdown / cooldown times]
f7[Log level / enableLog]
f8[positionVsParent]
end
subgraph config["Domain config slice"]
c1["movement.speed (stateConfig)"]
c2[asset.model]
c3["runtimeOptions.serviceType → hydraulicModel"]
c4["runtimeOptions.fluidDensity / fluidTemperatureK"]
c5["runtimeOptions.gasChokedRatioLimit"]
c6["stateConfig.time.starting / warmingup / stopping / coolingdown"]
c7["general.logging.enabled / logLevel"]
c8["functionality.positionVsParent → Port-2 registration"]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
f8 --> c8
```
| Form field | Config path | Default | Range / type | Where used |
|---|---|---|---|---|
| Reaction Speed | `movement.speed` (stateConfig) | `1` | > 0 (%/s) | `MovementManager` — sets rate of position change |
| Asset model | `asset.model` | `'Unknown'` | string | `SupplierCurvePredictor` — selects Kv curve dataset |
| Service type | `runtimeOptions.serviceType` | `null` (from asset) | `'gas'` / `'liquid'` | `ValveHydraulicModel` formula selection |
| Fluid density | `runtimeOptions.fluidDensity` | model default | > 0 (kg/m³) | liquid hydraulic formula |
| Fluid temperature | `runtimeOptions.fluidTemperatureK` | model default | > 0 (K) | gas hydraulic formula |
| Gas choke limit | `runtimeOptions.gasChokedRatioLimit` | per asset | 01 | gas choke cap in `ValveHydraulicModel` |
| Startup time | `stateConfig.time.starting` | `10` s | > 0 (s) | `StateManager` transition timer |
| Warmup time | `stateConfig.time.warmingup` | `5` s | > 0 (s) | `StateManager` protected transition |
| Shutdown time | `stateConfig.time.stopping` | `5` s | > 0 (s) | `StateManager` transition timer |
| Cooldown time | `stateConfig.time.coolingdown` | `10` s | > 0 (s) | `StateManager` transition timer |
| Mode | `mode.current` | `'auto'` | `auto` / `virtualControl` / `fysicalControl` / `maintenance` | `FlowController.isValidSourceForMode` |
| Log level | `general.logging.logLevel` | `'info'` | enum | structured logger |
| positionVsParent | `functionality.positionVsParent` | `'atEquipment'` | enum | Port-2 registration message to parent |
## 10. State chart
```mermaid
stateDiagram-v2
[*] --> off
off --> idle : cmd.startup (boot sequence)
off --> emergencystop : cmd.estop
off --> maintenance : set.mode=maintenance
idle --> starting : cmd.startup
idle --> off : (direct transition)
idle --> emergencystop : cmd.estop
idle --> maintenance : set.mode=maintenance
starting --> warmingup : timed (starting duration)
starting --> emergencystop : cmd.estop
warmingup --> operational : timed (warmup duration) [protected — cannot abort]
warmingup --> emergencystop : cmd.estop
operational --> accelerating : set.position > current
operational --> decelerating : set.position < current
operational --> stopping : cmd.shutdown
operational --> emergencystop : cmd.estop
accelerating --> operational : setpoint reached
accelerating --> emergencystop : cmd.estop
decelerating --> operational : setpoint reached
decelerating --> emergencystop : cmd.estop
stopping --> coolingdown : timed (stopping duration)
stopping --> idle : (direct)
stopping --> emergencystop : cmd.estop
coolingdown --> idle : timed (cooldown duration) [protected — cannot abort]
coolingdown --> off : (direct)
coolingdown --> emergencystop : cmd.estop
emergencystop --> idle : cmd.reset / sequence
emergencystop --> off : cmd.reset / sequence
emergencystop --> maintenance : (allowed)
maintenance --> idle : manual reset
maintenance --> off : manual reset
```
**Key valve-specific behaviours:**
- `accelerating` = position moving up; `decelerating` = position moving down. Both fire `positionChange` ticks. The valve's `stateBindings` hooks these to `updatePosition()` → Kv lookup → deltaP recompute.
- `warmingup` and `coolingdown` are **protected** — the abort signal is disabled; these phases cannot be interrupted.
- `cmd.shutdown` from `operational` first ramps the valve to position 0 (via `FlowController.executeSequence('shutdown')`), then transitions `stopping → coolingdown → idle`.
- `cmd.estop` triggers `emergencystop → off` regardless of current state (except from within protected transitions).
- Default sequences: `startup` = `[starting, warmingup, operational]`; `boot` = `[idle, starting, warmingup, operational]`; `emergencystop` = `[emergencystop, off]`.
## 11. Examples
| Tier | File | What it shows | Mandatory? |
|---|---|---|---|
| Basic | `examples/basic.flow.json` | Inject `set.position` + minimal wiring, no parent | ✅ |
| Integration | `examples/integration.flow.json` | valve + VGC + upstream measurement source | ✅ |
| Edge | `examples/edge.flow.json` | Edge-case inputs (gas, choke, estop, bad setpoints) | ⭕ optional |
Renamed example files (`01-Basic.flow.json`, `02-Integration.flow.json`, `03-Dashboard.flow.json`) will replace the above when produced. Screenshots under `wiki/_partial-screenshots/valve/`. Docker compose snippet under `examples/README.md`.
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| Status badge shows `⚠ no input` | Did any pressure / flow measurement register? Watch Port 2. | Debug tap on Port 2 |
| `delta_predicted_pressure` stuck at `0` | Is `kv > 0`? FSM may be in `off` / `idle` — valve is closed. | `state.getCurrentState()`, `percentageOpen` |
| Gas mismatch warning on status badge | `fluidCompatibility.status` is `mismatch` or `conflict`. | `getFluidCompatibility()` |
| `query.curve` returns empty curve | `asset.model` not found by `loadModel`; check `SupplierCurvePredictor.snapshot()`. | `SupplierCurvePredictor.snapshot()` |
| deltaP non-finite | Downstream gauge pressure absolute term ≤ 0, or choked ratio reached. | `hydraulicDiagnostics` in output |
| `set.position` has no effect | Check `currentMode` — source may not be in `mode.allowedSources[mode]`. | `FlowController.isValidSourceForMode` |
| FSM stuck in `accelerating` / `decelerating` | Movement was aborted but `_returnToOperationalOnAbort` was false. Send a new `set.position`. | `state.js` abort logic |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
## 13. When you would NOT use this node
- Use valve for a **throttling actuator** with a known Kv curve. For a fixed-restriction orifice (no actuator, no curve), model the deltaP externally.
- Don't use valve to model a **non-return / check valve** — no position control or FSM-driven actuation is exposed.
- Skip valve when an upstream source already provides flow directly and **no pressure-drop estimate is needed** — wire the source straight to the parent without inserting a valve.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | Gas-choke detection is a hard cap, not a smooth transition — chart traces show a step at the choked-ratio limit. | `hydraulicModel.js` |
| 2 | Multi-parent registration is allowed but not exercised in production tests. | `CONTRACT.md` — Children registered by this node |
| 3 | `set.position` move sequences are interruptible but tests cover happy-path only; abort-deadlock edge case documented separately. | `state.js` abort logic + `test/integration/` |
| 4 | `execSequence` (legacy umbrella topic) will be removed in Phase 7 — callers must migrate to `cmd.startup` / `cmd.shutdown` / `cmd.estop`. | `CONTRACT.md` — execSequence demux |

View File

@@ -0,0 +1,11 @@
| Key | Type | Unit | Sample |
|---|---|---|---|
| `state` | string | — | `"operational"` |
| `percentageOpen` | number | % | `0` |
| `moveTimeleft` | number | s | `0` |
| `mode` | string | — | `"auto"` |
| `downstream_predicted_flow` | number | m3/h | `0` |
| `downstream_measured_flow` | number | m3/h | _(emitted when measurement child present)_ |
| `downstream_predicted_pressure` | number | mbar | _(emitted when upstream pressure present)_ |
| `downstream_measured_pressure` | number | mbar | _(emitted when measurement child present)_ |
| `delta_predicted_pressure` | number | mbar | `0` |