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

14 KiB

Reference — Examples

code-ref

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.


1. Single root import — the contract

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

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

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

// 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

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

// 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

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

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

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

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)

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

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.


Page Why
Home Intuitive overview
Reference — Contracts Full public API surface, per-export stability tags
Reference — Architecture Three-tier rule, src/ layout, consumer responsibilities
Reference — Limitations Known issues, deprecations, stability rules
Platform CONTRACTS.md The authoritative base-class spec
rotatingMachine wiki A consumer node that uses every primitive