Files
generalFunctions/wiki/Reference-Examples.md
znetsixe 8b28f8969e docs(wiki): full 5-page wiki matching the rotatingMachine reference format
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>
2026-05-19 09:42:15 +02:00

362 lines
14 KiB
Markdown

# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-48fa543-blue)
> [!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 &mdash; 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` &mdash; 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 = '...'` &mdash; tells `configManager.buildConfig()` which `src/configs/<n>.json` file to merge defaults from.
- `static unitPolicy` &mdash; 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 &mdash; everything else is event-driven.
---
## 3. Extending `BaseNodeAdapter` &mdash; 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 &rarr; domain instantiation &rarr; Port 2 registration after a 100 ms delay &rarr; status loop start &rarr; input dispatch via the registry &rarr; 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 &mdash; 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 &mdash; `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` &mdash; 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` &mdash; 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 &rarr; 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 &mdash; 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 &mdash; Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
| [Reference &mdash; Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
| [Reference &mdash; 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 |