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>
This commit is contained in:
361
wiki/Reference-Examples.md
Normal file
361
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user