Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
362 lines
14 KiB
Markdown
362 lines
14 KiB
Markdown
# Reference — Examples
|
|
|
|

|
|
|
|
> [!NOTE]
|
|
> Usage patterns: how a consumer node imports and extends the library's base classes, how to register topic commands, how to declare child routes, and how to chain `MeasurementContainer` writes. Snippets are pulled from real consumer nodes (`rotatingMachine`, `pumpingStation`, `machineGroupControl`). For an intuitive overview, return to [Home](Home).
|
|
|
|
---
|
|
|
|
## 1. Single root import — the contract
|
|
|
|
```js
|
|
const {
|
|
BaseDomain, BaseNodeAdapter, UnitPolicy, ChildRouter, HealthStatus, LatestWinsGate,
|
|
MeasurementContainer, outputUtils, logger, statusBadge,
|
|
convert, PIDController,
|
|
} = require('generalFunctions');
|
|
```
|
|
|
|
The package root (`require('generalFunctions')`) is the only contractual import path. Internal subpaths (`require('generalFunctions/src/domain/UnitPolicy')`) are NOT contractual and may move at any time.
|
|
|
|
---
|
|
|
|
## 2. Extending `BaseDomain` — pattern from `pumpingStation/specificClass.js`
|
|
|
|
```js
|
|
const { BaseDomain, UnitPolicy } = require('generalFunctions');
|
|
|
|
class PumpingStation extends BaseDomain {
|
|
// static name must match src/configs/<nodeName>.json on the library side.
|
|
static name = 'pumpingStation';
|
|
|
|
// Declarative unit triple. canonical = internal storage. output = render units.
|
|
// curve = supplier curve units (only if the node consumes a characteristic curve).
|
|
static unitPolicy = UnitPolicy.declare({
|
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
|
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
|
requireUnitForTypes: ['pressure', 'flow', 'power', 'temperature'],
|
|
});
|
|
|
|
configure() {
|
|
// Named child getters — readable in code, but the registry remains source of truth.
|
|
this.declareChildGetter('machines', 'machine');
|
|
this.declareChildGetter('machineGroups', 'machinegroup');
|
|
|
|
// Declarative child routing — no per-node registerChild switch needed.
|
|
this.router
|
|
.onRegister('machinegroup', (child) => this._onMachineGroupRegistered(child))
|
|
.onMeasurement('measurement', { type: 'level' }, (data, child) => {
|
|
this._onLevel(data.value, data);
|
|
});
|
|
}
|
|
|
|
getOutput() {
|
|
return {
|
|
...this.measurements.getFlattenedOutput(),
|
|
...this.basin.snapshot(),
|
|
};
|
|
}
|
|
|
|
getStatusBadge() {
|
|
return statusBadge.compose(['filling', 'V=12.4/50.0 m³']);
|
|
}
|
|
}
|
|
|
|
module.exports = PumpingStation;
|
|
```
|
|
|
|
Key points:
|
|
|
|
- `static name = '...'` — tells `configManager.buildConfig()` which `src/configs/<n>.json` file to merge defaults from.
|
|
- `static unitPolicy` — pre-built `UnitPolicy` instance; `BaseDomain` passes `unitPolicy.containerOptions()` to the `MeasurementContainer` so it auto-converts on write.
|
|
- `configure()` is where you wire `ChildRouter` routes and instantiate concern modules. The constructor is owned by `BaseDomain`.
|
|
- `getOutput()` and `getStatusBadge()` are the only two methods `BaseNodeAdapter` calls on the domain to produce ports + status — everything else is event-driven.
|
|
|
|
---
|
|
|
|
## 3. Extending `BaseNodeAdapter` — pattern from `pumpingStation/nodeClass.js`
|
|
|
|
```js
|
|
const { BaseNodeAdapter } = require('generalFunctions');
|
|
const Domain = require('./specificClass');
|
|
const commands = require('./commands');
|
|
|
|
class nodeClass extends BaseNodeAdapter {
|
|
static DomainClass = Domain; // The specificClass to instantiate.
|
|
static commands = commands; // Array of command descriptors.
|
|
static tickInterval = 1000; // ms — only for time-driven math. Omit for event-driven nodes.
|
|
static statusInterval = 1000; // ms — how often to re-render the status badge.
|
|
|
|
// Translate Node-RED editor field values into the domain's config slice.
|
|
// The base class already merges schema defaults from src/configs/<nodeName>.json;
|
|
// this hook lets the adapter shape per-node values before the domain sees them.
|
|
buildDomainConfig(uiConfig, nodeId) {
|
|
return {
|
|
basin: {
|
|
volume: Number(uiConfig.basinVolume),
|
|
height: Number(uiConfig.basinHeight),
|
|
surfaceArea: Number(uiConfig.basinSurface),
|
|
},
|
|
hydraulics: {
|
|
inflowPipeArea: Number(uiConfig.inflowArea),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = nodeClass;
|
|
```
|
|
|
|
`BaseNodeAdapter` wires the full lifecycle: schema merge → domain instantiation → Port 2 registration after a 100 ms delay → status loop start → input dispatch via the registry → close handler that drains everything. The subclass only declares the static config and overrides `buildDomainConfig`.
|
|
|
|
---
|
|
|
|
## 4. Command descriptors with unit normalisation
|
|
|
|
```js
|
|
// src/commands/index.js
|
|
module.exports = [
|
|
{
|
|
topic: 'set.demand',
|
|
aliases: ['Qd'], // Legacy name — first use logs a one-time deprecation.
|
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
|
payloadSchema: { type: 'number' },
|
|
description: 'Operator demand setpoint. Unit-normalised before handler runs.',
|
|
handler: (source, msg) => { source.setDemand(msg.payload); },
|
|
},
|
|
{
|
|
topic: 'cmd.startup',
|
|
payloadSchema: { type: 'none' },
|
|
description: 'Trigger startup sequence.',
|
|
handler: (source, msg) => { source.startup(msg.payload?.source); },
|
|
},
|
|
{
|
|
topic: 'set.flow-setpoint',
|
|
aliases: ['flowMovement'],
|
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
|
payloadSchema: { type: 'object', properties: { setpoint: { type: 'number' } } },
|
|
description: 'Set a flow-unit setpoint. Auto-converted to canonical m³/s.',
|
|
handler: (source, msg) => { source.setFlowSetpoint(msg.payload.setpoint); },
|
|
},
|
|
];
|
|
```
|
|
|
|
When `units` is declared, `CommandRegistry` reads `msg.unit` from the incoming message (falling back to `default`) and converts via the `convert` library to the canonical unit before invoking the handler. The handler always sees a canonical value — it never has to do its own unit conversion.
|
|
|
|
A free side-effect: every command descriptor with a `units` field contributes a row to the auto-generated `query.units` reply, which dashboards can use to introspect a node's unit contract at runtime.
|
|
|
|
---
|
|
|
|
## 5. Declarative child routing — `ChildRouter`
|
|
|
|
```js
|
|
configure() {
|
|
this.router
|
|
// Trigger a callback the first time a machine-group child registers.
|
|
.onRegister('machinegroup', (child) => {
|
|
this.logger.info(`MachineGroup ${child.general.id} attached`);
|
|
this._mgcChild = child;
|
|
})
|
|
|
|
// Filter on a measurement child's asset.type.
|
|
.onMeasurement('measurement', { type: 'pressure', position: 'upstream' }, (data, child) => {
|
|
this._onUpstreamPressure(data.value, data);
|
|
})
|
|
|
|
.onMeasurement('measurement', { type: 'pressure', position: 'downstream' }, (data, child) => {
|
|
this._onDownstreamPressure(data.value, data);
|
|
})
|
|
|
|
.onMeasurement('measurement', { type: 'flow' }, (data, child) => {
|
|
// No position filter → matches any position.
|
|
this._onFlow(data.value, data, child);
|
|
})
|
|
|
|
// React to a child's own predictions (e.g. a downstream MGC publishing predicted group flow).
|
|
.onPrediction('machinegroup', { type: 'flow' }, (data, child) => {
|
|
this._onChildPrediction(data, child);
|
|
});
|
|
}
|
|
```
|
|
|
|
Pre-refactor, the same code lived as a `registerChild(child)` method on every node with a 30-line `switch (child.softwareType)` block. `ChildRouter` makes the wiring declarative; the underlying `childRegistrationUtils` calls are unchanged.
|
|
|
|
---
|
|
|
|
## 6. `MeasurementContainer` chaining
|
|
|
|
```js
|
|
// Write: chainable, auto-converts from srcUnit to canonical per UnitPolicy.
|
|
this.measurements
|
|
.type('pressure')
|
|
.variant('measured')
|
|
.position('upstream', child.general.id) // childId narrows the storage slot.
|
|
.value(3.4, Date.now(), 'mbar'); // value, timestamp, srcUnit.
|
|
|
|
// Read: latest value in canonical or arbitrary unit.
|
|
const p_Pa = this.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue();
|
|
const p_mbar = this.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue('mbar');
|
|
|
|
// Read: windowed average.
|
|
const avg = this.measurements.type('flow').variant('measured').position('atequipment').getAverage('m3/h');
|
|
|
|
// Read: difference over a time window (e.g. for integrators).
|
|
const dV = this.measurements
|
|
.type('level').variant('measured').position('atequipment')
|
|
.difference({ from: Date.now() - 60_000, to: Date.now(), unit: 'm' });
|
|
|
|
// Introspect: the 4-segment flat output (used by getOutput()).
|
|
const flat = this.measurements.getFlattenedOutput();
|
|
// → {
|
|
// 'pressure.measured.upstream.dashboard-sim-upstream': 0,
|
|
// 'pressure.measured.downstream.dashboard-sim-downstream': 1100,
|
|
// 'flow.predicted.downstream.default': 12.4,
|
|
// 'power.predicted.atequipment.default': 18.2,
|
|
// }
|
|
```
|
|
|
|
Key shape: `<type>.<variant>.<position>.<childId>`. Position labels are always lowercase in keys (`atequipment`, not `atEquipment`). The `childId` is `default` for the node's own predictions; otherwise the registering child's `general.id`.
|
|
|
|
---
|
|
|
|
## 7. `HealthStatus` — prediction quality / drift state
|
|
|
|
```js
|
|
const { HealthStatus } = require('generalFunctions');
|
|
|
|
// Ok state.
|
|
const ok = HealthStatus.ok('Pressure source healthy', 'real-child');
|
|
|
|
// Degraded with reason flags.
|
|
const warm = HealthStatus.degraded(1, ['pressure_init_warming'], 'Pressure not yet initialised', 'dashboard-sim');
|
|
|
|
// Compose multiple sub-statuses into the worst case.
|
|
const overall = HealthStatus.compose([ok, warm, flowDrift, powerDrift]);
|
|
// → frozen { level: max(level_i), flags: union(flags_i), message, source }
|
|
```
|
|
|
|
Levels: `0 = good`, `1 = warming`, `2 = degraded`, `3 = invalid`. The shape is frozen; you cannot mutate a `HealthStatus` instance, only compose new ones.
|
|
|
|
---
|
|
|
|
## 8. `LatestWinsGate` — latest-write-wins async dispatch
|
|
|
|
```js
|
|
const { LatestWinsGate } = require('generalFunctions');
|
|
|
|
// Construct.
|
|
this._dispatchGate = new LatestWinsGate({
|
|
dispatch: async (value) => { await this._reallySetDemand(value); },
|
|
logger: this.logger,
|
|
});
|
|
|
|
// Fire (non-blocking; intermediate calls are superseded).
|
|
this._dispatchGate.fire(newDemand);
|
|
|
|
// Fire and await result.
|
|
const result = await this._dispatchGate.fireAndWait(newDemand);
|
|
if (result === LatestWinsGate.SUPERSEDED) {
|
|
// A newer fire pre-empted this one; nothing to do.
|
|
}
|
|
|
|
// Wait until idle (useful in tests and clean shutdown).
|
|
await this._dispatchGate.drain();
|
|
```
|
|
|
|
Originally extracted from `machineGroupControl` to coordinate fast successive demand changes against a slow dispatcher. Now shared by `pumpingStation`, `valveGroupControl`, `machineGroupControl`.
|
|
|
|
---
|
|
|
|
## 9. PID controller
|
|
|
|
```js
|
|
const { createPidController } = require('generalFunctions');
|
|
|
|
const pid = createPidController({
|
|
kp: 1.2, ki: 0.4, kd: 0.05,
|
|
outputLimits: { min: 0, max: 100 },
|
|
rateLimitPerSec: 5, // %/s ramp cap
|
|
derivativeFilterTau: 0.2, // first-order LPF on the D term
|
|
antiWindup: 'clamping',
|
|
setpoint: 50,
|
|
});
|
|
|
|
pid.setSetpoint(60); // bumpless on the next compute call
|
|
const output = pid.compute(processValue); // discrete tick
|
|
```
|
|
|
|
For cascaded loops (outer = level → inner = flow), use `createCascadePidController({ outer: {...}, inner: {...} })`.
|
|
|
|
---
|
|
|
|
## 10. Status badge composition
|
|
|
|
```js
|
|
const { statusBadge } = require('generalFunctions');
|
|
|
|
getStatusBadge() {
|
|
const state = this.state.getCurrentState();
|
|
const flowFmt = `${(this._predictedFlow * 3600).toFixed(1)} m³/h`;
|
|
const powerFmt = `${(this._predictedPower / 1000).toFixed(1)} kW`;
|
|
|
|
if (state === 'emergencystop') {
|
|
return statusBadge.error('E-stop active');
|
|
}
|
|
if (state === 'idle') {
|
|
return statusBadge.idle('idle');
|
|
}
|
|
return statusBadge.compose([state, flowFmt, powerFmt]);
|
|
// → { fill: 'green', shape: 'dot', text: 'operational | 12.4 m³/h | 18.2 kW' }
|
|
}
|
|
```
|
|
|
|
`StatusUpdater` polls `getStatusBadge()` every `statusInterval` ms and calls `node.status(...)`. Text clipped to 60 chars to fit the Node-RED editor.
|
|
|
|
---
|
|
|
|
## 11. Unit conversion (when you really do need it directly)
|
|
|
|
```js
|
|
const { convert } = require('generalFunctions');
|
|
|
|
const m3s = convert(80).from('m3/h').to('m3/s'); // 0.0222...
|
|
|
|
// What units can a measure take?
|
|
const units = convert.possibilities('volumeFlowRate');
|
|
// → ['m3/s', 'm3/h', 'l/s', 'l/min', 'gpm', ...]
|
|
```
|
|
|
|
In domain code, you should usually be relying on the `UnitPolicy` + `MeasurementContainer` pipeline to convert at the boundary — calling `convert` directly is a smell unless you're processing a one-off ad-hoc payload.
|
|
|
|
---
|
|
|
|
## 12. Loading a per-node JSON schema
|
|
|
|
```js
|
|
const { configManager } = require('generalFunctions');
|
|
const cm = new configManager();
|
|
|
|
// What schemas are registered?
|
|
const names = cm.getAvailableConfigs();
|
|
// → ['baseConfig', 'rotatingMachine', 'pumpingStation', 'measurement', ...]
|
|
|
|
// Merge editor values over schema defaults.
|
|
const merged = cm.buildConfig('pumpingStation', uiConfig, nodeId, domainSlice);
|
|
```
|
|
|
|
`BaseNodeAdapter` does this for you in the constructor. Direct use is for tests and migration tooling.
|
|
|
|
---
|
|
|
|
## Related pages
|
|
|
|
| Page | Why |
|
|
|:---|:---|
|
|
| [Home](Home) | Intuitive overview |
|
|
| [Reference — Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
|
|
| [Reference — Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
|
|
| [Reference — Limitations](Reference-Limitations) | Known issues, deprecations, stability rules |
|
|
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative base-class spec |
|
|
| [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | A consumer node that uses every primitive |
|