Compare commits
10 Commits
main
...
7acd6c2ce0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acd6c2ce0 | ||
|
|
144460e6ba | ||
|
|
87214788d2 | ||
|
|
8c2b2c0f9c | ||
|
|
68ebe4ebce | ||
|
|
95ccc77b25 | ||
|
|
43a17ad83f | ||
|
|
63b5f946e2 | ||
|
|
8aa5b5e23e | ||
|
|
e27135bdc4 |
17
CLAUDE.md
17
CLAUDE.md
@@ -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
95
CONTRACT.md
Normal 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.
|
||||
@@ -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
66
src/commands/handlers.js
Normal 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
75
src/commands/index.js
Normal 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 operating mode. Allowed: `auto`, `virtualControl`, `fysicalControl`, `maintenance` (schema-validated in `valve.json` → `mode.current`).',
|
||||
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,
|
||||
},
|
||||
];
|
||||
95
src/curve/supplierCurve.js
Normal file
95
src/curve/supplierCurve.js
Normal 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 };
|
||||
84
src/flow/flowController.js
Normal file
84
src/flow/flowController.js
Normal 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;
|
||||
201
src/fluid/fluidCompatibility.js
Normal file
201
src/fluid/fluidCompatibility.js
Normal 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
73
src/io/output.js
Normal 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 };
|
||||
120
src/measurement/measurementRouter.js
Normal file
120
src/measurement/measurementRouter.js
Normal 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 };
|
||||
388
src/nodeClass.js
388
src/nodeClass.js
@@ -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 = {
|
||||
serviceType: uiConfig.serviceType,
|
||||
fluidDensity: asNumberOrUndefined(uiConfig.fluidDensity),
|
||||
fluidTemperatureK: asNumberOrUndefined(uiConfig.fluidTemperatureK),
|
||||
gasChokedRatioLimit: asNumberOrUndefined(uiConfig.gasChokedRatioLimit),
|
||||
starting: asNum(uiConfig.startup), warmingup: asNum(uiConfig.warmup),
|
||||
stopping: asNum(uiConfig.shutdown), coolingdown: asNum(uiConfig.cooldown),
|
||||
},
|
||||
},
|
||||
runtimeOptions: {
|
||||
serviceType: uiConfig.serviceType,
|
||||
fluidDensity: asNum(uiConfig.fluidDensity),
|
||||
fluidTemperatureK: asNum(uiConfig.fluidTemperatureK),
|
||||
gasChokedRatioLimit: asNum(uiConfig.gasChokedRatioLimit),
|
||||
},
|
||||
};
|
||||
return {
|
||||
general: { unit: flowUnit },
|
||||
asset: { model: uiConfig.model || null, unit: flowUnit },
|
||||
};
|
||||
|
||||
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 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;
|
||||
|
||||
1049
src/specificClass.js
1049
src/specificClass.js
File diff suppressed because it is too large
Load Diff
17
src/state/stateBindings.js
Normal file
17
src/state/stateBindings.js
Normal 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 };
|
||||
13
valve.html
13
valve.html
@@ -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() {
|
||||
|
||||
134
wiki/Home.md
Normal file
134
wiki/Home.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# valve
|
||||
|
||||
  
|
||||
|
||||
A `valve` models a single actuated throttling valve. It loads a supplier Kv-vs-position characteristic curve, drives a position FSM (`accelerating` / `decelerating` through `operational`), and recomputes pressure drop from flow + Kv via a hydraulic model that picks a liquid or gas formula by `serviceType`. Used standalone, or as a child of `valveGroupControl`, downstream of a `rotatingMachine` / `machineGroupControl` / `pumpingStation`.
|
||||
|
||||
> [!NOTE]
|
||||
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
|
||||
|
||||
---
|
||||
|
||||
## At a glance
|
||||
|
||||
| Thing | Value |
|
||||
|:---|:---|
|
||||
| What it represents | One actuated throttling valve — supplier Kv curve, position FSM, deltaP estimate |
|
||||
| S88 level | Equipment Module |
|
||||
| Use it when | You need a position-controlled valve whose deltaP depends on flow, Kv(position), and service-type (gas / liquid) |
|
||||
| Don't use it for | Fixed-restriction orifices, non-return / check valves, or curveless throttling devices (no fallback model) |
|
||||
| Children it accepts | Upstream sources (`rotatingmachine`, `machinegroup` / `machinegroupcontrol`, `pumpingstation`, `valvegroupcontrol`) for fluid-contract tracking; `measurement` for pressure / flow |
|
||||
| Parents it talks to | `valveGroupControl` (typical) or any node that issues `set.position` / `cmd.startup` / `cmd.shutdown` |
|
||||
|
||||
---
|
||||
|
||||
## How it fits
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
parent[valveGroupControl]:::unit -->|set.position<br/>cmd.startup / shutdown| v[valve<br/>Equipment]:::equip
|
||||
src["rotatingMachine /<br/>MGC / pumpingStation"]:::unit -->|child.register<br/>(fluid contract)| v
|
||||
m_p[measurement<br/>pressure]:::ctrl -.measured.-> v
|
||||
m_f[measurement<br/>flow]:::ctrl -.measured.-> v
|
||||
v -->|child.register| parent
|
||||
v -.->|evt.deltaPChange<br/>evt.fluidCompatibilityChange<br/>evt.fluidContractChange| parent
|
||||
classDef unit fill:#50a8d9,color:#000
|
||||
classDef equip fill:#86bbdd,color:#000
|
||||
classDef ctrl fill:#a9daee,color:#000
|
||||
```
|
||||
|
||||
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
|
||||
|
||||
---
|
||||
|
||||
## Try it — 3-minute demo
|
||||
|
||||
Import the basic example flow, deploy, and drive a single valve through a position move.
|
||||
|
||||
```bash
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
--data @nodes/valve/examples/basic.flow.json \
|
||||
http://localhost:1880/flow
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The shipped `examples/{basic,integration,edge}.flow.json` files are minimal stubs (one inject → valve → debug). A tiered `01 - Basic Manual Control.json` / `02 - Integration with Valve Group.json` / `03 - Dashboard Visualization.json` set, matching the `rotatingMachine` template, is on the backlog. Until then, drive the node directly with injects.
|
||||
|
||||
What to send after deploy (the topics map one-to-one to entries in [Reference — Contracts](Reference-Contracts#topic-contract)):
|
||||
|
||||
1. `set.mode = virtualControl` — lets the GUI source drive the valve (parent path is for grouped use).
|
||||
2. `cmd.startup` — FSM runs `idle → starting → warmingup → operational`.
|
||||
3. `set.position = {setpoint: 60}` (position %) — valve ramps from 0 to 60; state goes `operational → accelerating → operational`. Each position tick fires a Kv lookup + deltaP recompute.
|
||||
4. `data.flow = {variant: 'measured', value: 25, position: 'downstream', unit: 'm3/h'}` — push flow so the hydraulic model has something to chew on. `delta_predicted_pressure` updates and `evt.deltaPChange` fires upward.
|
||||
5. `cmd.shutdown` — if currently `operational`, the controller first ramps to position 0, then transitions `stopping → coolingdown → idle`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **GIF needed.** Demo recording of steps 1–5 with the live status badge. Save as `wiki/_partial-gifs/valve/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||
|
||||
---
|
||||
|
||||
## The seven things you'll send
|
||||
|
||||
| Topic | Aliases | Payload | What it does |
|
||||
|:---|:---|:---|:---|
|
||||
| `set.mode` | `setMode` | `"auto"` \| `"virtualControl"` \| `"fysicalControl"` \| `"maintenance"` | Switch operational mode. Source allow-list per mode (defaults from `valve.json`). |
|
||||
| `cmd.startup` | — | `{ source?: string }` | Run the configured `startup` sequence (default `[starting, warmingup, operational]`). |
|
||||
| `cmd.shutdown` | — | `{ source?: string }` | Run `shutdown`. If currently `operational`, first ramps the valve to position 0, then transitions `stopping → coolingdown → idle`. |
|
||||
| `cmd.estop` | `emergencystop`, `emergencyStop` | `{ source?: string, action?: string }` | Trigger an emergency stop — runs the `emergencystop` sequence (default `[emergencystop, off]`). |
|
||||
| `set.position` | `execMovement` | `{ source?: string, action?: string, setpoint: number }` | Move the valve to a position (control-%, `0..100`). Setpoint is coerced to `Number`. |
|
||||
| `data.flow` | `updateFlow` | `{ variant, value, position, unit? }` — `variant ∈ {'measured','predicted'}` | Push a flow measurement; triggers a Kv lookup + deltaP recompute via the hydraulic model. |
|
||||
| `query.curve` | `showcurve` | any | Reply on Port 0 with `{ topic: 'Showing curve', payload: <curve snapshot> }`. |
|
||||
|
||||
Plus the registration topic emitted upward at startup and accepted from real `measurement` children:
|
||||
|
||||
| Topic | Aliases | Payload |
|
||||
|:---|:---|:---|
|
||||
| `child.register` | `registerChild` | child Node-RED id (string); `msg.positionVsParent` carries the position label |
|
||||
|
||||
The legacy umbrella `execSequence` (`{action: 'startup' \| 'shutdown' \| 'emergencystop'}`) is still accepted — it forwards to the canonical `cmd.*` handler and logs a one-time deprecation warning. Scheduled for removal in Phase 7.
|
||||
|
||||
---
|
||||
|
||||
## What you'll see come out
|
||||
|
||||
Sample Port 0 message (delta-compressed, while operational at ~60 % open with a 25 m³/h flow):
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "valve#valve_a",
|
||||
"payload": {
|
||||
"state": "operational",
|
||||
"percentageOpen": 60,
|
||||
"moveTimeleft": 0,
|
||||
"mode": "auto",
|
||||
"downstream_measured_flow": 25,
|
||||
"downstream_predicted_flow": 0,
|
||||
"delta_predicted_pressure": 84
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key shape: **`<position>_<variant>_<type>`** — the legacy three-segment shape. Position labels are lowercase (`downstream`, `delta`, `upstream`). `valve` does **not** use the four-segment `<type>.<variant>.<position>.<childId>` shape that `rotatingMachine` emits.
|
||||
|
||||
| Field | Meaning |
|
||||
|:---|:---|
|
||||
| `state` | Current FSM state. See [Architecture — FSM](Reference-Architecture#fsm). |
|
||||
| `percentageOpen` | Current position (`0..100`). 0 = closed, 100 = fully open. |
|
||||
| `moveTimeleft` | Seconds remaining on the current position move (0 when stationary). |
|
||||
| `mode` | One of `auto` / `virtualControl` / `fysicalControl` / `maintenance`. |
|
||||
| `delta_predicted_pressure` | Predicted deltaP across the valve (output unit `mbar`). |
|
||||
| `downstream_predicted_flow` / `_measured_flow` | Last flow pushed via `data.flow` (output unit `m3/h`). |
|
||||
| `downstream_measured_pressure` / `_predicted_pressure` | Pressure measurements pushed via the `MeasurementRouter`. |
|
||||
|
||||
---
|
||||
|
||||
## Need more?
|
||||
|
||||
| Page | What you'll find |
|
||||
|:---|:---|
|
||||
| [Reference — Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, FSM, hydraulic model, lifecycle |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped example flows + debug recipes |
|
||||
| [Reference — Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
|
||||
|
||||
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||
300
wiki/Reference-Architecture.md
Normal file
300
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Reference — Architecture
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Code structure for `valve`: the three-tier sandwich, the `src/` layout, the position FSM, the hydraulic-model pipeline, the lifecycle, and the output ports. For an intuitive overview, return to [Home](Home).
|
||||
>
|
||||
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
|
||||
|
||||
---
|
||||
|
||||
## Three-tier code layout
|
||||
|
||||
```
|
||||
nodes/valve/
|
||||
|
|
||||
+-- valve.js entry: RED.nodes.registerType('valve', NodeClass)
|
||||
|
|
||||
+-- src/
|
||||
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
|
||||
| specificClass.js extends BaseDomain (orchestration only)
|
||||
| hydraulicModel.js ValveHydraulicModel + normalizeServiceType
|
||||
| |
|
||||
| +-- commands/
|
||||
| | index.js topic descriptors
|
||||
| | handlers.js pure handler functions
|
||||
| |
|
||||
| +-- curve/
|
||||
| | supplierCurve.js SupplierCurvePredictor (Kv-vs-position load + interp)
|
||||
| |
|
||||
| +-- fluid/
|
||||
| | fluidCompatibility.js FluidCompatibility — upstream service-type aggregation
|
||||
| |
|
||||
| +-- measurement/
|
||||
| | measurementRouter.js MeasurementRouter + FORMULA_UNITS
|
||||
| |
|
||||
| +-- flow/
|
||||
| | flowController.js handleInput, executeSequence, setpoint (pre-shutdown ramp)
|
||||
| |
|
||||
| +-- state/
|
||||
| | stateBindings.js wires state.emitter('positionChange') → updatePosition()
|
||||
| |
|
||||
| +-- io/
|
||||
| output.js buildOutput + buildStatusBadge
|
||||
```
|
||||
|
||||
### Tier responsibilities
|
||||
|
||||
| Tier | File | What it owns | Touches `RED.*` |
|
||||
|:---|:---|:---|:---:|
|
||||
| entry | `valve.js` | Type registration | Yes |
|
||||
| nodeClass | `src/nodeClass.js` | UI-config → domain config, legacy-asset-field reject, status-badge polling (`statusInterval=1000`). No tick loop (`tickInterval=null`) — event-driven. | Yes |
|
||||
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; expose the public surface tests + parents (VGC) depend on (`handleInput`, `setMode`, `updatePosition`, `updateFlow`, `updatePressure`, `registerChild`, `showCurve`, `getOutput`, …). Overrides `BaseDomain.registerChild` so upstream-source registration falls into `FluidCompatibility` instead of the generic ChildRouter. | No |
|
||||
|
||||
`specificClass` is stitching. All real work lives in the concern modules: position bindings in `state/`, deltaP math in `hydraulicModel.js`, Kv interpolation in `curve/`, measurement → deltaP plumbing in `measurement/`, mode + sequences in `flow/`, fluid contract aggregation in `fluid/`, and Port-0 shaping in `io/`.
|
||||
|
||||
---
|
||||
|
||||
## FSM
|
||||
|
||||
> [!NOTE]
|
||||
> The state machine is declared in `generalFunctions/src/state/stateConfig.json`. Same allowed-transition graph as `rotatingMachine`, with `accelerating` / `decelerating` reused for position moves up / down.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
idle --> starting: cmd.startup
|
||||
idle --> off
|
||||
idle --> maintenance
|
||||
starting --> warmingup: timer (time.starting)
|
||||
warmingup --> operational: timer (time.warmingup) [protected]
|
||||
operational --> accelerating: set.position up
|
||||
operational --> decelerating: set.position down
|
||||
operational --> stopping: cmd.shutdown
|
||||
accelerating --> operational: target reached
|
||||
decelerating --> operational: target reached
|
||||
stopping --> coolingdown: timer (time.stopping)
|
||||
coolingdown --> idle: timer (time.coolingdown) [protected]
|
||||
coolingdown --> off
|
||||
off --> idle: boot
|
||||
off --> maintenance
|
||||
maintenance --> off
|
||||
maintenance --> idle
|
||||
|
||||
note right of operational
|
||||
any state -> emergencystop via cmd.estop
|
||||
from emergencystop: idle / off / maintenance
|
||||
end note
|
||||
```
|
||||
|
||||
Default sequences (from `valve.json`):
|
||||
|
||||
| Sequence | States |
|
||||
|:---|:---|
|
||||
| `startup` | `[starting, warmingup, operational]` |
|
||||
| `shutdown` | `[stopping, coolingdown, idle]` |
|
||||
| `emergencystop` | `[emergencystop, off]` |
|
||||
| `boot` | `[idle, starting, warmingup, operational]` |
|
||||
|
||||
### Pre-shutdown ramp to zero
|
||||
|
||||
`FlowController.executeSequence('shutdown')` checks the FSM. When the valve is `operational` it first calls `setpoint(0)` — the position-ramp to fully closed is interruptible — then iterates the sequence states.
|
||||
|
||||
### Protected states
|
||||
|
||||
`warmingup` and `coolingdown` are **protected** at the state-machine layer (same mechanism as `rotatingMachine`). Aborts during these phases are ignored to preserve safety guarantees.
|
||||
|
||||
> [!NOTE]
|
||||
> Whether `valve` adopts the `sequenceAbortToken` mechanism from `rotatingMachine` (2026-05-15) for mid-shutdown re-engage races is an open question. TODO: confirm from `generalFunctions/src/state/state.js` whether valve inherits the token automatically. Source: `nodes/valve/src/flow/flowController.js`.
|
||||
|
||||
### Position-move bindings
|
||||
|
||||
`src/state/stateBindings.js` wires the underlying state machine's `positionChange` event to `host.updatePosition()`. Every position tick triggers:
|
||||
|
||||
1. `host.kv = host.curvePredictor.predictKvForPosition(x)` — Kv lookup against the supplier curve.
|
||||
2. `MeasurementRouter.updateDeltaP(currentFlow, kv, downstreamP)` — recompute the hydraulic deltaP and write `pressure.predicted.delta`.
|
||||
3. `host.emitter.emit('deltaPChange', deltaP)` — upward to the parent VGC.
|
||||
|
||||
`updatePosition()` is a no-op outside of `operational` / `accelerating` / `decelerating` (see `MeasurementRouter.updatePositionDependent`).
|
||||
|
||||
---
|
||||
|
||||
## Hydraulic + measurement pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
set[set.position]:::input --> fc[FlowController.setpoint]
|
||||
fc --> moveTo[state.moveTo]
|
||||
moveTo --> tick[state.emitter 'positionChange']
|
||||
tick --> upd[updatePosition]
|
||||
upd --> kv[curvePredictor.predictKvForPosition]
|
||||
fdat[data.flow]:::input --> mr[MeasurementRouter.updateFlow]
|
||||
fpres[measurement child<br/>pressure.measured.*]:::input --> mp[MeasurementRouter.updatePressure]
|
||||
mr --> dp[updateDeltaP]
|
||||
mp --> dp
|
||||
kv --> dp
|
||||
dp --> hyd[ValveHydraulicModel<br/>calculateDeltaPMbar]
|
||||
hyd --> write[write pressure.predicted.delta]
|
||||
write --> emit[emitter 'deltaPChange']
|
||||
write --> out[Port 0]
|
||||
classDef input fill:#a9daee,color:#000
|
||||
```
|
||||
|
||||
### Curve loading
|
||||
|
||||
At `configure()` startup:
|
||||
|
||||
1. `assetResolver.resolveAssetMetadata('valve', model)` resolves supplier / type / allowed units from `generalFunctions/datasets/assetData/` — **may return null** for valve; the predictor tolerates an inline `asset.valveCurve` fallback.
|
||||
2. `SupplierCurvePredictor` is constructed with the model, the inline curve override, density, temperature, and valve diameter.
|
||||
3. `predictKv` (the curve-evaluation function) is exposed on the host; `host.curveSelection` records which `(densityKey, diameterKey)` lane of the dataset is in use.
|
||||
|
||||
The `asset.valveCurve` schema is a nested map keyed by gas-density (kg per nm³) and valve diameter (mm); the leaf carries `{x: [position%], y: [Kv (m³/h)]}` lookup tables.
|
||||
|
||||
### Hydraulic formula selection
|
||||
|
||||
`ValveHydraulicModel.calculateDeltaPMbar` picks one of two formulas by `serviceType`:
|
||||
|
||||
| serviceType | Formula | Notes |
|
||||
|:---|:---|:---|
|
||||
| `liquid` | `deltaP_bar = (Q / Kv)^2 * (rho / 1000)` | Density override via `runtimeOptions.fluidDensity` (default 997 kg/m³). |
|
||||
| `gas` | `deltaP_bar = (Q^2 * rho * T) / (514^2 * Kv^2 * P2_abs)` | Density (default 1.204), absolute downstream pressure, temperature K. Capped at `gasChokedRatioLimit * P2_abs` when choked. |
|
||||
|
||||
Inputs are validated: `kv > 0`, `flow !== 0`, and (for gas) a finite downstream gauge pressure are required — otherwise the function returns `null` and the router skips the write.
|
||||
|
||||
### Formula units are pinned
|
||||
|
||||
`measurement/measurementRouter.js` declares:
|
||||
|
||||
```js
|
||||
const FORMULA_UNITS = Object.freeze({ pressure: 'mbar', flow: 'm3/h', temperature: 'K' });
|
||||
```
|
||||
|
||||
The hydraulic model expects q in m³/h, downstream gauge in mbar, and T in K. The router reads MeasurementContainer values back in these units before calling `calculateDeltaPMbar` regardless of the per-node `unitPolicy.output.*` rendering choices.
|
||||
|
||||
### Unit policy
|
||||
|
||||
Source: `src/specificClass.js` lines 20–24.
|
||||
|
||||
| Quantity | Canonical (internal) | Output (rendered) | Required-unit |
|
||||
|:---|:---|:---|:---:|
|
||||
| Pressure | `Pa` | `mbar` | ✓ |
|
||||
| Flow | `m3/s` | `m3/h` | ✓ |
|
||||
| Temperature | `K` | `C` | ✓ |
|
||||
|
||||
`requireUnitForTypes` means MeasurementContainer rejects writes that omit `unit` for these types.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle — what one event does
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant parent as VGC / GUI
|
||||
participant v as valve
|
||||
participant fc as flowController
|
||||
participant fsm as state (FSM)
|
||||
participant hyd as hydraulicModel
|
||||
participant out as Port 0 / parent
|
||||
|
||||
parent->>v: set.position {setpoint: 60}
|
||||
v->>fc: flowController.handleInput('parent','execMovement', 60)
|
||||
fc->>fc: isValidSourceForMode check
|
||||
fc->>fsm: setpoint(60) → state.moveTo(60)
|
||||
fsm-->>v: positionChange events per move tick
|
||||
v->>v: kv = curvePredictor.predictKvForPosition(pos)
|
||||
v->>hyd: calculateDeltaPMbar(q, kv, downP, rho, T)
|
||||
hyd-->>v: { deltaPMbar, details }
|
||||
v->>v: write pressure.predicted.delta
|
||||
v->>parent: emitter.emit('deltaPChange', deltaP)
|
||||
v->>out: notifyOutputChanged (Port 0 delta)
|
||||
parent->>v: data.flow {variant, value, position, unit}
|
||||
v->>v: MeasurementRouter.updateFlow → updateDeltaP
|
||||
```
|
||||
|
||||
### Mode + source allow-lists
|
||||
|
||||
Each input is gated in `flowController.handleInput`:
|
||||
|
||||
```js
|
||||
if (!this.isValidSourceForMode(source, this.host.currentMode)) {
|
||||
this.logger.warn(`Source '${source}' is not valid for mode '${currentMode}'.`);
|
||||
return { status: false, feedback: msg };
|
||||
}
|
||||
```
|
||||
|
||||
Defaults (per `valve.json` `mode.allowedSources`):
|
||||
|
||||
| Mode | Allowed sources |
|
||||
|:---|:---|
|
||||
| `auto` | `parent, GUI, fysical` |
|
||||
| `virtualControl` | `GUI, fysical` |
|
||||
| `fysicalControl` | `fysical` |
|
||||
| `maintenance` | _(no entry — no source accepted; only `statusCheck` action allowed)_ |
|
||||
|
||||
A rejected request logs at warn and short-circuits.
|
||||
|
||||
> [!NOTE]
|
||||
> Unlike `rotatingMachine`, `valve`'s `flowController` does not currently gate by `allowedActions` — only by source. The schema defines `mode.allowedActions` but it isn't enforced in `flowController.handleInput`. TODO: confirm intentional or backlog. Source: `nodes/valve/src/flow/flowController.js` lines 18–24.
|
||||
|
||||
---
|
||||
|
||||
## Output ports
|
||||
|
||||
| Port | Carries | Sample shape |
|
||||
|:---|:---|:---|
|
||||
| 0 (process) | Delta-compressed state snapshot — FSM state, position %, mode, every populated MeasurementContainer slot | `{topic, payload: {state, percentageOpen, moveTimeleft, mode, delta_predicted_pressure, downstream_measured_flow, ...}}` |
|
||||
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `valve,id=valve_a state="operational",percentageOpen=60,delta_predicted_pressure=84,...` |
|
||||
| 2 (register / control) | `child.register` upward at startup; `positionVsParent` and optional `distance` carried on the msg | `{topic: 'child.register', payload: <node.id>, positionVsParent, distance}` |
|
||||
|
||||
Port-0 key shape is **`<position>_<variant>_<type>`** (legacy three-segment). Examples: `delta_predicted_pressure`, `downstream_measured_flow`, `downstream_predicted_pressure`. Only keys with finite values are emitted — consumers must cache and merge.
|
||||
|
||||
On `query.curve` the node additionally emits `{topic: 'Showing curve', payload: <SupplierCurvePredictor.snapshot()>}` synchronously on Port 0.
|
||||
|
||||
See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
|
||||
|
||||
---
|
||||
|
||||
## Event sources
|
||||
|
||||
| Source | Where it fires | What it triggers |
|
||||
|:---|:---|:---|
|
||||
| `state.emitter` `'positionChange'` | `movementManager` during a position move | `updatePosition()` — Kv lookup, deltaP recompute, Port 0 |
|
||||
| `state.emitter` `'stateChange'` | `stateManager.transitionTo` resolve | Logged; `getOutput()` picks up the new `state` value on the next tick |
|
||||
| `source.emitter` `'deltaPChange'` | `MeasurementRouter.updateDeltaP` after a finite deltaP | Consumed by `valveGroupControl` to update group totals |
|
||||
| `source.emitter` `'fluidCompatibilityChange'` | `FluidCompatibility` on upstream-source contract change | Consumed by parent for service-type aggregation |
|
||||
| `source.emitter` `'fluidContractChange'` | `FluidCompatibility` when the contract this valve advertises downstream changes | Consumed by downstream consumers |
|
||||
| `source.measurements.emitter` `'<type>.<variant>.<position>'` | MeasurementContainer write | Generic handshake; parents subscribe via `child.measurements.emitter.on` |
|
||||
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch |
|
||||
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
|
||||
|
||||
No per-second tick on the domain itself. Position moves drive their own animation interval inside `movementManager`.
|
||||
|
||||
---
|
||||
|
||||
## Where to start reading
|
||||
|
||||
| If you're changing... | Read first |
|
||||
|:---|:---|
|
||||
| Kv curve load / inline-curve fallback | `src/curve/supplierCurve.js` |
|
||||
| Liquid / gas deltaP math, choke cap | `src/hydraulicModel.js` |
|
||||
| Measurement → deltaP plumbing (when a recompute fires) | `src/measurement/measurementRouter.js` |
|
||||
| Position-tick → updatePosition wiring | `src/state/stateBindings.js` |
|
||||
| Mode allow-list, setpoint, executeSequence, pre-shutdown ramp | `src/flow/flowController.js` |
|
||||
| Upstream-source fluid tracking, contract aggregation | `src/fluid/fluidCompatibility.js` |
|
||||
| `query.curve` reply / status badge / Port 0 shape | `src/io/output.js` |
|
||||
| Topic registration, payload validation, aliases | `src/commands/{index, handlers}.js` |
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [valveGroupControl wiki](https://gitea.wbd-rd.nl/RnD/valveGroupControl/wiki/Home) | The grouped-control parent |
|
||||
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||
254
wiki/Reference-Contracts.md
Normal file
254
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Reference — Contracts
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Full topic contract, configuration schema, and child-registration filters for `valve`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/valve.json`.
|
||||
>
|
||||
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only. For an intuitive overview, return to the [Home](Home).
|
||||
|
||||
---
|
||||
|
||||
## Topic contract
|
||||
|
||||
The registry lives in `src/commands/index.js`. Each descriptor maps a canonical `msg.topic` to its handler; aliases emit a one-time deprecation warning the first time they fire.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||
|---|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` | — | Switch the operating mode. Allowed: `auto`, `virtualControl`, `fysicalControl`, `maintenance` (schema-validated in `valve.json` → `mode.current`). |
|
||||
| `cmd.startup` | — | any | — | Initiate the valve startup sequence. |
|
||||
| `cmd.shutdown` | — | any | — | Initiate the valve shutdown sequence. |
|
||||
| `cmd.estop` | `emergencystop`, `emergencyStop` | any | — | Trigger an emergency stop on the valve. |
|
||||
| `execSequence` | — | `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 -->
|
||||
|
||||
### `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`. A deprecation warning fires once. Future-Phase-7 removal of `execSequence` is a behavioural change — callers must migrate to the canonical topics.
|
||||
|
||||
### Mode / source allow-lists
|
||||
|
||||
A topic that survives the registry still passes through `flowController.handleInput`:
|
||||
|
||||
```js
|
||||
if (!this.isValidSourceForMode(source, this.host.currentMode)) {
|
||||
this.logger.warn(`Source '${source}' is not valid for mode '${currentMode}'.`);
|
||||
return { status: false, feedback: msg };
|
||||
}
|
||||
```
|
||||
|
||||
Defaults from `valve.json`:
|
||||
|
||||
| Mode | `allowedSources` | `allowedActions` (schema) |
|
||||
|:---|:---|:---|
|
||||
| `auto` | `parent, GUI, fysical` | `statusCheck, execMovement, execSequence, emergencyStop` |
|
||||
| `virtualControl` | `GUI, fysical` | `statusCheck, execMovement, execSequence, emergencyStop` |
|
||||
| `fysicalControl` | `fysical` | `statusCheck, emergencyStop` |
|
||||
| `maintenance` | _(none)_ | `statusCheck` |
|
||||
|
||||
> [!NOTE]
|
||||
> `flowController.handleInput` currently enforces only the source side. The schema's `allowedActions` is defined but not gated in code — flagged as a TODO in [Architecture](Reference-Architecture#mode--source-allow-lists). Source: `nodes/valve/src/flow/flowController.js` line 13.
|
||||
|
||||
A rejected request logs at warn and short-circuits.
|
||||
|
||||
---
|
||||
|
||||
## Data model — `getOutput()` shape
|
||||
|
||||
Composed each tick by `src/io/output.js` `buildOutput()`. Delta-compressed: consumers see only the keys that changed.
|
||||
|
||||
### Per-measurement keys
|
||||
|
||||
For every `(type, variant, position)` stored in MeasurementContainer with a finite value, the flattened output emits:
|
||||
|
||||
```
|
||||
<position>_<variant>_<type>
|
||||
```
|
||||
|
||||
This is the **legacy three-segment** key shape (no trailing `<childId>`). Position labels are normalised to lowercase. valve does **not** use the four-segment `<type>.<variant>.<position>.<childId>` shape that `rotatingMachine` emits.
|
||||
|
||||
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
|
||||
|
||||
| Key | Type | Unit | Sample / notes |
|
||||
|:---|:---|:---|:---|
|
||||
| `state` | string | — | `"operational"` — one of the FSM states. |
|
||||
| `percentageOpen` | number | % | `60` — current position `0..100`. |
|
||||
| `moveTimeleft` | number | s | `0` — seconds remaining on the current move. |
|
||||
| `mode` | string | — | `"auto"` / `"virtualControl"` / `"fysicalControl"` / `"maintenance"`. |
|
||||
| `delta_predicted_pressure` | number | mbar | Predicted deltaP across the valve. Emitted on the next position tick once kv > 0 and a finite flow is known. |
|
||||
| `downstream_predicted_flow` | number | m3/h | Last flow pushed via `data.flow` with `variant=predicted`. |
|
||||
| `downstream_measured_flow` | number | m3/h | Last flow pushed via `data.flow` with `variant=measured`, or written by a registered flow measurement child. |
|
||||
| `downstream_predicted_pressure` | number | mbar | Last predicted pressure written upstream. |
|
||||
| `downstream_measured_pressure` | number | mbar | Last measured pressure from a registered pressure measurement child. |
|
||||
|
||||
<!-- END AUTOGEN -->
|
||||
|
||||
### Status badge
|
||||
|
||||
`buildStatusBadge` in `io/output.js`:
|
||||
|
||||
```
|
||||
<mode>: <state-symbol> <position%>% 💨<flow><unit> ΔP<deltaP> <unit>
|
||||
```
|
||||
|
||||
(The `position` / `flow` / `deltaP` line only appears in `operational` / `warmingup` / `accelerating` / `decelerating`; other states show just `<mode>: <symbol>`.)
|
||||
|
||||
State symbols (per `STATE_SYMBOLS` map in `io/output.js`):
|
||||
|
||||
| State | Symbol | Fill |
|
||||
|:---|:---:|:---|
|
||||
| `off` | ⬛ | red |
|
||||
| `idle` | ⏸️ | blue |
|
||||
| `operational` | ⏵️ | green |
|
||||
| `starting` | ⏯️ | yellow |
|
||||
| `warmingup` | 🔄 | green |
|
||||
| `accelerating` | ⏩ | yellow |
|
||||
| `decelerating` | ⏪ | yellow |
|
||||
| `stopping` | ⏹️ | yellow |
|
||||
| `coolingdown` | ❄️ | yellow |
|
||||
|
||||
When `getFluidCompatibility().status` is `mismatch` or `conflict`, the badge is overridden to a yellow ring with `⚠ <message>` appended.
|
||||
|
||||
---
|
||||
|
||||
## Configuration schema — editor form to config keys
|
||||
|
||||
Source of truth: `generalFunctions/src/configs/valve.json` plus `nodeClass.buildDomainConfig`.
|
||||
|
||||
### General (`config.general`)
|
||||
|
||||
| Form field | Config key | Default | Notes |
|
||||
|:---|:---|:---|:---|
|
||||
| Name | `general.name` | `valve` | Re-derived in `configure()`. |
|
||||
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
|
||||
| Default unit | `general.unit` | `m3/h` (schema) | Re-resolved to the unitPolicy `output.flow` in `configure()`. |
|
||||
| Enable logging | `general.logging.enabled` | `true` | Master switch. |
|
||||
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
|
||||
|
||||
### Functionality (`config.functionality`)
|
||||
|
||||
| Form field | Config key | Default | Notes |
|
||||
|:---|:---|:---|:---|
|
||||
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | One of `atEquipment` / `upstream` / `downstream`. Used in the child-register payload that goes UP to VGC. |
|
||||
| (hidden) | `functionality.softwareType` | `valve` | Constant. |
|
||||
| (hidden) | `functionality.role` | `controller` | Constant. |
|
||||
|
||||
### Asset (`config.asset`)
|
||||
|
||||
| Form field | Config key | Default | Notes |
|
||||
|:---|:---|:---|:---|
|
||||
| Asset UUID | `asset.uuid` | `null` | Globally-unique identifier. |
|
||||
| Tag code | `asset.tagCode` | `null` | |
|
||||
| Geolocation | `asset.geoLocation` | `{x:0, y:0, z:0}` | |
|
||||
| Model | `asset.model` | `null` | Optional. If set, resolves curve + supplier / type / allowed units via `assetResolver.resolveAssetMetadata('valve', model)`. If null, the predictor uses the inline `valveCurve`. |
|
||||
| Deployment unit | `asset.unit` | `null` | Must appear in the registry's allowed list for the model when set. |
|
||||
| Accuracy | `asset.accuracy` | `null` | Optional. |
|
||||
| Valve curve | `asset.valveCurve` | `{ '1.204': { '1': { x: [0..100 by 10], y: [0,18,50,95,150,216,337,564,882,1398,1870] } } }` | Nested map: outer key = density (kg per nm³), middle = diameter (mm), leaf = `{x: position%, y: Kv (m³/h)}`. |
|
||||
|
||||
Runtime options that bypass `config` and reach `configure()` via `Valve._pendingExtras.runtimeOptions`:
|
||||
|
||||
| UI field | runtimeOptions key | Default | Effect |
|
||||
|:---|:---|:---|:---|
|
||||
| Service type | `serviceType` | derived (`gas` if not `liquid`) | Picks the hydraulic formula in `ValveHydraulicModel`. |
|
||||
| Fluid density | `fluidDensity` | model default (997 / 1.204) | Sets `host.rho`. |
|
||||
| Fluid temperature K | `fluidTemperatureK` | 293.15 | Sets `host.T`. |
|
||||
| Gas choke ratio limit | `gasChokedRatioLimit` | 0.7 | Cap for the gas hydraulic formula. |
|
||||
|
||||
> [!WARNING]
|
||||
> **Legacy asset fields rejected.** `supplier`, `category`, and `assetType` are no longer node config — the registry derives them from the model. Flows saved before the AssetResolver refactor will throw a startup error from `_rejectLegacyAssetFields` with a clear migration message. Re-open the node, re-select the model from the asset menu, and save.
|
||||
|
||||
### State times (`stateConfig.time`)
|
||||
|
||||
Set on the state machine via `nodeClass.buildDomainConfig` from editor fields:
|
||||
|
||||
| Form field | Config key | Notes |
|
||||
|:---|:---|:---|
|
||||
| Startup Time | `time.starting` | Time spent in `starting` before transitioning to `warmingup`. |
|
||||
| Warmup Time | `time.warmingup` | Time in `warmingup` — **non-interruptible** safety. |
|
||||
| Shutdown Time | `time.stopping` | Time in `stopping`. |
|
||||
| Cooldown Time | `time.coolingdown` | Time in `coolingdown` — **non-interruptible** safety. |
|
||||
|
||||
> [!NOTE]
|
||||
> TODO: confirm canonical defaults. `valve.json` does not declare them inline; they come from `generalFunctions/src/configs/state.json` or the parent state-machine schema. Source: `nodeClass.buildDomainConfig` lines 19–26.
|
||||
|
||||
### Movement (`stateConfig.movement`)
|
||||
|
||||
| Form field | Config key | Notes |
|
||||
|:---|:---|:---|
|
||||
| Reaction Speed | `movement.speed` | Position ramp rate (%/s). E.g. `1` means setpoint 60 from 0 takes ~60 s. |
|
||||
|
||||
### Sequences (`config.sequences`)
|
||||
|
||||
State-transition lists per sequence name. Defaults:
|
||||
|
||||
| Sequence | States |
|
||||
|:---|:---|
|
||||
| `startup` | `[starting, warmingup, operational]` |
|
||||
| `shutdown` | `[stopping, coolingdown, idle]` |
|
||||
| `emergencystop` | `[emergencystop, off]` |
|
||||
| `boot` | `[idle, starting, warmingup, operational]` |
|
||||
|
||||
Note: unlike `rotatingMachine`, `valve.json` does not ship `entermaintenance` / `exitmaintenance` sequences. TODO: confirm whether maintenance transitions are intentionally manual.
|
||||
|
||||
### Mode (`config.mode`)
|
||||
|
||||
| Form field | Config key | Default | Range |
|
||||
|:---|:---|:---|:---|
|
||||
| Mode | `mode.current` | `auto` | `auto` / `virtualControl` / `fysicalControl` / `maintenance` |
|
||||
| (defaults) | `mode.allowedActions.<mode>` | see [Topic contract](#mode--source-allow-lists) | schema only — not currently gated in code |
|
||||
| (defaults) | `mode.allowedSources.<mode>` | see above | enforced by `flowController.isValidSourceForMode` |
|
||||
|
||||
### Unit policy
|
||||
|
||||
Source: `src/specificClass.js` lines 20–24.
|
||||
|
||||
| Quantity | Canonical (internal) | Output (rendered) | Required-unit |
|
||||
|:---|:---|:---|:---:|
|
||||
| Pressure | `Pa` | `mbar` | ✓ |
|
||||
| Flow | `m3/s` | `m3/h` | ✓ |
|
||||
| Temperature | `K` | `C` | ✓ |
|
||||
|
||||
`requireUnitForTypes` means MeasurementContainer rejects writes that omit `unit` for these types. The hydraulic model itself reads back via fixed `FORMULA_UNITS = {pressure: 'mbar', flow: 'm3/h', temperature: 'K'}`.
|
||||
|
||||
### Calculation mode (`config.calculationMode`)
|
||||
|
||||
`low` / `medium` (default) / `high` — declared in the schema. TODO: confirm whether the dispatch path consults `calculationMode` for trigger frequency. Source: `valve.json` lines 346–366.
|
||||
|
||||
---
|
||||
|
||||
## Child registration
|
||||
|
||||
Source: `src/specificClass.js` lines 100–101 and `src/fluid/fluidCompatibility.js`.
|
||||
|
||||
valve **overrides** `BaseDomain.registerChild` so registrations fall into `FluidCompatibility.registerChild` rather than the generic ChildRouter. Upstream sources feed the fluid-contract aggregator; measurement children attach via the standard measurement handshake and land in `MeasurementRouter`.
|
||||
|
||||
| Software type | Side-effect | Subscribed events |
|
||||
|:---|:---|:---|
|
||||
| `machine` / `rotatingmachine` | Stored as upstream source; reads `getFluidContract()` or `asset.serviceType`, defaulting to `liquid` for the rotating-equipment family. Recomputes aggregate service type. | `fluidContractChange` |
|
||||
| `machinegroup` / `machinegroupcontrol` | Same; recomputes aggregate service type. | `fluidContractChange` |
|
||||
| `pumpingstation` | Same. | `fluidContractChange` |
|
||||
| `valvegroupcontrol` | Same. | `fluidContractChange` |
|
||||
| `measurement` (asset.type=`pressure`, position=`*`) | Routed through `MeasurementRouter.updatePressure(variant, value, position, unit)`; triggers a deltaP recompute. | `<type>.measured.<position>` |
|
||||
| `measurement` (asset.type=`flow`, position=`*`) | Routed through `MeasurementRouter.updateFlow(variant, value, position, unit)`; triggers a deltaP recompute. | `<type>.measured.<position>` |
|
||||
|
||||
The valve's `_updateMeasurement` path (via `updateMeasurement(variant, subType, value, position, unit)`) currently handles `pressure` and `flow`. `power` is recognised but ignored.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, FSM, hydraulic-model pipeline |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [EVOLV — Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
|
||||
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |
|
||||
143
wiki/Reference-Examples.md
Normal file
143
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Reference — Examples
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Every example flow shipped under `nodes/valve/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/valve/examples/`.
|
||||
>
|
||||
> Pending full node review (2026-05). The shipped flows are currently minimal stubs; tiered demos (matching the `rotatingMachine` template) are on the backlog.
|
||||
|
||||
---
|
||||
|
||||
## Shipped examples
|
||||
|
||||
| File | Tier | Dependencies | What it shows |
|
||||
|:---|:---:|:---|:---|
|
||||
| `basic.flow.json` | 1 (stub) | EVOLV only | Minimal: one `inject` → one `valve` → one `debug`. Sanity check that the node loads. |
|
||||
| `integration.flow.json` | 2 (stub) | EVOLV only | Same shape as basic; placeholder for VGC + measurement integration. |
|
||||
| `edge.flow.json` | 3 (stub) | EVOLV only | Placeholder for edge cases (gas-choke, e-stop, invalid setpoints). |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Tiered example flows TODO.** Replace the three stubs with `01 - Basic Manual Control.json` / `02 - Integration with Valve Group.json` / `03 - Dashboard Visualization.json` following the `rotatingMachine` template. Track in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` and validate live against Docker-stack Node-RED. Screenshots / GIFs land under `wiki/_partial-screenshots/valve/` and `wiki/_partial-gifs/valve/`.
|
||||
|
||||
---
|
||||
|
||||
## Loading a flow
|
||||
|
||||
### Via the editor
|
||||
|
||||
1. Open the Node-RED editor at `http://localhost:1880`.
|
||||
2. Menu → Import → drag the JSON file.
|
||||
3. Click Deploy.
|
||||
|
||||
### Via the Admin API
|
||||
|
||||
```bash
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
--data @"nodes/valve/examples/basic.flow.json" \
|
||||
http://localhost:1880/flow
|
||||
```
|
||||
|
||||
Use `POST /flow` (single tab, full replace) or `POST /flows` (full deploy) depending on whether other tabs are already loaded.
|
||||
|
||||
---
|
||||
|
||||
## Driving the basic flow manually
|
||||
|
||||
The shipped `basic.flow.json` has a single `inject` wired to the valve. To exercise the FSM + hydraulic model, send the following sequence by hand (e.g. via additional inject nodes you wire in, or the Admin API):
|
||||
|
||||
1. `set.mode` — payload `"virtualControl"` — lets the GUI source drive the valve.
|
||||
2. `cmd.startup` — payload `{}`. FSM walks `idle → starting → warmingup → operational`. Watch `state` on Port 0.
|
||||
3. `set.position` — payload `{"setpoint": 60}`. FSM goes `operational → accelerating → operational`; `percentageOpen` ramps 0 → 60 at `movement.speed` %/s.
|
||||
4. `data.flow` — payload `{"variant": "measured", "value": 25, "position": "downstream", "unit": "m3/h"}`. Flow lands in MeasurementContainer; `MeasurementRouter.updateFlow` recomputes deltaP. `delta_predicted_pressure` appears on Port 0; `evt.deltaPChange` fires upward.
|
||||
5. `data.flow` — payload `{"variant": "measured", "value": 0, "position": "downstream", "unit": "mbar"}` to push downstream pressure as well (needed for the gas-flow path).
|
||||
6. `cmd.shutdown` — payload `{}`. Because the valve is `operational`, the controller first ramps `percentageOpen` to 0, then `state` transitions `stopping → coolingdown → idle`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **GIF needed.** Demo recording of steps 1–6 + status badge progression. Save as `wiki/_partial-gifs/valve/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||
|
||||
### Try the position-residue handler
|
||||
|
||||
After the valve reaches `operational` at 60 %:
|
||||
|
||||
1. Send `set.position = {setpoint: 20}`. State goes `operational → decelerating → ...`.
|
||||
2. While `decelerating`, send `set.position = {setpoint: 80}`.
|
||||
3. `state.moveTo` recognises the residue state, transitions back to `operational` synchronously, then ramps up to 80. No setpoint is lost.
|
||||
|
||||
This is the same residue mechanism `rotatingMachine` uses for fast retargets.
|
||||
|
||||
### Try the e-stop sequence
|
||||
|
||||
From `operational`, send `cmd.estop`. The valve runs the `emergencystop` sequence (`[emergencystop, off]`). Allowed transitions out of `emergencystop` are `idle` / `off` / `maintenance`. To restart, drop to `idle` first (`cmd.shutdown` from `off` may not work depending on the state graph — TODO: confirm).
|
||||
|
||||
---
|
||||
|
||||
## Integration with `valveGroupControl`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **TODO: Tier-2 example.** A proper integration flow with `valveGroupControl` + 2×valve children + an upstream `rotatingMachine` / `pumpingStation` for fluid-contract tracking is on the backlog. Screenshot under `wiki/_partial-screenshots/valve/02-integration.png`.
|
||||
|
||||
When built, the integration flow will demonstrate:
|
||||
|
||||
- Auto-registration via Port 2 at deploy — each valve's `child.register` reaches the VGC; no manual wiring needed.
|
||||
- Upstream-source registration — a `rotatingMachine` registered as a child of the valve feeds `getFluidContract()` into `FluidCompatibility`. Status flips from `pending` / `compatible` / `mismatch` based on `serviceType` agreement.
|
||||
- `evt.deltaPChange` propagation from each valve to the VGC for group-level deltaP aggregation.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard visualization
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **TODO: Tier-3 example.** A FlowFuse Dashboard 2.0 page (`@flowfuse/node-red-dashboard`) with control buttons (mode, startup, shutdown, e-stop, position slider), live status (state badge, position %, deltaP, flow), and trend charts (deltaP, position) is on the backlog. Save as `03 - Dashboard Visualization.json`.
|
||||
|
||||
---
|
||||
|
||||
## Docker compose snippet
|
||||
|
||||
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (extract)
|
||||
services:
|
||||
nodered:
|
||||
build: ./docker/nodered
|
||||
ports: ['1880:1880']
|
||||
volumes:
|
||||
- ./docker/nodered/data:/data/evolv
|
||||
influxdb:
|
||||
image: influxdb:2.7
|
||||
ports: ['8086:8086']
|
||||
```
|
||||
|
||||
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
|
||||
|
||||
---
|
||||
|
||||
## Debug recipes
|
||||
|
||||
| Symptom | First thing to check | Where to look |
|
||||
|:---|:---|:---|
|
||||
| Editor throws `legacy asset field(s) [supplier, ...]` on deploy | Flow predates the AssetResolver refactor. Re-open the node, pick the model from the asset menu, save. The registry derives supplier / category / type. | `src/nodeClass.js` `_rejectLegacyAssetFields`. |
|
||||
| Status badge shows `⚠ <message>` (yellow ring) | `getFluidCompatibility().status` is `mismatch` or `conflict`. An upstream source advertised a service type that doesn't match this valve's expected type. | `src/fluid/fluidCompatibility.js`, `getFluidCompatibility()`. |
|
||||
| `delta_predicted_pressure` stuck at `0` or missing | `kv` is 0 (valve closed), the FSM isn't in `operational` / `accelerating` / `decelerating`, or no flow has landed. For gas flow, also needs a finite `downstream_measured_pressure`. | `state.getCurrentState()`, `percentageOpen`, `MeasurementRouter.updateDeltaP`. |
|
||||
| `set.position` has no effect | Source not in `mode.allowedSources[currentMode]`. Watch for `Source '...' is not valid for mode '...'` in the warn log. | `src/flow/flowController.js` `isValidSourceForMode`. |
|
||||
| `data.flow` payloads aren't reflected on Port 0 | Payload shape: `{variant: 'measured'\|'predicted', value: <number>, position: <string>, unit?: 'm3/h'}`. Missing `variant` warns `Unrecognized variant '...' for flow update`. Missing `value` warns `Received null or undefined value for flow update`. | `src/measurement/measurementRouter.js` `updateFlow`. |
|
||||
| Gas-flow deltaP saturates at a ceiling | The choked-flow cap fired (`isChoked: true` in `hydraulicDiagnostics`). Increase `gasChokedRatioLimit` or revise downstream pressure. | `src/hydraulicModel.js` `_calculateGasDeltaP`. |
|
||||
| `query.curve` returns empty `valveCurve` | `asset.model` not found by `assetResolver`; the predictor falls back to inline `asset.valveCurve` — check that exists. | `src/curve/supplierCurve.js` `SupplierCurvePredictor.snapshot()`. |
|
||||
| FSM stuck in `accelerating` / `decelerating` | A move was aborted with `returnToOperationalOnAbort = false`. Send a new `set.position` — the residue handler in `state.moveTo` transitions back to `operational` first. | `generalFunctions/src/state/state.js` `moveTo` residue branch. |
|
||||
| Per-valve Port 0 key names differ from what your dashboard expects | valve uses `<position>_<variant>_<type>` (e.g. `delta_predicted_pressure`, `downstream_measured_flow`). `rotatingMachine` uses `<type>.<variant>.<position>.<childId>`. Don't mix them. | `src/io/output.js` `buildOutput`. |
|
||||
|
||||
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, FSM, hydraulic-model pipeline |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [valveGroupControl — Examples](https://gitea.wbd-rd.nl/RnD/valveGroupControl/wiki/Reference-Examples) | Group-control demo flows |
|
||||
| [EVOLV — Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where valve fits in a larger plant |
|
||||
111
wiki/Reference-Limitations.md
Normal file
111
wiki/Reference-Limitations.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Reference — Limitations
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> What `valve` does not do, current rough edges, and open questions. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
|
||||
>
|
||||
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
|
||||
|
||||
---
|
||||
|
||||
## When you would not use this node
|
||||
|
||||
| Scenario | Use instead |
|
||||
|:---|:---|
|
||||
| A fixed-restriction orifice (no actuator, no curve) | Model the deltaP externally; valve assumes a position-controlled Kv lookup. |
|
||||
| A non-return / check valve (no motorised actuation) | Don't use valve — no FSM-driven position control is exposed for this case. |
|
||||
| A pump or compressor (rotating equipment on a Q–H curve) | `rotatingMachine` — it loads a flow / power curve and predicts the operating point. |
|
||||
| A throttling device with no known Kv curve | Without `asset.valveCurve` or a registry-resolved model, the predictor stays empty and deltaP recomputes return null. There is no fallback hydraulic model. |
|
||||
| A grouped valve manifold | `valveGroupControl` — instantiate this as a child. |
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
### Gas-choke detection is a hard cap
|
||||
|
||||
`hydraulicModel.js` `_calculateGasDeltaP` caps the effective deltaP at `gasChokedRatioLimit * P2_abs` once the raw deltaP exceeds that threshold. The result is a discontinuous step in deltaP — chart traces show a sharp ceiling rather than a smooth choked-flow transition. The `isChoked` flag in `hydraulicDiagnostics` lets consumers detect the regime. Tracked.
|
||||
|
||||
### Single-source pressure for the deltaP recompute
|
||||
|
||||
`MeasurementRouter` looks for `pressure.measured.downstream` (preferred) or `pressure.predicted.downstream` to feed the gas-flow formula. There is no fallback if both are missing — the hydraulic model returns `null` and `delta_predicted_pressure` simply doesn't get written. The liquid-flow path doesn't need downstream pressure. Tracked.
|
||||
|
||||
### Multi-parent registration
|
||||
|
||||
Allowed but not exercised in production tests. valve overrides `BaseDomain.registerChild` with `FluidCompatibility.registerChild`, which records upstream sources and aggregates `serviceType`. Teardown ordering (parent gone first vs valve gone first) is not test-covered. Open question.
|
||||
|
||||
### `flowController.handleInput` only gates by source, not action
|
||||
|
||||
The schema's `mode.allowedActions` is defined but `flowController.handleInput` only enforces `isValidSourceForMode`. A `cmd.shutdown` from a source allowed for the current mode will fire regardless of whether the schema lists `execSequence` in `allowedActions[mode]`. This differs from `rotatingMachine`, which gates both. TODO: confirm whether this is intentional (valve's reduced operational set) or a backlog item.
|
||||
|
||||
### `execSequence` legacy umbrella
|
||||
|
||||
The `execSequence` topic (with `payload.action = 'startup' | 'shutdown' | 'emergencystop'`) is kept alive for legacy flows. The handler demuxes to the canonical topic; the deprecation warning fires once per session. Scheduled for removal in Phase 7. Use `cmd.startup` / `cmd.shutdown` / `cmd.estop` instead.
|
||||
|
||||
### `data.flow` payloads don't clear stale values
|
||||
|
||||
If a flow source stops emitting, the last-known value persists in MeasurementContainer. There is no TTL and no explicit clear topic. Workaround: send `value: 0` explicitly. The deltaP recompute then writes 0 (or `null` if it short-circuits on `flow === 0`). Tracked.
|
||||
|
||||
### Editor cosmetics don't reflect `asset` derivation
|
||||
|
||||
The editor form may still expose supplier / category / type fields even though `_rejectLegacyAssetFields` rejects them on save. Re-saving an old flow surfaces the legacy-fields error until each valve is re-opened and the model re-picked. Cosmetic; the registry is the source of truth.
|
||||
|
||||
### No `entermaintenance` / `exitmaintenance` sequences out of the box
|
||||
|
||||
`valve.json` ships `startup` / `shutdown` / `emergencystop` / `boot` sequences only. `maintenance` is reachable via `set.mode = maintenance`, but there is no canned state-transition sequence for entering / leaving. `rotatingMachine` defines these in its own config — valve does not. TODO: confirm intentional.
|
||||
|
||||
### Position residue handling depends on shared state machine
|
||||
|
||||
The residue handler that lets a mid-decel `set.position` re-engage cleanly lives in `generalFunctions/src/state/state.js` `moveTo`. valve inherits the behaviour but the integration test coverage is thin. The sequence-abort token mechanism (`rotatingMachine`, 2026-05-15) may or may not apply to valve's shutdown ramp; TODO: confirm from `FlowController.executeSequence` behaviour when a new `set.position` arrives during the pre-shutdown ramp-to-zero.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (tracked)
|
||||
|
||||
| Question | Where it lives |
|
||||
|:---|:---|
|
||||
| Gate `mode.allowedActions` in `flowController.handleInput` for parity with `rotatingMachine`? | Internal |
|
||||
| Add `entermaintenance` / `exitmaintenance` sequences to `valve.json`? | Internal |
|
||||
| Multi-parent teardown ordering | Internal |
|
||||
| Add an explicit `data.clear-flow` topic for stale flow cleanup | Internal |
|
||||
| Smooth choked-flow transition instead of a hard cap | `hydraulicModel.js` |
|
||||
| Does the sequence-abort token mechanism (rotatingMachine, 2026-05-15) apply to valve's pre-shutdown ramp? | Internal — flagged in [Architecture](Reference-Architecture#fsm) |
|
||||
| Phase 7 removal of `execSequence` umbrella + legacy aliases | Internal |
|
||||
| `calculationMode` (`low` / `medium` / `high`) — does the dispatch path consult it? | `valve.json` schema vs source |
|
||||
|
||||
---
|
||||
|
||||
## Migration notes
|
||||
|
||||
### From pre-AssetResolver
|
||||
|
||||
Old flows saved with `supplier`, `category`, or `assetType` fields will throw on deploy:
|
||||
|
||||
```
|
||||
valve: legacy asset field(s) [supplier, category] 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.
|
||||
```
|
||||
|
||||
The fix is mechanical: open each valve node, re-pick the model from the asset menu, save. No data is lost — the registry has the same supplier / category / type the old flow carried.
|
||||
|
||||
### From inline `valveCurve` only
|
||||
|
||||
valve still supports `asset.valveCurve` as a fully inline curve fallback when no `asset.model` is set. The predictor logs which `(densityKey, diameterKey)` lane of the dataset it selected. If you migrate from inline-only to a registry-resolved model, the curve may pick a slightly different lane and your operating-point predictions will shift — review on the dashboard before declaring done.
|
||||
|
||||
### From `setpoint` topic name (pre-canonical)
|
||||
|
||||
The old topic without a `set.` prefix has been retired. Use `set.position` (alias `execMovement`) for position setpoints.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, FSM, hydraulic-model pipeline |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||
| [valveGroupControl — Limitations](https://gitea.wbd-rd.nl/RnD/valveGroupControl/wiki/Reference-Limitations) | Where the parent's aggregator sits |
|
||||
19
wiki/_Sidebar.md
Normal file
19
wiki/_Sidebar.md
Normal file
@@ -0,0 +1,19 @@
|
||||
### valve
|
||||
|
||||
- [Home](Home)
|
||||
|
||||
**Reference**
|
||||
|
||||
- [Contracts](Reference-Contracts)
|
||||
- [Architecture](Reference-Architecture)
|
||||
- [Examples](Reference-Examples)
|
||||
- [Limitations](Reference-Limitations)
|
||||
|
||||
**Related**
|
||||
|
||||
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
|
||||
- [valveGroupControl wiki](https://gitea.wbd-rd.nl/RnD/valveGroupControl/wiki/Home)
|
||||
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
|
||||
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
|
||||
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)
|
||||
11
wiki/_partial-datamodel.md.template
Normal file
11
wiki/_partial-datamodel.md.template
Normal 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` |
|
||||
Reference in New Issue
Block a user