9 Commits

Author SHA1 Message Date
znetsixe
5c091cdce9 feat(config): add planner.emergencyPressurePa for MGC rendezvous emergency bypass
Documented, defaults to null (inert). When set, the MGC pre-empts an in-flight
rendezvous lock and re-plans immediately if the resolved header pressure reaches
this canonical-Pa threshold. Mechanism is wired + tested; never fires until a
real value is configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:47:57 +02:00
znetsixe
c0be50d02c feat(output): alwaysEmit fields, drop undefined/empty Influx tags, time-based movement re-basing
- OutputUtils: new `alwaysEmit` option exempts named fields from delta
  compression so steady-state values (e.g. ctrl) trace continuously.
- flattenTags now drops null/undefined/empty-string tag values, fixing
  literal `category="undefined"` tags that split every Grafana series in two.
- BaseNodeAdapter wires `static alwaysEmitFields` from the subclass.
- movementManager: track position by elapsed wall-time and capture partial
  progress on abort, so a fast-re-commanding parent can't freeze an actuator
  at its start position.
- Tests for the above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:14 +02:00
znetsixe
bc79de133e fix(influx): accept tagCode camelCase and emit positionVsParent tag
The asset config standardised on tagCode (camelCase) but the InfluxDB
tag emitter still read the lowercase tagcode, so any node saved through
the new editor silently emitted tags.tagcode: undefined. Read both
spellings so old + new configs both produce the tag.

Also surfaces functionality.positionVsParent as a tag so dashboards
can filter by upstream/downstream side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:29:39 +02:00
znetsixe
6c4db03aba feat(formatters): frost handoff formatter + config wiring
Adds src/helper/formatters/frostFormatter.js — structured-envelope formatter parallel to the InfluxDB one. Produces dbase messages that a CoreSync collector can forward to FROST/SensorThings without coupling producing nodes to FROST HTTP details. Registered in formatters/index.js.

Config additions in 4 node schemas (machineGroupControl, measurement, pumpingStation, rotatingMachine) expose the new dbase format option in the editor.

Part of the CoreSync FROST handoff initiative — see superproject CORESYNC_FROST_INTERVIEW_HANDOFF.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:06:39 +02:00
znetsixe
ae30cef89c feat(pumpingStation schema): add holdLevel + deadZoneKeepAlivePercent; slim npm pack
Schema:
- holdLevel (optional, default null → equals startLevel): 0 % ramp foot for
  the levelbased curve. When raised above startLevel, pumps engage at
  startLevel but hold at MGC flow.min across [startLevel, holdLevel] before
  the ramp begins.
- deadZoneKeepAlivePercent (default 1): percent emitted across the
  [stopLevel, startLevel] falling-edge keep-alive band.
- Refreshed startLevel / stopLevel descriptions: hysteresis is no longer
  coupled to inflowLevel (was misleading).

Packaging:
- Add .npmignore mirroring .gitignore plus the dev-only trees (test/,
  wiki/, scripts/, .claude/, …) so npm pack doesn't ship the doc set.
- Extend .gitignore with the standard dev-artifact deny list so both
  files share the same baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:35:49 +02:00
znetsixe
8252a5f898 fix(schemas): drop dead config — allowedActions (valve/VGC) + calculationMode (RM/valve/VGC)
Code audit across all consumers found:

- `calculationMode` is declared in rotatingMachine.json, valve.json, and
  valveGroupControl.json schemas but NEVER read by any code. Pure dead
  config — entered values are silently discarded. Removed from all three.

- `allowedActions` is declared in valve.json and valveGroupControl.json
  but their `flowController.handleInput` only calls
  `isValidSourceForMode`, never `isValidActionForMode`. The schema would
  invite users to configure action allow-lists that never gate anything.
  Removed from both. Kept in `rotatingMachine.json` and
  `machineGroupControl.json` where the action allow-list IS enforced
  (specificClass / handlers / strategies).

Schema fixes only — no runtime behaviour changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:05:34 +02:00
znetsixe
4f715e8ad6 fix(reactor schema): timeStep unit was "h" but engine treats input as seconds
reactor.json declared `timeStep.unit = "h"` and `default = 0.001`, but:

- reactor.html labels the field [s] and defaults to 1.
- baseEngine.js line 40 converts via (1/86400) — seconds-per-day —
  meaning the engine internally treats the input as seconds.

A reader trusting the schema would have entered an hours value; the
engine would then have run the integrator at 1/3600× the intended step,
silently producing wrong rates.

Schema now matches the actual contract: `unit = "s"`, `default = 1`,
`min = 0.001` (1 ms minimum). Description block calls out the
seconds→days conversion so future readers don't need to dig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:01:02 +02:00
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
znetsixe
48fa54363d docs: drop substrate_score reference from wiki Home (repo-mem MCP retired)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:30:26 +02:00
22 changed files with 1517 additions and 513 deletions

9
.gitignore vendored
View File

@@ -1,5 +1,14 @@
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
# in sync — anything that shouldn't be committed AND shouldn't ship in the
# npm tarball goes in both files.
node_modules/ node_modules/
# Local stub generated by `npm install` in the submodule directory. # Local stub generated by `npm install` in the submodule directory.
# generalFunctions has no production deps of its own. # generalFunctions has no production deps of its own.
package-lock.json package-lock.json
*.tgz
.env
.env.*
.DS_Store
npm-debug.log*

28
.npmignore Normal file
View File

@@ -0,0 +1,28 @@
# === Mirrors .gitignore — items below this block are also excluded from
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
# the .gitignore inheritance (silent + surprising). ===
node_modules/
package-lock.json
*.tgz
.env
.env.*
.DS_Store
npm-debug.log*
# === Dev-only content the npm tarball doesn't need ===
# Tests + their harness — consumers load index.js, not the test tree.
test/
*.test.js
# Wiki / docs — useful in the repo, big in the pack.
wiki/
# One-off maintenance tooling (wiki generator, etc.) not used at runtime.
scripts/
# Project memory + IDE configs.
.claude/
.codex/
.repo-mem/
CLAUDE.md
CLAUDE.local.md

View File

@@ -134,6 +134,7 @@
"type": "enum", "type": "enum",
"values": [ "values": [
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." }, { "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
{ "value": "json", "description": "Raw JSON payload." }, { "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." } { "value": "csv", "description": "CSV-formatted payload." }
], ],
@@ -148,6 +149,13 @@
"type": "boolean", "type": "boolean",
"description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)." "description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)."
} }
},
"emergencyPressurePa": {
"default": null,
"rules": {
"type": "number",
"description": "Safety threshold (canonical Pa) for the rendezvous emergency bypass. While a rendezvous is in flight new setpoints are locked out and queued sequentially; if the resolved header pressure reaches this value the lock is pre-empted and the group re-plans immediately. Null/unset (the default) leaves the bypass mechanism wired but INERT — it never fires until a real threshold is configured."
}
} }
}, },
"mode": { "mode": {

View File

@@ -120,6 +120,7 @@
"type": "enum", "type": "enum",
"values": [ "values": [
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." }, { "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
{ "value": "json", "description": "Raw JSON payload." }, { "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." } { "value": "csv", "description": "CSV-formatted payload." }
], ],

View File

@@ -166,6 +166,10 @@
"value": "influxdb", "value": "influxdb",
"description": "InfluxDB telemetry payload." "description": "InfluxDB telemetry payload."
}, },
{
"value": "frost",
"description": "FROST/SensorThings CoreSync payload."
},
{ {
"value": "json", "value": "json",
"description": "JSON payload." "description": "JSON payload."
@@ -498,7 +502,7 @@
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "min": 0,
"description": "Pump-on threshold (engagement edge for stopLevel hysteresis). Demand stays at 0 % between startLevel and inflowLevel — the ramp foot is inflowLevel, not startLevel. The ramp itself scales 0 → 100 % across [inflowLevel, maxLevel]. When enableShiftedRamp is on, startLevel also serves as the bottom of the held-then-ramp curve during draining." "description": "Pump-on threshold (rising-edge engagement). Pumps stay off below startLevel until level rises through it; once engaged they remain on until level drops through stopLevel (falling-edge). Also serves as the bottom of the held-then-ramp curve during draining when enableShiftedRamp is on. Independent of basin geometry: NOT clamped against inflowLevel."
} }
}, },
"stopLevel": { "stopLevel": {
@@ -507,7 +511,25 @@
"type": "number", "type": "number",
"nullable": true, "nullable": true,
"min": 0, "min": 0,
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel. NOTE: schema default stays null so omitting stopLevel keeps the hysteresis inactive (matching levelBased.js); the editor HTML provides a realistic 0.5 m default for drag-in UX." "description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Does NOT shape the ramp. Pair with a startLevel above stopLevel to get hysteresis (engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel. NOTE: schema default stays null so omitting stopLevel keeps the hysteresis inactive; the editor HTML provides a realistic 0.5 m default for drag-in UX."
}
},
"holdLevel": {
"default": null,
"rules": {
"type": "number",
"nullable": true,
"min": 0,
"description": "Optional `0 %` ramp foot. When set, pumps engage at startLevel but hold at 0 % (= flow.min via MGC) across [startLevel, holdLevel], then ramp 0 → 100 % across [holdLevel, maxLevel]. Default null → equals startLevel, i.e. no hold band and the ramp starts immediately at startLevel. Must satisfy startLevel ≤ holdLevel ≤ maxLevel."
}
},
"deadZoneKeepAlivePercent": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"max": 100,
"description": "Percent emitted to MGC across the falling-edge keep-alive band [stopLevel, startLevel] (i.e. once engaged, while draining back below startLevel but still above stopLevel). 0 maps to flow.min; the 1 % default sits just above min so MGC keeps at least one pump rotating instead of resting at the absolute minimum."
} }
}, },
"maxLevel": { "maxLevel": {

View File

@@ -136,12 +136,12 @@
} }
}, },
"timeStep": { "timeStep": {
"default": 0.001, "default": 1,
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0.0001, "min": 0.001,
"unit": "h", "unit": "s",
"description": "Integration time step for the reactor model." "description": "Integration time step in seconds. The kinetics engine converts to days internally (timeStep / 86400) before each ASM Euler step; the HTML editor labels this field [s] and tests assume seconds. Do not change the unit without updating baseEngine.js line 40 in the reactor submodule."
} }
} }
}, },

View File

@@ -134,6 +134,7 @@
"type": "enum", "type": "enum",
"values": [ "values": [
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." }, { "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
{ "value": "json", "description": "Raw JSON payload." }, { "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." } { "value": "csv", "description": "CSV-formatted payload." }
], ],
@@ -459,27 +460,6 @@
"description": "Predefined sequences of states for the machine." "description": "Predefined sequences of states for the machine."
}, },
"calculationMode": {
"default": "medium",
"rules": {
"type": "enum",
"values": [
{
"value": "low",
"description": "Calculations run at fixed intervals (time-based)."
},
{
"value": "medium",
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
},
{
"value": "high",
"description": "Calculations run on all event-driven info, including every movement."
}
],
"description": "The frequency at which calculations are performed."
}
},
"flowNumber": { "flowNumber": {
"default": 1, "default": 1,
"rules": { "rules": {

View File

@@ -205,47 +205,6 @@
"description": "The operational mode of the machine." "description": "The operational mode of the machine."
} }
}, },
"allowedActions":{
"default":{},
"rules": {
"type": "object",
"schema":{
"auto": {
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in auto mode."
}
},
"virtualControl": {
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in virtualControl mode."
}
},
"fysicalControl": {
"default": ["statusCheck", "emergencyStop"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in fysicalControl mode."
}
},
"maintenance": {
"default": ["statusCheck"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in maintenance mode."
}
}
},
"description": "Information about valid command sources recognized by the machine."
}
},
"allowedSources":{ "allowedSources":{
"default": {}, "default": {},
"rules": { "rules": {
@@ -342,27 +301,6 @@
}, },
"description": "Predefined sequences of states for the machine." "description": "Predefined sequences of states for the machine."
},
"calculationMode": {
"default": "medium",
"rules": {
"type": "enum",
"values": [
{
"value": "low",
"description": "Calculations run at fixed intervals (time-based)."
},
{
"value": "medium",
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
},
{
"value": "high",
"description": "Calculations run on all event-driven info, including every movement."
}
],
"description": "The frequency at which calculations are performed."
}
} }
} }

View File

@@ -176,47 +176,6 @@
"description": "The operational mode of the valveGroupControl." "description": "The operational mode of the valveGroupControl."
} }
}, },
"allowedActions":{
"default":{},
"rules": {
"type": "object",
"schema":{
"auto": {
"default": ["statusCheck", "execSequence", "emergencyStop", "valvePositionChange", "totalFlowChange", "valveDeltaPchange"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in auto mode."
}
},
"virtualControl": {
"default": ["statusCheck", "execSequence", "emergencyStop", "valvePositionChange", "totalFlowChange", "valveDeltaPchange"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in virtualControl mode."
}
},
"fysicalControl": {
"default": ["statusCheck", "emergencyStop"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in fysicalControl mode."
}
},
"maintenance": {
"default": ["statusCheck"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in maintenance mode."
}
}
},
"description": "Information about valid command sources recognized by the valve."
}
},
"allowedSources":{ "allowedSources":{
"default": {}, "default": {},
"rules": { "rules": {
@@ -346,26 +305,5 @@
}, },
"description": "Predefined sequences of states for the valveGroupControl." "description": "Predefined sequences of states for the valveGroupControl."
},
"calculationMode": {
"default": "medium",
"rules": {
"type": "enum",
"values": [
{
"value": "low",
"description": "Calculations run at fixed intervals (time-based)."
},
{
"value": "medium",
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
},
{
"value": "high",
"description": "Calculations run on all event-driven info, including every movement."
}
],
"description": "The frequency at which calculations are performed."
}
} }
} }

View File

@@ -0,0 +1,23 @@
/**
* FROST handoff formatter
* -----------------------
* Keeps the same structured envelope as the InfluxDB formatter so a shared
* CoreSync collector can accept existing EVOLV dbase messages without coupling
* producing nodes to FROST HTTP details.
*/
function format(measurement, metadata) {
const { fields, tags, config } = metadata;
return {
measurement,
fields,
tags: tags || {},
timestamp: new Date().toISOString(),
source: {
nodeId: config?.general?.id,
softwareType: config?.functionality?.softwareType,
unit: config?.general?.unit || config?.asset?.unit,
},
};
}
module.exports = { format };

View File

@@ -14,6 +14,7 @@ const influxdbFormatter = require('./influxdbFormatter');
const jsonFormatter = require('./jsonFormatter'); const jsonFormatter = require('./jsonFormatter');
const csvFormatter = require('./csvFormatter'); const csvFormatter = require('./csvFormatter');
const processFormatter = require('./processFormatter'); const processFormatter = require('./processFormatter');
const frostFormatter = require('./frostFormatter');
// Built-in registry // Built-in registry
const registry = { const registry = {
@@ -21,6 +22,7 @@ const registry = {
json: jsonFormatter, json: jsonFormatter,
csv: csvFormatter, csv: csvFormatter,
process: processFormatter, process: processFormatter,
frost: frostFormatter,
}; };
/** /**

View File

@@ -2,8 +2,16 @@ const { getFormatter } = require('./formatters');
//this class will handle the output events for the node red node //this class will handle the output events for the node red node
class OutputUtils { class OutputUtils {
constructor() { // `options.alwaysEmit` is an optional list of field keys that bypass delta
// compression: they are re-emitted on every tick even when unchanged. Use it
// sparingly for slowly-varying values that must still trace as a continuous
// line downstream (e.g. a pump's realized control position `ctrl`, which sits
// constant in steady state and otherwise produces ~1 point per long stretch —
// invisible in a Grafana timeseries with createEmpty:false). Defaults to none,
// so existing nodes keep pure delta-compression behaviour.
constructor(options = {}) {
this.output = {}; this.output = {};
this.alwaysEmit = new Set(options.alwaysEmit || []);
} }
checkForChanges(output, format) { checkForChanges(output, format) {
@@ -13,7 +21,9 @@ class OutputUtils {
this.output[format] = this.output[format] || {}; this.output[format] = this.output[format] || {};
const changedFields = {}; const changedFields = {};
for (const key in output) { for (const key in output) {
if (Object.prototype.hasOwnProperty.call(output, key) && output[key] !== this.output[format][key]) { if (!Object.prototype.hasOwnProperty.call(output, key)) continue;
const forced = this.alwaysEmit.has(key) && output[key] !== undefined;
if (forced || output[key] !== this.output[format][key]) {
let value = output[key]; let value = output[key];
// For fields: if the value is an object (and not a Date), stringify it. // For fields: if the value is an object (and not a Date), stringify it.
if (value !== null && typeof value === 'object' && !(value instanceof Date)) { if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
@@ -79,7 +89,13 @@ class OutputUtils {
for (const key in obj) { for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]; const value = obj[key];
if (value !== null && typeof value === 'object' && !(value instanceof Date)) { // Skip tags that carry no information. When a config field is unset,
// extractRelevantConfig hands us `undefined`; stringifying that wrote
// literal `category="undefined"` / `geoLocation="undefined"` tags that
// clutter every Grafana legend and needlessly inflate tag cardinality.
// Drop null / undefined / empty-string before they reach InfluxDB.
if (value === null || value === undefined || value === '') continue;
if (typeof value === 'object' && !(value instanceof Date)) {
// Recursively flatten the nested object. // Recursively flatten the nested object.
const flatChild = this.flattenTags(value); const flatChild = this.flattenTags(value);
for (const childKey in flatChild) { for (const childKey in flatChild) {
@@ -104,9 +120,10 @@ class OutputUtils {
// functionality properties // functionality properties
softwareType: config.functionality?.softwareType, softwareType: config.functionality?.softwareType,
role: config.functionality?.role, role: config.functionality?.role,
positionVsParent: config.functionality?.positionVsParent,
// asset properties (exclude machineCurve) // asset properties (exclude machineCurve)
uuid: config.asset?.uuid, uuid: config.asset?.uuid,
tagcode: config.asset?.tagcode, tagcode: config.asset?.tagCode || config.asset?.tagcode,
geoLocation: config.asset?.geoLocation, geoLocation: config.asset?.geoLocation,
category: config.asset?.category, category: config.asset?.category,
type: config.asset?.type, type: config.asset?.type,

View File

@@ -82,7 +82,9 @@ class BaseNodeAdapter {
// pumpingStation/measurement nodeClass _attachInputHandler patterns. // pumpingStation/measurement nodeClass _attachInputHandler patterns.
this.node.source = this.source; this.node.source = this.source;
this._output = new OutputUtils(); // `static alwaysEmitFields = ['ctrl', …]` on a subclass exempts those
// fields from delta compression so they trace continuously downstream.
this._output = new OutputUtils({ alwaysEmit: ctor.alwaysEmitFields });
const userHasUnitsQuery = ctor.commands.some( const userHasUnitsQuery = ctor.commands.some(
(c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units')))); (c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units'))));
const mergedCommands = userHasUnitsQuery const mergedCommands = userHasUnitsQuery

View File

@@ -79,65 +79,70 @@ class movementManager {
// Clamp the final target into [minPosition, maxPosition] // Clamp the final target into [minPosition, maxPosition]
targetPosition = this.constrain(targetPosition); targetPosition = this.constrain(targetPosition);
// Compute direction and remaining distance // Snapshot the starting point. Position is derived from ELAPSED WALL-TIME
const direction = targetPosition > this.currentPosition ? 1 : -1; // (not accumulated per-tick steps) so an interruption that lands between
const distance = Math.abs(targetPosition - this.currentPosition); // ticks — or before the very first tick — still leaves currentPosition at
// the real distance travelled. A fast re-commanding parent (e.g. MGC
// updating demand every tick) then re-bases from the true position instead
// of freezing at the start. See _settleAt / the abort handler below.
const startPosition = this.currentPosition;
const direction = targetPosition > startPosition ? 1 : -1;
const distance = Math.abs(targetPosition - startPosition);
const velocity = this.getVelocity(); // units per second const velocity = this.getVelocity(); // units per second
if (velocity <= 0) { if (velocity <= 0) {
return reject(new Error("Movement aborted: zero speed")); return reject(new Error("Movement aborted: zero speed"));
} }
// Duration and bookkeeping const duration = distance / velocity; // seconds to go the full distance
const duration = distance / velocity; // seconds to go the remaining distance
this.timeleft = duration; this.timeleft = duration;
this.logger.debug( this.logger.debug(
`Linear move: dir=${direction}, dist=${distance}, vel=${velocity.toFixed(2)} u/s, dur=${duration.toFixed(2)}s` `Linear move: dir=${direction}, dist=${distance}, vel=${velocity.toFixed(2)} u/s, dur=${duration.toFixed(2)}s`
); );
// Compute how much to move each tick
const intervalMs = this.interval; const intervalMs = this.interval;
const intervalSec = intervalMs / 1000;
const stepSize = direction * velocity * intervalSec;
const startTime = Date.now(); const startTime = Date.now();
// Position reached after `elapsedSec` of travel, clamped to the target.
const posAt = (elapsedSec) =>
this.constrain(startPosition + direction * Math.min(distance, velocity * elapsedSec));
// Re-base currentPosition (and timeleft) onto the real elapsed progress.
const settle = () => {
const elapsed = (Date.now() - startTime) / 1000;
this.currentPosition = posAt(elapsed);
this.timeleft = Math.max(0, duration - elapsed);
this.emitPos(this.currentPosition);
return elapsed;
};
// Kick off the loop // Kick off the loop
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
// 7a) Abort check
if (signal?.aborted) { if (signal?.aborted) {
clearInterval(intervalId); clearInterval(intervalId);
settle();
return reject(new Error("Movement aborted")); return reject(new Error("Movement aborted"));
} }
// Advance position and clamp const elapsed = settle();
this.currentPosition += stepSize;
this.currentPosition = this.constrain(this.currentPosition);
this.emitPos(this.currentPosition);
// Update timeleft
const elapsed = (Date.now() - startTime) / 1000;
this.timeleft = Math.max(0, duration - elapsed);
this.logger.debug( this.logger.debug(
`pos=${this.currentPosition.toFixed(2)}, timeleft=${this.timeleft.toFixed(2)}` `pos=${this.currentPosition.toFixed(2)}, timeleft=${this.timeleft.toFixed(2)}`
); );
// Completed the move? // Completed the move? (time-based so it can't overshoot/undershoot)
if ( if (elapsed >= duration) {
(direction > 0 && this.currentPosition >= targetPosition) ||
(direction < 0 && this.currentPosition <= targetPosition)
) {
clearInterval(intervalId); clearInterval(intervalId);
this.currentPosition = targetPosition; this.currentPosition = targetPosition;
this.timeleft = 0;
this.emitPos(this.currentPosition); this.emitPos(this.currentPosition);
return resolve("Reached target move."); return resolve("Reached target move.");
} }
}, intervalMs); }, intervalMs);
// 8) Also catch aborts that happen before the first tick // Catch aborts that happen between ticks (incl. before the first tick):
// capture the partial progress so the move re-bases instead of freezing.
signal?.addEventListener("abort", () => { signal?.addEventListener("abort", () => {
clearInterval(intervalId); clearInterval(intervalId);
settle();
reject(new Error("Movement aborted")); reject(new Error("Movement aborted"));
}); });
}); });
@@ -213,8 +218,8 @@ class movementManager {
return reject(new Error("Movement aborted")); return reject(new Error("Movement aborted"));
} }
const totalDistance = Math.abs(targetPosition - this.currentPosition);
const startPosition = this.currentPosition; const startPosition = this.currentPosition;
const totalDistance = Math.abs(targetPosition - this.currentPosition);
const velocity = this.getVelocity(); const velocity = this.getVelocity();
if (velocity <= 0) { if (velocity <= 0) {
return reject(new Error("Movement aborted: zero speed")); return reject(new Error("Movement aborted: zero speed"));
@@ -223,45 +228,53 @@ class movementManager {
const easeFunction = (t) => const easeFunction = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
let elapsedTime = 0;
const duration = totalDistance / velocity; const duration = totalDistance / velocity;
this.timeleft = duration; this.timeleft = duration;
const interval = this.interval; const interval = this.interval;
const startTime = Date.now();
// Position from ELAPSED WALL-TIME (eased), so an interruption between
// ticks re-bases from the real position rather than freezing at the
// start — same rationale as moveLinear.
const posAt = (elapsedSec) => {
const progress = duration > 0 ? Math.min(elapsedSec / duration, 1) : 1;
return startPosition + (targetPosition - startPosition) * easeFunction(progress);
};
const settle = () => {
const elapsed = (Date.now() - startTime) / 1000;
this.currentPosition = posAt(elapsed);
this.timeleft = Math.max(0, duration - elapsed);
this.emitPos(this.currentPosition);
return elapsed;
};
// 2) Start the moving loop // 2) Start the moving loop
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
// 3) Check for abort on each tick // 3) Check for abort on each tick
if (signal?.aborted) { if (signal?.aborted) {
clearInterval(intervalId); clearInterval(intervalId);
settle();
return reject(new Error("Movement aborted")); return reject(new Error("Movement aborted"));
} }
elapsedTime += interval / 1000; const elapsed = settle();
const progress = Math.min(elapsedTime / duration, 1);
this.timeleft = duration - elapsedTime;
const easedProgress = easeFunction(progress);
const newPosition =
startPosition + (targetPosition - startPosition) * easedProgress;
this.emitPos(newPosition);
this.logger.debug( this.logger.debug(
`Using ${this.movementMode} => Progress=${progress.toFixed( `Using ${this.movementMode} => elapsed=${elapsed.toFixed(2)}s, pos=${this.currentPosition.toFixed(2)}`
2
)}, Eased=${easedProgress.toFixed(2)}`
); );
if (progress >= 1) { if (elapsed >= duration) {
clearInterval(intervalId); clearInterval(intervalId);
this.currentPosition = targetPosition; this.currentPosition = targetPosition;
this.timeleft = 0;
this.emitPos(this.currentPosition);
resolve(`Reached target move.`); resolve(`Reached target move.`);
} else {
this.currentPosition = newPosition;
} }
}, interval); }, interval);
// 4) Also listen once for abort before first tick // 4) Capture partial progress on aborts between/before ticks.
signal?.addEventListener("abort", () => { signal?.addEventListener("abort", () => {
clearInterval(intervalId); clearInterval(intervalId);
settle();
reject(new Error("Movement aborted")); reject(new Error("Movement aborted"));
}); });
}); });

View File

@@ -0,0 +1,78 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('events');
const MovementManager = require('../src/state/movementManager');
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function makeManager({ mode = 'staticspeed', speed = 50, interval = 1000, initial = 0 } = {}) {
// speed%/s on a 0..100 range → velocity = speed %/s. interval defaults to the
// production 1000ms so the abort-before-first-tick race is reproduced exactly.
return new MovementManager(
{
position: { min: 0, max: 100, initial },
movement: { mode, speed, maxSpeed: 1000, interval },
},
noopLogger,
new EventEmitter(),
);
}
// Regression: before the time-based fix, currentPosition only advanced inside
// setInterval(…, interval). An abort landing before the first tick (the MGC's
// ~1s re-command cadence vs the 1000ms tick) left the pump frozen at the start.
for (const mode of ['staticspeed', 'dynspeed']) {
test(`${mode}: abort before the first tick still advances position (no freeze)`, async () => {
const mgr = makeManager({ mode, speed: 50, interval: 1000 });
const ac = new AbortController();
const moving = mgr.moveTo(80, ac.signal); // ~1.6s of travel; first tick at 1000ms
await sleep(200); // interrupt well before the first tick
ac.abort();
await moving;
const pos = mgr.getCurrentPosition();
// The fix: any non-zero progress means the abort re-based instead of
// freezing at the start. (dynspeed eases in, so its early travel is small
// but must still be > 0; staticspeed travels ~velocity·elapsed.)
assert.ok(pos > 0, `expected partial progress, got frozen at ${pos}`);
assert.ok(pos < 80, `should not have reached target, got ${pos}`);
});
test(`${mode}: a fresh setpoint re-bases from the interrupted position`, async () => {
const mgr = makeManager({ mode, speed: 50, interval: 1000 });
const ac1 = new AbortController();
const m1 = mgr.moveTo(80, ac1.signal);
await sleep(200);
ac1.abort();
await m1;
const afterFirst = mgr.getCurrentPosition();
// New command toward 0 must start from afterFirst, not from 80 or a reset.
const ac2 = new AbortController();
const m2 = mgr.moveTo(0, ac2.signal);
await sleep(100);
ac2.abort();
await m2;
const afterSecond = mgr.getCurrentPosition();
assert.ok(afterSecond < afterFirst, `expected re-base downward from ${afterFirst}, got ${afterSecond}`);
assert.ok(afterSecond >= 0, `position must stay in range, got ${afterSecond}`);
});
}
test('staticspeed: an uninterrupted move reaches the exact target', async () => {
const mgr = makeManager({ mode: 'staticspeed', speed: 500, interval: 10 }); // fast
await mgr.moveTo(40, new AbortController().signal);
assert.equal(mgr.getCurrentPosition(), 40);
});
test('position is clamped to [min,max] on a re-based abort', async () => {
const mgr = makeManager({ mode: 'staticspeed', speed: 5000, interval: 1000, initial: 0 });
const ac = new AbortController();
const moving = mgr.moveTo(100, ac.signal);
await sleep(150);
ac.abort();
await moving;
const pos = mgr.getCurrentPosition();
assert.ok(pos >= 0 && pos <= 100, `clamped, got ${pos}`);
});

View File

@@ -8,7 +8,7 @@ const config = {
general: { id: 'abc', unit: 'mbar' }, general: { id: 'abc', unit: 'mbar' },
asset: { asset: {
uuid: 'u1', uuid: 'u1',
tagcode: 't1', tagCode: 't1',
geoLocation: { lat: 51.6, lon: 4.7 }, geoLocation: { lat: 51.6, lon: 4.7 },
category: 'measurement', category: 'measurement',
type: 'pressure', type: 'pressure',
@@ -30,6 +30,35 @@ test('process format emits message with changed fields only', () => {
assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) }); assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) });
}); });
test('alwaysEmit fields bypass delta compression (re-emitted while unchanged)', () => {
const out = new OutputUtils({ alwaysEmit: ['ctrl'] });
const first = out.formatMsg({ ctrl: 40, flow: 12 }, config, 'influxdb');
assert.deepEqual(first.payload.fields, { ctrl: 40, flow: 12 });
// flow unchanged → dropped; ctrl unchanged but forced → still emitted.
const second = out.formatMsg({ ctrl: 40, flow: 12 }, config, 'influxdb');
assert.deepEqual(second.payload.fields, { ctrl: 40 });
// ctrl changed → emitted with its new value.
const third = out.formatMsg({ ctrl: 41, flow: 12 }, config, 'influxdb');
assert.deepEqual(third.payload.fields, { ctrl: 41 });
});
test('alwaysEmit is per-format and does not force a missing/undefined field', () => {
const out = new OutputUtils({ alwaysEmit: ['ctrl'] });
// ctrl absent from the output → nothing to force; with no other change the
// message is suppressed as usual.
out.formatMsg({ flow: 5 }, config, 'influxdb');
assert.equal(out.formatMsg({ flow: 5 }, config, 'influxdb'), null);
});
test('default OutputUtils keeps pure delta compression (no alwaysEmit)', () => {
const out = new OutputUtils();
out.formatMsg({ ctrl: 40 }, config, 'influxdb');
assert.equal(out.formatMsg({ ctrl: 40 }, config, 'influxdb'), null);
});
test('influx format flattens tags and stringifies tag values', () => { test('influx format flattens tags and stringifies tag values', () => {
const out = new OutputUtils(); const out = new OutputUtils();
const msg = out.formatMsg({ value: 10 }, config, 'influxdb'); const msg = out.formatMsg({ value: 10 }, config, 'influxdb');
@@ -38,5 +67,41 @@ test('influx format flattens tags and stringifies tag values', () => {
assert.equal(msg.payload.measurement, 'measurement_abc'); assert.equal(msg.payload.measurement, 'measurement_abc');
assert.equal(msg.payload.tags.geoLocation_lat, '51.6'); assert.equal(msg.payload.tags.geoLocation_lat, '51.6');
assert.equal(msg.payload.tags.geoLocation_lon, '4.7'); assert.equal(msg.payload.tags.geoLocation_lon, '4.7');
assert.equal(msg.payload.tags.tagcode, 't1');
assert.ok(msg.payload.timestamp instanceof Date); assert.ok(msg.payload.timestamp instanceof Date);
}); });
test('influx format omits tags whose config value is unset', () => {
const out = new OutputUtils();
// No asset block at all: uuid/tagcode/geoLocation/category/type/model are
// all undefined and must NOT appear as `="undefined"` tags.
const sparse = {
functionality: { softwareType: 'measurement' },
general: { id: 'abc' },
};
const msg = out.formatMsg({ value: 10 }, sparse, 'influxdb');
for (const t of ['geoLocation', 'category', 'type', 'model', 'uuid', 'tagcode', 'unit', 'role']) {
assert.ok(!(t in msg.payload.tags), `tag "${t}" should be omitted when unset, got "${msg.payload.tags[t]}"`);
}
// Tags that DO have values still come through.
assert.equal(msg.payload.tags.id, 'abc');
assert.equal(msg.payload.tags.softwareType, 'measurement');
// Nothing should stringify to the literal "undefined".
for (const v of Object.values(msg.payload.tags)) {
assert.notEqual(v, 'undefined');
}
});
test('influx format drops empty-string tag values too', () => {
const out = new OutputUtils();
const cfg = {
functionality: { softwareType: 'pump', role: '' },
general: { id: 'p1' },
asset: { category: '', model: 'M9' },
};
const msg = out.formatMsg({ value: 1 }, cfg, 'influxdb');
assert.ok(!('role' in msg.payload.tags));
assert.ok(!('category' in msg.payload.tags));
assert.equal(msg.payload.tags.model, 'M9');
});

View File

@@ -1,33 +1,41 @@
# generalFunctions # generalFunctions
> **Reflects code as of `f21e2aa` · regenerated `2026-05-11` (hand-written)** ![code-ref](https://img.shields.io/badge/code--ref-48fa543-blue) ![kind](https://img.shields.io/badge/kind-Shared_Library-dddddd) ![status](https://img.shields.io/badge/status-stable-brightgreen)
> No `npm run wiki:all` script exists for this library. The API surface block (section 5) is hand-maintained between the AUTOGEN markers. If the banner is stale, treat this page as informative, not authoritative.
**generalFunctions** is the shared infrastructure every EVOLV node depends on. It provides the base classes all nodes extend (`BaseDomain`, `BaseNodeAdapter`), the command dispatch engine, the measurement store, unit-policy system, child-registration machinery, InfluxDB output formatting, and a set of domain utilities (PID, curve interpolation, prediction, statistics, coolprop). Nodes hold zero duplicated scaffolding &mdash; they only write the logic that differs.
--- ---
## 1. What this library is ## At a glance
**generalFunctions** is the shared infrastructure every EVOLV node depends on. It provides the base classes all nodes extend (`BaseDomain`, `BaseNodeAdapter`), the command dispatch engine, the measurement store, unit-policy system, child-registration machinery, InfluxDB output formatting, and a set of domain utilities (PID, curve interpolation, prediction, statistics, coolprop). Nodes hold zero duplicated scaffolding — they only write the logic that differs. | Thing | Value |
|:---|:---|
| What it is | The shared library &mdash; not a Node-RED node, never placed in a flow |
| Kind | Shared library (`require('generalFunctions')`) |
| Consumed by | All 12 EVOLV nodes (rotatingMachine, MGC, pumpingStation, valve, VGC, reactor, settler, monster, measurement, diffuser, dashboardAPI) |
| Import style | Package root only &mdash; `const { BaseDomain, UnitPolicy } = require('generalFunctions');` |
| Side effects on a flow | None &mdash; the library has no editor form, no node registration |
| Cross-node coupling | Through this library's API surface + Node-RED messages only &mdash; never direct imports between node packages |
--- ---
## 2. Position in the platform ## How it fits
```mermaid ```mermaid
flowchart LR flowchart LR
gf["generalFunctions\n(shared library)"]:::lib gf["generalFunctions<br/>(shared library)"]:::lib
rm["rotatingMachine\nEquipment"]:::equip rm["rotatingMachine<br/>Equipment"]:::equip
mgc["machineGroupControl\nUnit"]:::unit mgc["machineGroupControl<br/>Unit"]:::unit
ps["pumpingStation\nProcess Cell"]:::proc ps["pumpingStation<br/>Process Cell"]:::proc
meas["measurement\nControl Module"]:::ctrl meas["measurement<br/>Control Module"]:::ctrl
valve["valve\nEquipment"]:::equip valve["valve<br/>Equipment"]:::equip
vgc["valveGroupControl\nUnit"]:::unit vgc["valveGroupControl<br/>Unit"]:::unit
reactor["reactor\nUnit"]:::unit reactor["reactor<br/>Unit"]:::unit
settler["settler\nUnit"]:::unit settler["settler<br/>Unit"]:::unit
monster["monster\nUnit"]:::unit monster["monster<br/>Unit"]:::unit
diffuser["diffuser\nEquipment"]:::equip diffuser["diffuser<br/>Equipment"]:::equip
dashAPI["dashboardAPI\nutility"]:::util dashAPI["dashboardAPI<br/>utility"]:::util
gf --> rm gf --> rm
gf --> mgc gf --> mgc
@@ -49,37 +57,48 @@ flowchart LR
classDef util fill:#dddddd,color:#000 classDef util fill:#dddddd,color:#000
``` ```
Every EVOLV node declares `generalFunctions` as a `dependencies` entry and imports from the package root only (`require('generalFunctions')`). Cross-node coupling happens exclusively through this library's API surface and Node-RED messages — never through direct imports between node packages. Every EVOLV node declares `generalFunctions` as a `dependencies` entry and imports from the package root only. The library has no S88 level of its own &mdash; it is the substrate the S88-classified nodes are built on.
--- ---
## 3. Capability matrix ## How to import
| Capability | Status | Notes | Single root import, destructure what you need:
|---|---|---|
| Base domain scaffolding (`BaseDomain`) | ✅ | Constructor, emitter, logger, measurements, child registry wired automatically | ```js
| Base Node-RED adapter (`BaseNodeAdapter`) | ✅ | Tick/event loop, status badge, input dispatch, Port 0/1/2 output | const {
| Declarative command dispatch (`CommandRegistry`) | ✅ | Alias deprecation warnings, unit normalisation, `query.units` auto-topic | // Platform base classes
| Declarative child-registration routing (`ChildRouter`) | ✅ | Replaces per-node `registerChild` switch blocks | BaseDomain, BaseNodeAdapter, ChildRouter, UnitPolicy, HealthStatus, LatestWinsGate,
| Unit policy + conversion (`UnitPolicy`, `convert`) | ✅ | Canonical ↔ output ↔ curve unit sets; dual method/property access | // Node-RED bridge
| Measurement store (`MeasurementContainer`) | ✅ | Chainable, windowed, auto-convert, 4-segment key output | createRegistry, CommandRegistry, statusBadge, StatusUpdater,
| InfluxDB + process output formatting (`outputUtils`) | ✅ | Delta-compressed; consumers must cache and merge | // Measurement + config
| Status badge helpers (`statusBadge`, `StatusUpdater`) | ✅ | Converged look-and-feel across all nodes | MeasurementContainer, configManager, configUtils, validation,
| Latest-wins async gate (`LatestWinsGate`) | ✅ | Extracted from MGC; shared by PS, VGC, MGC | // Output formatting + logging
| Prediction quality / drift tracking (`HealthStatus`) | ✅ | Frozen plain-object shape; composable | outputUtils, logger,
| Config schema registry (`configManager`) | ✅ | One JSON schema per node in `src/configs/` | // Child registration
| PID control (`PIDController`, `CascadePIDController`) | ✅ | Full-featured discrete PID with bumpless transfer | childRegistrationUtils,
| Curve interpolation (`interpolation`, `predict`) | ✅ | Multidimensional characteristic-curve predictor | // Unit conversion + physics
| Statistical helpers (`stats`, `nrmse`, `outliers`) | ✅ | Mean, stddev, median, NRMSE, dynamic-cluster outlier detection | convert, Fysics, gravity, coolprop,
| Thermodynamic properties (`coolprop`) | ✅ | CoolProp bindings for fluid/gas property lookup | // Control + prediction
| FSM for valve/machine states (`state`) | ✅ | StateManager + MovementManager | PIDController, CascadePIDController, createPidController, createCascadePidController,
| Gravity calculations (`gravity`) | ✅ | WGS-84 model | predict, interpolation, nrmse, stats, state,
| Physical constants (`Fysics`) | ✅ | Air density, viscosity, etc. | // Editor menus
| Browser-side editor dropdowns (`MenuManager`, `menuUtils`) | ✅ | Node-RED editor form population | MenuManager,
// Asset registry
assetResolver, AssetResolver, FileBackend, HttpBackend,
// Constants
POSITIONS, POSITION_VALUES, isValidPosition,
} = require('generalFunctions');
```
> [!IMPORTANT]
> Never import internal paths (`require('generalFunctions/src/domain/UnitPolicy')`). Only the package root is contractual; internal layout may move.
For the full export list with signatures and stability tags, see [Reference &mdash; Contracts](Reference-Contracts).
--- ---
## 4. Module map ## Module map &mdash; what lives where
```mermaid ```mermaid
flowchart TB flowchart TB
@@ -125,17 +144,17 @@ flowchart TB
end end
subgraph math["numeric & domain utilities"] subgraph math["numeric & domain utilities"]
PID["src/pid/ — PIDController"] PID["src/pid/"]
NRMSE["src/nrmse/ — ErrorMetrics"] NRMSE["src/nrmse/"]
STATS["src/stats/ — mean/stddev/median"] STATS["src/stats/"]
OUT["src/outliers/ — DynamicClusterDeviation"] OUT["src/outliers/"]
STATE["src/state/ — state FSM"] STATE["src/state/"]
CONV["src/convert/ — unit conversion"] CONV["src/convert/"]
COOL["src/coolprop-node/ — thermodynamics"] COOL["src/coolprop-node/"]
FYS["src/convert/fysics.js — physical constants"] FYS["src/convert/fysics.js"]
end end
subgraph menu_grp["src/menu/ — editor menus"] subgraph menu_grp["src/menu/"]
MM["MenuManager"] MM["MenuManager"]
end end
@@ -155,13 +174,13 @@ flowchart TB
``` ```
| Directory | Primary export | Read first if you're changing… | | Directory | Primary export | Read first if you're changing… |
|---|---|---| |:---|:---|:---|
| `src/domain/` | `BaseDomain`, `ChildRouter`, `UnitPolicy`, `LatestWinsGate`, `HealthStatus` | Base class contracts, child routing, unit system | | `src/domain/` | `BaseDomain`, `ChildRouter`, `UnitPolicy`, `LatestWinsGate`, `HealthStatus` | Base class contracts, child routing, unit system |
| `src/nodered/` | `BaseNodeAdapter`, `CommandRegistry`, `statusBadge`, `StatusUpdater` | Input dispatch, output loops, editor status | | `src/nodered/` | `BaseNodeAdapter`, `CommandRegistry`, `statusBadge`, `StatusUpdater` | Input dispatch, output loops, editor status |
| `src/measurements/` | `MeasurementContainer` | Measurement storage, statistics, 4-segment key output | | `src/measurements/` | `MeasurementContainer` | Measurement storage, statistics, 4-segment key output |
| `src/helper/` | `logger`, `outputUtils`, `childRegistrationUtils`, `configUtils`, `validationUtils`, `menuUtils`, `gravity` | Logging, InfluxDB formatting, child registration | | `src/helper/` | `logger`, `outputUtils`, `childRegistrationUtils`, `configUtils`, `validationUtils`, `menuUtils`, `gravity` | Logging, InfluxDB formatting, child registration |
| `src/configs/` | `ConfigManager` + per-node JSON schemas | Schema loading, config validation, default values | | `src/configs/` | `ConfigManager` + per-node JSON schemas | Schema loading, config validation, default values |
| `src/predict/` | `predict`, `interpolation` | Characteristic curve fitting and flow/power prediction | | `src/predict/` | `predict`, `interpolation` | Characteristic curve fitting + flow/power prediction |
| `src/pid/` | `PIDController`, `CascadePIDController` | Closed-loop control | | `src/pid/` | `PIDController`, `CascadePIDController` | Closed-loop control |
| `src/nrmse/` | `ErrorMetrics` (NRMSE) | Prediction quality scoring | | `src/nrmse/` | `ErrorMetrics` (NRMSE) | Prediction quality scoring |
| `src/stats/` | `stats` (mean, stddev, median) | Statistical reducers | | `src/stats/` | `stats` (mean, stddev, median) | Statistical reducers |
@@ -170,283 +189,78 @@ flowchart TB
| `src/convert/` | `convert`, `Fysics` | Unit conversion, physical constants | | `src/convert/` | `convert`, `Fysics` | Unit conversion, physical constants |
| `src/coolprop-node/` | `coolprop` | Thermodynamic property lookup | | `src/coolprop-node/` | `coolprop` | Thermodynamic property lookup |
| `src/menu/` | `MenuManager` | Editor-form dropdown population | | `src/menu/` | `MenuManager` | Editor-form dropdown population |
| `src/registry/` | `assetResolver`, `AssetResolver`, `FileBackend`, `HttpBackend` | Asset metadata lookup (replaces ad-hoc JSON readers) |
| `src/constants/` | `POSITIONS`, `POSITION_VALUES`, `isValidPosition` | Canonical spatial position constants | | `src/constants/` | `POSITIONS`, `POSITION_VALUES`, `isValidPosition` | Canonical spatial position constants |
--- ---
## 5. API surface ## What you'll send (the platform contract)
<!-- BEGIN AUTOGEN: api-surface --> This library doesn't accept `msg.topic` directly &mdash; nodes do. But every node's `nodeClass.js` and `specificClass.js` route through the same primitives:
All imports use the package root: `const { X } = require('generalFunctions');` | Primitive | Role |
|:---|:---|
| `BaseNodeAdapter.input(msg)` | Routes incoming Node-RED messages through the node's `CommandRegistry`, applies unit normalisation, then dispatches to the handler. |
| `CommandRegistry` | Topic + alias map. Handlers are pure functions; `units: {measure, default}` triggers automatic `convert` normalisation. |
| `ChildRouter` | Declarative parent-side routing. `.onRegister(type, cb)`, `.onMeasurement(type, filter, cb)`, `.onPrediction(type, filter, cb)`. |
| `MeasurementContainer.type().variant().position().value()` | Chainable write. Flattened output emits 4-segment keys `<type>.<variant>.<position>.<childId>`. |
| `UnitPolicy.declare({canonical, output, curve?})` | The per-node unit triple. Used by `MeasurementContainer` (auto-convert on write) and by the output formatter (render in `output` units). |
| `outputUtils.formatMsg(snapshot, config, mode)` | Delta-compresses successive snapshots. Returns `undefined` when nothing changed. |
| `HealthStatus.ok / degraded / compose` | Frozen plain-object factory for prediction-quality state. |
| `LatestWinsGate.fire(value)` | Serialises async dispatches; the latest call wins, intermediates are marked `SUPERSEDED`. |
| Export | Import name | Source file | Contract | For full signatures and stability tags see [Reference &mdash; Contracts](Reference-Contracts).
|---|---|---|---|
| `BaseDomain` | `BaseDomain` | `src/domain/BaseDomain.js` | Abstract base class for every `specificClass.js`. Provides `emitter`, `config`, `logger`, `measurements`, `childRegistrationUtils`, `router`. Subclass must declare `static name` (maps to schema JSON) and implement `configure()`. See CONTRACTS.md §3. |
| `BaseNodeAdapter` | `BaseNodeAdapter` | `src/nodered/BaseNodeAdapter.js` | Abstract base for every `nodeClass.js`. Wires config build → domain instantiation → registration delay → output strategy → status loop → input dispatch → close handler. Subclass declares `static DomainClass`, `static commands`, `static tickInterval`, `static statusInterval`, and overrides `buildDomainConfig(uiConfig, nodeId)`. See CONTRACTS.md §2. |
| `ChildRouter` | `ChildRouter` | `src/domain/ChildRouter.js` | Declarative parent-side child registration. Replaces per-node `registerChild` switch. Chain `.onRegister(softwareType, cb)`, `.onMeasurement(softwareType, filter, cb)`, `.onPrediction(softwareType, filter, cb)`. See CONTRACTS.md §5. |
| `CommandRegistry` | `CommandRegistry` | `src/nodered/commandRegistry.js` | Class form of the command registry. Accepts array of descriptors (topic, aliases, payloadSchema, units, description, handler). Dispatches by O(1) lookup, normalises units before handler runs, warns on alias use. |
| `createRegistry` | `createRegistry` | `src/nodered/commandRegistry.js` | Factory: `createRegistry(descriptors, options)``CommandRegistry`. Used by `BaseNodeAdapter`; rarely needed directly. |
| `UnitPolicy` | `UnitPolicy` | `src/domain/UnitPolicy.js` | Declare unit sets: `UnitPolicy.declare({ canonical, output, curve?, requireUnitForTypes? })`. Returns policy with dual method/property access (`policy.canonical('flow')` and `policy.canonical.flow`). Methods: `canonical`, `output`, `curve`, `resolve`, `convert`, `containerOptions`, `setLogger`. See CONTRACTS.md §6. |
| `LatestWinsGate` | `LatestWinsGate` | `src/domain/LatestWinsGate.js` | Serialises async dispatches so only the latest value wins. `fire(value)` — non-blocking. `fireAndWait(value)``Promise` that resolves with dispatch result or `LatestWinsGate.SUPERSEDED`. `drain()` — await idle. See CONTRACTS.md §8. |
| `HealthStatus` | `HealthStatus` | `src/domain/HealthStatus.js` | Factory functions for frozen health objects: `HealthStatus.ok(msg, src)`, `HealthStatus.degraded(level, flags, msg, src)`, `HealthStatus.compose(statuses)`. Shape: `{ level: 03, flags: string[], message, source }`. See CONTRACTS.md §9. |
| `MeasurementContainer` | `MeasurementContainer` | `src/measurements/MeasurementContainer.js` | Chainable measurement store: `.type().variant().position().value(v, ts, srcUnit)`. Query: `getCurrentValue(unit)`, `getAverage(unit)`, `difference({ from, to, unit })`. Introspect: `getFlattenedOutput()` returns 4-segment keyed object (`type.variant.position.childId`). |
| `outputUtils` | `outputUtils` | `src/helper/outputUtils.js` | Singleton-per-node delta-compression engine. `formatMsg(output, config, format)` returns `msg` only when fields changed, or `undefined`. `format` is `'process'` or `'influxdb'`. Consumers must cache and merge. |
| `logger` | `logger` | `src/helper/logger.js` | `new logger(enabled, logLevel, moduleName)`. Methods: `debug`, `info`, `warn`, `error`, `setLogLevel`, `toggleLogging`. Never use `console.log` directly. |
| `configManager` | `configManager` | `src/configs/index.js` | `new configManager()`. Methods: `getConfig(name)`, `buildConfig(name, uiConfig, nodeId, domainSlice?)`, `getAvailableConfigs()`, `hasConfig(name)`. Config files live in `src/configs/*.json`. |
| `configUtils` | `configUtils` | `src/helper/configUtils.js` | `new configUtils(defaultConfig)`. `initConfig(userConfig)` validates and merges user values over defaults via `validationUtils`. |
| `validation` | `validation` | `src/helper/validationUtils.js` | `new validation(logEnabled, logLevel)`. `validateSchema(config, schema, name)` walks schema, clamps numbers, coerces types, strips unknown keys. |
| `childRegistrationUtils` | `childRegistrationUtils` | `src/helper/childRegistrationUtils.js` | `new childRegistrationUtils(parentDomain)`. `registerChild(child, positionVsParent, distance?)` stores child by softwareType/category with alias normalisation. `getChildrenOfType(softwareType, category?)`, `getChildById(id)`, `getAllChildren()`. Normally used via `ChildRouter` — direct use is for advanced cases. |
| `statusBadge` | `statusBadge` | `src/nodered/statusBadge.js` | Pure-function badge builder. `statusBadge.compose(parts, opts?)``{ fill, shape, text }`. `statusBadge.error(msg)`, `statusBadge.idle(label)`. Text clipped to 60 chars. See CONTRACTS.md §7. |
| `StatusUpdater` | `StatusUpdater` | `src/nodered/statusUpdater.js` | `new StatusUpdater({ node, source, intervalMs, logger })`. `start()`, `stop()`. Calls `source.getStatusBadge()` on interval; catches errors and shows a red badge. Owned by `BaseNodeAdapter` — rarely needed directly. |
| `convert` | `convert` | `src/convert/index.js` | unit-converter factory. `convert(value).from(unit).to(unit)`. `convert.possibilities(measure)` lists accepted units. Measures: `volumeFlowRate`, `pressure`, `power`, `temperature`, `volume`, `length`, `mass`, `energy`, `reactivePower`, `apparentPower`, `reactiveEnergy`, and more. |
| `Fysics` | `Fysics` | `src/convert/fysics.js` | `new Fysics()`. Physical constants: `air_density`, `g0`; methods for gravity and viscosity calculations. |
| `gravity` | `gravity` | `src/helper/gravity.js` | Singleton-style `Gravity` instance. `getStandardGravity()` → 9.80665 m/s². WGS-84 latitude/altitude corrections available. |
| `predict` | `predict` | `src/predict/predict_class.js` | `new predict(config, logger)`. Multidimensional characteristic-curve predictor; emits results via internal EventEmitter. |
| `interpolation` | `interpolation` | `src/predict/interpolation.js` | Class for 1-D and 2-D curve interpolation (linear, cubic-spline). Used internally by `predict`. |
| `PIDController` | `PIDController` | `src/pid/PIDController.js` | Discrete PID with bumpless auto/manual transfer, anti-windup, derivative filtering, rate limiting, gain scheduling, feedforward. |
| `CascadePIDController` | `CascadePIDController` | `src/pid/PIDController.js` | Outer-inner PID cascade built on `PIDController`. |
| `createPidController` | `createPidController` | `src/pid/index.js` | Factory shorthand: `createPidController(options)``PIDController`. |
| `createCascadePidController` | `createCascadePidController` | `src/pid/index.js` | Factory shorthand for cascade PID. |
| `nrmse` | `nrmse` | `src/nrmse/index.js` | `ErrorMetrics` class for normalised-root-mean-squared-error tracking. Multi-metric via `registerMetric(id)`, `update(id, predicted, measured)`. |
| `stats` | `stats` | `src/stats/index.js` | Pure functions: `mean(arr)`, `stdDev(arr)`, `median(arr)`. No state; safe to call on any numeric array. |
| `state` | `state` | `src/state/index.js` | `new state(config, logger)`. FSM for valve/machine: StateManager (transitions) + MovementManager (timed moves). Emits state-change events. |
| `MenuManager` | `MenuManager` | `src/menu/index.js` | `new MenuManager()`. Manages editor dropdown menus (asset, logger, position, aquon). `registerMenu(type, factory)`. Used in node entry files to power Node-RED editor forms. |
| `menuUtils` / `MenuUtils` | via `menuUtils` in helper | `src/helper/menuUtils.js` | Browser-side editor helper. Toggles, data fetching, URL construction, dropdown population, HTML generation. Served to browser via `endpointUtils`. |
| `POSITIONS` | `POSITIONS` | `src/constants/positions.js` | Frozen enum: `{ UPSTREAM, DOWNSTREAM, AT_EQUIPMENT, DELTA }`. |
| `POSITION_VALUES` | `POSITION_VALUES` | `src/constants/positions.js` | `string[]` of all four position strings. |
| `isValidPosition` | `isValidPosition` | `src/constants/positions.js` | `(pos: string) => boolean`. |
| `coolprop` | `coolprop` | `src/coolprop-node/src/index.js` | CoolProp fluid/gas thermodynamic property lookup. Used by nodes that model heat transfer or gas compression. |
| `loadModel` | `loadModel` | `datasets/assetData/modelData/index.js` | Load a JSON model-data asset by dataset type and asset ID (with LRU cache). Preferred over deprecated `loadCurve`. |
| `loadCurve` | `loadCurve` | `datasets/assetData/curves/index.js` | **Deprecated** — load a pump-curve JSON. Replaced by `loadModel`. |
<!-- END AUTOGEN: api-surface -->
--- ---
## 6. Config schema registry ## What you'll see come out
One JSON file per node in `src/configs/`. `ConfigManager.buildConfig` merges the schema defaults with the Node-RED editor values before the domain sees them. A node that imports `BaseNodeAdapter` automatically gets the three EVOLV ports:
| File | Node | What it defines | | Port | Carries | Built by |
|---|---|---| |:---|:---|:---|
| `baseConfig.json` | all nodes | Shared `general`, `asset`, `functionality`, `logging` sections | | 0 (process) | Delta-compressed state snapshot (the `getOutput()` return) | `outputUtils.formatMsg(snapshot, config, 'process')` |
| `rotatingMachine.json` | rotatingMachine | Curve selection, startup/shutdown ramps, safety thresholds, unit config | | 1 (telemetry) | InfluxDB line-protocol payload (same fields) | `outputUtils.formatMsg(snapshot, config, 'influxdb')` |
| `machineGroupControl.json` | machineGroupControl | Demand targets, strategy selection, dispatcher settings | | 2 (register / control) | Parent-child handshake messages | `childRegistrationUtils` via `BaseNodeAdapter` |
| `pumpingStation.json` | pumpingStation | Basin geometry, hydraulics, control strategies, safety levels |
| `measurement.json` | measurement | Scaling, smoothing, stability threshold, digital/MQTT mode |
| `valve.json` | valve | Actuator travel time, position limits, FSM config |
| `valveGroupControl.json` | valveGroupControl | Group strategy, demand distribution |
| `reactor.json` | reactor | ASM kinetics, reactor type (CSTR/PFR), volume, influent |
| `settler.json` | settler | Sludge settling parameters, effluent quality |
| `monster.json` | monster | Multi-parameter monitoring, flow bounds, sample intervals |
| `diffuser.json` | diffuser | Aeration model, oxygen transfer parameters |
To add a new node: create `src/configs/<nodeName>.json` extending `baseConfig.json`, declare `static name = '<nodeName>'` in the domain class. `configManager.buildConfig` finds it automatically. The 4-segment key shape **`<type>.<variant>.<position>.<childId>`** is the contractual output of `MeasurementContainer.getFlattenedOutput()`. Position labels are normalised to lowercase. Changing this shape is a forbidden breaking change &mdash; see [Reference &mdash; Limitations](Reference-Limitations#stability--versioning).
--- ---
## 7. Lifecycle — how a node tick or event reaches the output port ## Capability matrix
The sequence below uses `rotatingMachine` as the example. Every stateful EVOLV node follows the same path. See the [rotatingMachine wiki](../rotatingMachine/Home.md) for node-specific detail. | Capability | Status | Notes |
|:---|:---|:---|
```mermaid | Base domain scaffolding (`BaseDomain`) | ✅ | Constructor, emitter, logger, measurements, child registry wired automatically |
sequenceDiagram | Base Node-RED adapter (`BaseNodeAdapter`) | ✅ | Tick/event loop, status badge, input dispatch, Port 0/1/2 output |
participant RED as Node-RED runtime | Declarative command dispatch (`CommandRegistry`) | ✅ | Alias deprecation warnings, unit normalisation, `query.units` auto-topic |
participant BNA as BaseNodeAdapter | Declarative child-registration routing (`ChildRouter`) | ✅ | Replaces per-node `registerChild` switch blocks |
participant CMD as CommandRegistry | Unit policy + conversion (`UnitPolicy`, `convert`) | ✅ | Canonical &harr; output &harr; curve unit sets; dual method/property access |
participant DOM as Domain (specificClass) | Measurement store (`MeasurementContainer`) | ✅ | Chainable, windowed, auto-convert, 4-segment key output |
participant CR as ChildRouter | InfluxDB + process output formatting (`outputUtils`) | ✅ | Delta-compressed; consumers must cache and merge |
participant MC as MeasurementContainer | Status badge helpers (`statusBadge`, `StatusUpdater`) | ✅ | Converged look-and-feel across all nodes |
participant OU as outputUtils | Latest-wins async gate (`LatestWinsGate`) | ✅ | Extracted from MGC; shared by PS, VGC, MGC |
participant PORT as Port 0 / 1 / 2 | Prediction quality / drift tracking (`HealthStatus`) | ✅ | Frozen plain-object shape; composable |
| Config schema registry (`configManager`) | ✅ | One JSON schema per node in `src/configs/` |
RED->>BNA: constructor(uiConfig, RED, node, name) | PID control (`PIDController`, `CascadePIDController`) | ✅ | Full-featured discrete PID with bumpless transfer |
BNA->>BNA: configManager.buildConfig() | Curve interpolation (`interpolation`, `predict`) | ✅ | Multidimensional characteristic-curve predictor |
BNA->>DOM: new DomainClass(config) | Statistical helpers (`stats`, `nrmse`, `outliers`) | ✅ | Mean, stddev, median, NRMSE, dynamic-cluster outlier detection |
DOM->>MC: new MeasurementContainer(unitPolicy.containerOptions()) | Thermodynamic properties (`coolprop`) | ✅ | CoolProp bindings for fluid/gas property lookup |
DOM->>DOM: configure() — wire ChildRouter, concern modules | FSM for valve/machine states (`state`) | ✅ | StateManager + MovementManager |
BNA-->>PORT: Port 2 registration msg (after 100 ms delay) | Gravity calculations (`gravity`) | ✅ | WGS-84 model |
BNA->>BNA: start status loop (1000 ms) | Physical constants (`Fysics`) | ✅ | Air density, viscosity, etc. |
| Browser-side editor dropdowns (`MenuManager`, `menuUtils`) | ✅ | Node-RED editor form population |
Note over RED,PORT: Event-driven path (default) | Asset metadata registry (`assetResolver`) | ✅ | Replaces `loadCurve`, `AssetCategoryManager`, ad-hoc JSON readers |
RED->>BNA: input msg {topic: 'data.pressure', payload: 3.4}
BNA->>CMD: dispatch(msg)
CMD->>CMD: unit normalisation (Pa → mbar)
CMD->>DOM: handler(source, msg, ctx)
DOM->>MC: .type('pressure').variant('measured').position('upstream').value(3.4)
DOM->>DOM: emitter.emit('output-changed')
BNA->>DOM: getOutput()
DOM-->>BNA: flat snapshot object
BNA->>OU: formatMsg(snapshot, config, 'process')
OU-->>BNA: delta msg (only changed fields)
BNA-->>PORT: Port 0 process msg, Port 1 influx msg
Note over RED,PORT: Tick-driven path (opt-in — tickInterval set)
RED->>BNA: timer fires every tickInterval ms
BNA->>DOM: tick()
DOM->>DOM: time-based math; emitter.emit('output-changed')
BNA->>DOM: getOutput()
BNA->>OU: formatMsg(...)
BNA-->>PORT: Port 0 / 1 msgs (delta only)
```
--- ---
## 8. Stability + versioning ## Need more?
Source of truth: `.claude/rules/general-functions.md`. | Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full public API surface table &mdash; one row per export, with source file, stability tag, and signature |
| [Reference &mdash; Architecture](Reference-Architecture) | Three-tier rules, `src/` directory tree, how 12 nodes consume the library, additive-only export discipline |
| [Reference &mdash; Examples](Reference-Examples) | Usage patterns: extending `BaseDomain` and `BaseNodeAdapter`, registering commands, declaring child routes, `MeasurementContainer` chaining |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues (deprecated `loadCurve`, `outlierDetection` logs to console, `configUtils` silent strip, …) and stability/versioning rules |
| Category | Rule | [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|---|---|
| **Safe to add** | New named exports. New optional methods on existing classes. New config keys with defaults in the schema. |
| **Requires decision-gate interview** | Removing or renaming any export. Changing a method signature. Changing the output key format of `MeasurementContainer.getFlattenedOutput()`. Changing the `formatMsg` delta-compression behaviour. |
| **Forbidden without migration** | Breaking the 4-segment key shape (`type.variant.position.childId`). Changing Port 0/1/2 payload envelope. Changing the CONTRACTS.md §1§9 shapes. |
`generalFunctions` is a git submodule shared by all 12 node repos. A breaking change here requires updating every consumer in a single coordinated commit. Before modifying any module, run `grep -r "require('generalFunctions')" nodes/*/` to identify all call sites.
---
## 9. No editor form — consumers' config forms map to config slices
`generalFunctions` has no Node-RED editor form of its own. The library is never placed directly in a flow.
Consumer nodes expose their own editor forms. Each form field writes into a config key that `configManager.buildConfig` validates against the node's schema (in `src/configs/<nodeName>.json`). The resulting merged config is passed to the domain constructor.
For the form-to-config mapping of a specific node, see section 9 of that node's wiki page.
---
## 10. Examples — usage snippets from a real node
### 10.1 Extending `BaseDomain` (from `pumpingStation/specificClass.js` pattern)
```js
const { BaseDomain, UnitPolicy, ChildRouter } = require('generalFunctions');
class PumpingStation extends BaseDomain {
static name = 'pumpingStation';
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
});
configure() {
// Declare named child getters — readable in code, registry is source of truth
this.declareChildGetter('machines', 'machine');
this.declareChildGetter('machineGroups', 'machinegroup');
// Declarative child routing — no per-node registerChild switch
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() {
const { statusBadge } = require('generalFunctions');
return statusBadge.compose(['filling', 'V=12.4/50.0 m³']);
}
}
module.exports = PumpingStation;
```
### 10.2 Extending `BaseNodeAdapter` (from `pumpingStation/nodeClass.js` pattern)
```js
const { BaseNodeAdapter } = require('generalFunctions');
const Domain = require('./specificClass');
const commands = require('./commands');
class nodeClass extends BaseNodeAdapter {
static DomainClass = Domain;
static commands = commands;
static tickInterval = 1000; // ms — only for time-driven math
static statusInterval = 1000;
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;
```
### 10.3 Command descriptor with unit normalisation
```js
// src/commands/index.js
module.exports = [
{
topic: 'set.demand',
aliases: ['Qd'], // legacy name — logs 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); },
},
];
```
---
## 11. Debug recipes
| Symptom | First check | Where to look |
|---|---|---|
| Child never registers (no `registerChild` log) | Is the child's `softwareType` in the `SOFTWARE_TYPE_ALIASES` map? | `src/helper/childRegistrationUtils.js` line 112 and `src/domain/ChildRouter.js` |
| Port 0 sends nothing after an input | `outputUtils` only emits on changes. Is the field actually different from the last call? | Add a debug tap after `formatMsg`; check `outputUtils._output[format]` state |
| Unit mismatch — handler receives wrong value | Did the command descriptor declare `units: { measure, default }`? Is `msg.unit` set by the sender? | `commandRegistry.js``_normaliseUnit()`; check the warn log |
| `query.units` returns empty object | The commands array has no descriptors with a `units` field. | `BaseNodeAdapter._buildImplicitUnitsCommand()` |
| `MeasurementContainer.getFlattenedOutput()` returns unexpected key shape | Key is `type.variant.position.childId` — position is always lowercase. Check `setChildId()` was called. | `src/measurements/MeasurementContainer.js``getFlattenedOutput()` |
| `LatestWinsGate` promise never resolves | A superseded fire resolves with `{ superseded: true }`, not `undefined`. Branch on `r && r.superseded`. | `src/domain/LatestWinsGate.js` |
| Status badge stuck at grey | `getStatusBadge()` threw and `StatusUpdater` caught it. Look for `statusBadge.error(...)` in the container log. | `src/nodered/statusUpdater.js` |
> Never ship `enableLog: 'debug'` in a demo or production config — it fills the container log within seconds and obscures real errors.
---
## 12. When NOT to depend on this library
- **Passive HTTP gateway nodes** (e.g. `dashboardAPI`) may skip `BaseDomain` and `BaseNodeAdapter` entirely if they hold no domain state. A plain Node-RED node with HTTP endpoints needs only `logger`, `outputUtils`, and `configManager`. See the `dashboardAPI` wiki for the rationale.
- **External scripts or standalone tools** that need only unit conversion can import just `const { convert } = require('generalFunctions')` without pulling in the full domain stack.
- **Nodes at a different S88 level** that inherit from a third-party base class must not import from `src/domain/` or `src/nodered/` internal paths — they may only use root-level exports.
---
## 13. Known limitations
| # | Issue | Tracked in |
|---|---|---|
| 1 | `loadCurve` is deprecated; replacement `loadModel` exists but not all nodes have migrated | `OPEN_QUESTIONS.md` — Phase 8.5 cleanup |
| 2 | `outlierDetection` (`DynamicClusterDeviation`) prints to `console.log` internally — not routed through `logger` | Code review backlog |
| 3 | `configUtils.initConfig` strips unknown keys silently; schema must include every key the domain reads or defaults are lost | `OPEN_QUESTIONS.md` — e.g. monster schema fix 2026-05-11 |
| 4 | `state` (FSM) and `predict` are not yet integrated with `BaseDomain` lifecycle — nodes wire them manually in `configure()` | Architecture backlog |
| 5 | `menuUtils` / `MenuManager` are served as browser JavaScript and bypass the normal Node.js import path — deep changes require testing in both environments | `endpointUtils.js` |
| 6 | `CascadePIDController` has no dedicated test suite | Test backlog |
| 7 | `substrate_score` / wiki autogen script (`wiki:all`) not yet wired for this library; API surface block is hand-maintained | Phase 9 follow-up |

View File

@@ -0,0 +1,286 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-48fa543-blue)
> [!NOTE]
> The shape of the library: the three-tier rule it enforces on consumer nodes, the `src/` directory layout, how 12 EVOLV nodes consume each module, and the additive-only export discipline. For an intuitive overview, return to [Home](Home).
---
## Three-tier rule the library enforces
Every consumer node follows the same three-tier sandwich. `generalFunctions` provides the base classes for tiers 2 and 3; the entry file is per-node.
```
nodes/<nodeName>/
|
+-- <nodeName>.js entry: RED.nodes.registerType(...)
|
+-- src/
nodeClass.js extends BaseNodeAdapter <-- generalFunctions
specificClass.js extends BaseDomain <-- generalFunctions
commands/index.js CommandRegistry descriptors <-- generalFunctions
```
| Tier | Owns | May call `RED.*` | Provided by |
|:---|:---|:---:|:---|
| entry | Type registration, admin endpoints | Yes | per-node `<nodeName>.js` |
| nodeClass | Input routing, output ports, tick / status loops, registration delay | Yes | `BaseNodeAdapter` (this library) |
| specificClass | Domain logic, FSM, predictions, drift &mdash; no `RED.*` | No | `BaseDomain` (this library) |
Authoritative platform spec: [`.claude/refactor/CONTRACTS.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) sections 2 (nodeClass), 3 (specificClass), 4 (commandRegistry), 5 (ChildRouter), 6 (UnitPolicy), 7 (statusBadge), 9 (HealthStatus).
---
## `src/` directory tree
```
generalFunctions/
|
+-- index.js barrel — the only contractual import path
+-- CONTRACT.md per-export stability tags + cross-refs
|
+-- src/
| +-- domain/ base classes for specificClass.js
| | BaseDomain.js
| | ChildRouter.js
| | UnitPolicy.js
| | LatestWinsGate.js
| | HealthStatus.js
| |
| +-- nodered/ base classes for nodeClass.js
| | BaseNodeAdapter.js
| | commandRegistry.js
| | statusBadge.js
| | statusUpdater.js
| |
| +-- measurements/ measurement store
| | MeasurementContainer.js
| | MeasurementBuilder.js
| | Measurement.js
| |
| +-- helper/ shared utilities
| | logger.js
| | outputUtils.js
| | childRegistrationUtils.js
| | configUtils.js
| | validationUtils.js
| | menuUtils.js
| | gravity.js
| |
| +-- configs/ schema registry
| | index.js ConfigManager
| | baseConfig.json
| | <nodeName>.json one schema per consumer node
| | assetApiConfig.js
| |
| +-- convert/ unit conversion + physics
| | index.js convert
| | fysics.js Fysics class
| |
| +-- predict/ curve prediction
| | predict_class.js
| | interpolation.js
| |
| +-- pid/ closed-loop control
| | PIDController.js
| | index.js createPidController / createCascadePidController
| |
| +-- state/ FSM scaffold (StateManager + MovementManager)
| +-- nrmse/ prediction-quality NRMSE
| +-- stats/ pure-function statistical reducers
| +-- outliers/ DynamicClusterDeviation
| +-- coolprop-node/ CoolProp thermodynamic bindings
| +-- menu/ MenuManager (editor dropdowns)
| +-- registry/ AssetResolver + FileBackend / HttpBackend
| +-- constants/ POSITIONS, POSITION_VALUES, isValidPosition
|
+-- datasets/ asset metadata (curves, model data)
| +-- assetData/
| +-- curves/ pump / blower / compressor curves
| +-- modelData/ multi-parameter model assets
|
+-- test/ unit + integration tests
+-- scripts/ maintenance scripts
+-- settings/ shared Node-RED-side settings
```
`index.js` is the only contractual import path. Anything not re-exported there is internal; consumers must not reach into `src/...` paths.
---
## How nodes consume the library
| Layer | Consumer responsibility | Library responsibility |
|:---|:---|:---|
| nodeClass | Declare `static DomainClass`, `static commands`, `static tickInterval`, `static statusInterval`. Override `buildDomainConfig(uiConfig, nodeId)` to translate editor values into the domain's config slice. | `BaseNodeAdapter` wires config build &rarr; domain instantiation &rarr; registration delay &rarr; output strategy &rarr; status loop &rarr; input dispatch &rarr; close handler. |
| specificClass | Declare `static name` (matches the schema file). Implement `configure()`: wire `ChildRouter` routes, instantiate concern modules, attach measurement listeners. Implement `getOutput()` and `getStatusBadge()`. | `BaseDomain` provides `this.emitter`, `this.config`, `this.logger`, `this.measurements`, `this.childRegistrationUtils`, `this.router`. |
| commands/index.js | Export an array of descriptors: `{topic, aliases?, units?, payloadSchema?, description, handler}`. Handler is `(source, msg, ctx)`. | `CommandRegistry` builds an `O(1)` lookup, normalises units via `convert`, warns once on alias use, generates the auto-`query.units` topic. |
| measurements | Write via the chain: `this.measurements.type(t).variant(v).position(p, childId).value(x, ts, srcUnit)`. Read via `getCurrentValue(unit)`, `getAverage(unit)`, `getFlattenedOutput()`. | `MeasurementContainer` auto-converts inputs to canonical units (per `UnitPolicy`), maintains windows, emits change events. |
| output | Implement `getOutput()` returning a flat snapshot object. Implement `getStatusBadge()` returning `statusBadge.compose(parts, opts)`. | `outputUtils.formatMsg` delta-compresses the snapshot for Port 0 + Port 1; `StatusUpdater` polls `getStatusBadge()` on `statusInterval`. |
All 12 nodes follow this pattern. Variations are in how richly they fill `configure()` &mdash; `dashboardAPI` has the lightest (HTTP gateway, no FSM); `rotatingMachine` and `machineGroupControl` have the densest (full curve loading, drift assessor, multi-source pressure routing).
---
## Lifecycle &mdash; one tick or event reaches the output port
```mermaid
sequenceDiagram
participant RED as Node-RED runtime
participant BNA as BaseNodeAdapter
participant CMD as CommandRegistry
participant DOM as Domain (specificClass)
participant CR as ChildRouter
participant MC as MeasurementContainer
participant OU as outputUtils
participant PORT as Port 0 / 1 / 2
RED->>BNA: constructor(uiConfig, RED, node, name)
BNA->>BNA: configManager.buildConfig()
BNA->>DOM: new DomainClass(config)
DOM->>MC: new MeasurementContainer(unitPolicy.containerOptions())
DOM->>DOM: configure() — wire ChildRouter, concern modules
BNA-->>PORT: Port 2 registration msg (after 100 ms delay)
BNA->>BNA: start status loop (1000 ms)
Note over RED,PORT: Event-driven path (default)
RED->>BNA: input msg {topic: 'data.pressure', payload: 3.4}
BNA->>CMD: dispatch(msg)
CMD->>CMD: unit normalisation (Pa → mbar)
CMD->>DOM: handler(source, msg, ctx)
DOM->>MC: .type('pressure').variant('measured').position('upstream').value(3.4)
DOM->>DOM: emitter.emit('output-changed')
BNA->>DOM: getOutput()
DOM-->>BNA: flat snapshot object
BNA->>OU: formatMsg(snapshot, config, 'process')
OU-->>BNA: delta msg (only changed fields)
BNA-->>PORT: Port 0 process msg, Port 1 influx msg
Note over RED,PORT: Tick-driven path (opt-in — tickInterval set)
RED->>BNA: timer fires every tickInterval ms
BNA->>DOM: tick()
DOM->>DOM: time-based math; emitter.emit('output-changed')
BNA->>DOM: getOutput()
BNA->>OU: formatMsg(...)
BNA-->>PORT: Port 0 / 1 msgs (delta only)
```
The event path is the default. The tick path is opt-in via `static tickInterval = 1000;` &mdash; only nodes with genuinely time-based math (integrators, ramps, runtime counters) enable it.
---
## Config schema registry
Each consumer node has one JSON schema in `src/configs/`. `ConfigManager.buildConfig` merges the schema defaults with the Node-RED editor values before the domain sees them.
| File | Node | What it defines |
|:---|:---|:---|
| `baseConfig.json` | all nodes | Shared `general`, `asset`, `functionality`, `logging` sections |
| `rotatingMachine.json` | rotatingMachine | Curve selection, startup/shutdown ramps, safety thresholds, unit config |
| `machineGroupControl.json` | machineGroupControl | Demand targets, strategy selection, dispatcher settings |
| `pumpingStation.json` | pumpingStation | Basin geometry, hydraulics, control strategies, safety levels |
| `measurement.json` | measurement | Scaling, smoothing, stability threshold, digital/MQTT mode |
| `valve.json` | valve | Actuator travel time, position limits, FSM config |
| `valveGroupControl.json` | valveGroupControl | Group strategy, demand distribution |
| `reactor.json` | reactor | ASM kinetics, reactor type (CSTR/PFR), volume, influent |
| `settler.json` | settler | Sludge settling parameters, effluent quality |
| `monster.json` | monster | Multi-parameter monitoring, flow bounds, sample intervals |
| `diffuser.json` | diffuser | Aeration model, oxygen transfer parameters |
To add a new node: create `src/configs/<nodeName>.json` extending `baseConfig.json`, declare `static name = '<nodeName>'` in the domain class. `configManager.buildConfig` finds it automatically &mdash; no registration step.
---
## Stability &mdash; additive-only export discipline
Source of truth: [`.claude/rules/general-functions.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/general-functions.md) in the superproject.
| Category | Rule |
|:---|:---|
| **Safe to add** | New named exports. New optional methods on existing classes. New config keys with defaults in the schema. |
| **Requires decision-gate interview** | Removing or renaming any export. Changing a method signature. Changing the output key format of `MeasurementContainer.getFlattenedOutput()`. Changing the `formatMsg` delta-compression behaviour. |
| **Forbidden without migration** | Breaking the 4-segment key shape (`type.variant.position.childId`). Changing Port 0/1/2 payload envelope. Changing the CONTRACTS.md §1&ndash;§9 shapes. |
`generalFunctions` is a git submodule shared by all 12 node repos. A breaking change here requires updating every consumer in a single coordinated commit. Before modifying any module:
```bash
grep -r "require('generalFunctions')" nodes/*/
```
Run the test suites of every affected consumer, not just this library's own tests.
### Canonical units
`MeasurementContainer` and all internal processing assume canonical units:
| Quantity | Canonical |
|:---|:---|
| Pressure | `Pa` |
| Flow | `m3/s` |
| Power | `W` |
| Temperature | `K` |
Unit conversion happens at system boundaries (input via `CommandRegistry.units` normalisation, output via `UnitPolicy.output` rendering) &mdash; never in core logic.
---
## Adding a new export &mdash; the dance
1. Implement the module under `src/<concern>/`.
2. Re-export it from `index.js` (alphabetical within the concern block).
3. Add a row to the appropriate table in [`CONTRACT.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md) with the stability tag.
4. If the export is a new platform shape (a new base class or cross-node protocol), add a section to [`.claude/refactor/CONTRACTS.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) in the superproject.
5. Add a test under `test/`.
## Removing an export
1. Mark it **deprecated** in `CONTRACT.md` (keep the row, change the tag, add a "removed-in" line).
2. Update every consumer in `nodes/*` to use the replacement.
3. Bump submodule pin in the superproject for each touched node.
4. After one release on `development` with no consumers, remove the export and its row.
---
## When NOT to depend on this library
- **Passive HTTP gateway nodes** (e.g. `dashboardAPI`) may skip `BaseDomain` and `BaseNodeAdapter` entirely if they hold no domain state. A plain Node-RED node with HTTP endpoints needs only `logger`, `outputUtils`, and `configManager`.
- **External scripts or standalone tools** that need only unit conversion can import just `const { convert } = require('generalFunctions')` without pulling in the full domain stack.
- **Nodes at a different S88 level** that inherit from a third-party base class must not import from `src/domain/` or `src/nodered/` internal paths &mdash; they may only use root-level exports.
---
## Where to start reading
| If you're changing... | Read first |
|:---|:---|
| Base class for a new domain | `src/domain/BaseDomain.js` + `.claude/refactor/CONTRACTS.md §3` |
| Node-RED adapter behaviour | `src/nodered/BaseNodeAdapter.js` + `.claude/refactor/CONTRACTS.md §2` |
| Topic dispatch, alias warnings, unit normalisation | `src/nodered/commandRegistry.js` + `.claude/refactor/CONTRACTS.md §4` |
| Declarative child registration | `src/domain/ChildRouter.js` + `.claude/refactor/CONTRACTS.md §5` |
| Canonical / output / curve units | `src/domain/UnitPolicy.js` + `.claude/refactor/CONTRACTS.md §6` |
| Measurement chain + flattened output | `src/measurements/MeasurementContainer.js` |
| Delta-compressed output formatting | `src/helper/outputUtils.js` |
| Editor status badge | `src/nodered/statusBadge.js`, `statusUpdater.js`, `.claude/refactor/CONTRACTS.md §7` |
| Async dispatch serialisation | `src/domain/LatestWinsGate.js` + `.claude/refactor/CONTRACTS.md §8` |
| Prediction quality / drift state | `src/domain/HealthStatus.js` + `.claude/refactor/CONTRACTS.md §9` |
| Curve fitting + flow/power prediction | `src/predict/predict_class.js`, `interpolation.js` |
| PID control | `src/pid/PIDController.js` |
| FSM (valve / machine states) | `src/state/` |
| Per-node JSON schema loading | `src/configs/index.js` |
| Asset metadata lookup | `src/registry/AssetResolver.js`, `FileBackend.js`, `HttpBackend.js` |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
| [Reference &mdash; Examples](Reference-Examples) | Usage patterns from real consumer nodes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues, stability rules, deprecations |
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative base-class + protocol spec |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

180
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,180 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-48fa543-blue)
> [!NOTE]
> The full public API surface &mdash; one row per export from `require('generalFunctions')`, with source file, stability tag, and contract summary. Source of truth: `index.js` (the barrel). For an intuitive overview, return to [Home](Home).
>
> **Stability tags:**
>
> - `stable` — API change requires a deprecation cycle and a CONTRACT update.
> - `experimental` — may change without warning; do not depend on the exact shape in production code paths.
> - `deprecated` — kept for backwards compatibility, slated for removal.
---
## Platform base classes
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `BaseDomain` | stable | `src/domain/BaseDomain.js` | Abstract base class for every `specificClass.js`. Provides `emitter`, `config`, `logger`, `measurements`, `childRegistrationUtils`, `router`. Subclass must declare `static name` (maps to the schema JSON file in `src/configs/`) and implement `configure()`. See [CONTRACTS.md §3](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
| `BaseNodeAdapter` | stable | `src/nodered/BaseNodeAdapter.js` | Abstract base for every `nodeClass.js`. Wires config build &rarr; domain instantiation &rarr; registration delay &rarr; output strategy &rarr; status loop &rarr; input dispatch &rarr; close handler. Subclass declares `static DomainClass`, `static commands`, `static tickInterval`, `static statusInterval`, and overrides `buildDomainConfig(uiConfig, nodeId)`. See [CONTRACTS.md §2](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
| `ChildRouter` | stable | `src/domain/ChildRouter.js` | Declarative parent-side child registration. Replaces per-node `registerChild` switch. Chain `.onRegister(softwareType, cb)`, `.onMeasurement(softwareType, filter, cb)`, `.onPrediction(softwareType, filter, cb)`. See [CONTRACTS.md §5](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
| `CommandRegistry` | stable | `src/nodered/commandRegistry.js` | Class form of the command registry. Accepts array of descriptors `{topic, aliases, payloadSchema, units, description, handler}`. Dispatches by `O(1)` lookup, normalises units before handler runs, warns on alias use. |
| `createRegistry` | stable | `src/nodered/commandRegistry.js` | Factory: `createRegistry(descriptors, options) → CommandRegistry`. Used by `BaseNodeAdapter`; rarely needed directly. |
| `UnitPolicy` | stable | `src/domain/UnitPolicy.js` | Declare unit sets: `UnitPolicy.declare({ canonical, output, curve?, requireUnitForTypes? })`. Returns policy with dual method/property access (`policy.canonical('flow')` and `policy.canonical.flow`). Methods: `canonical`, `output`, `curve`, `resolve`, `convert`, `containerOptions`, `setLogger`. See [CONTRACTS.md §6](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
| `LatestWinsGate` | stable | `src/domain/LatestWinsGate.js` | Serialises async dispatches so only the latest value wins. `fire(value)` &mdash; non-blocking. `fireAndWait(value) → Promise` that resolves with dispatch result or `LatestWinsGate.SUPERSEDED`. `drain()` &mdash; await idle. See [CONTRACTS.md §8](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
| `HealthStatus` | stable | `src/domain/HealthStatus.js` | Factory functions for frozen health objects: `HealthStatus.ok(msg, src)`, `HealthStatus.degraded(level, flags, msg, src)`, `HealthStatus.compose(statuses)`. Shape: `{ level: 0..3, flags: string[], message, source }`. See [CONTRACTS.md §9](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
| `statusBadge` | stable | `src/nodered/statusBadge.js` | Pure-function badge builder. `statusBadge.compose(parts, opts?) → {fill, shape, text}`. `statusBadge.error(msg)`, `statusBadge.idle(label)`. Text clipped to 60 chars. See [CONTRACTS.md §7](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
| `StatusUpdater` | stable | `src/nodered/statusUpdater.js` | `new StatusUpdater({ node, source, intervalMs, logger })`. `start()`, `stop()`. Calls `source.getStatusBadge()` on interval; catches errors and shows a red badge. Owned by `BaseNodeAdapter` &mdash; rarely needed directly. |
---
## Measurements
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `MeasurementContainer` | stable | `src/measurements/MeasurementContainer.js` | Chainable measurement store: `.type().variant().position().value(v, ts, srcUnit)`. Query: `getCurrentValue(unit)`, `getAverage(unit)`, `difference({ from, to, unit })`. Introspect: `getFlattenedOutput()` returns 4-segment keyed object (`type.variant.position.childId`). Auto-converts on write to canonical units per the supplied `UnitPolicy`. |
| `POSITIONS` | stable | `src/constants/positions.js` | Frozen enum: `{ UPSTREAM, DOWNSTREAM, AT_EQUIPMENT, DELTA }`. |
| `POSITION_VALUES` | stable | `src/constants/positions.js` | `string[]` of all position strings. |
| `isValidPosition` | stable | `src/constants/positions.js` | `(pos: string) => boolean`. |
### 4-segment output key
The contractual output of `MeasurementContainer.getFlattenedOutput()` is:
```
<type>.<variant>.<position>.<childId>
```
| Segment | Examples | Notes |
|:---|:---|:---|
| `type` | `flow`, `pressure`, `power`, `temperature`, `level`, `efficiency` | Lowercase. |
| `variant` | `predicted`, `measured`, `setpoint`, `max`, `min` | Lowercase. |
| `position` | `upstream`, `downstream`, `atequipment`, `delta` | Always lowercase &mdash; e.g. `atequipment`, not `atEquipment`. |
| `childId` | `default`, `<child.general.id>`, `dashboard-sim-upstream`, &hellip; | `default` for the node's own predictions; otherwise the registering child's id. |
Changing this shape is a forbidden breaking change &mdash; see [Reference &mdash; Limitations](Reference-Limitations#stability--versioning).
---
## Output formatting
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `outputUtils` | stable | `src/helper/outputUtils.js` | Singleton-per-node delta-compression engine. `formatMsg(output, config, format)` returns `msg` only when fields changed, or `undefined`. `format` is `'process'` or `'influxdb'`. Consumers must cache and merge. |
| `logger` | stable | `src/helper/logger.js` | `new logger(enabled, logLevel, moduleName)`. Methods: `debug`, `info`, `warn`, `error`, `setLogLevel`, `toggleLogging`. Use this instead of `console.log`. |
---
## Configuration
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `configManager` | stable | `src/configs/index.js` | `new configManager()`. Methods: `getConfig(name)`, `buildConfig(name, uiConfig, nodeId, domainSlice?)`, `getAvailableConfigs()`, `hasConfig(name)`. Config files live in `src/configs/*.json`. |
| `configUtils` | stable | `src/helper/configUtils.js` | `new configUtils(defaultConfig)`. `initConfig(userConfig)` validates and merges user values over defaults via `validationUtils`. |
| `validation` | stable | `src/helper/validationUtils.js` | `new validation(logEnabled, logLevel)`. `validateSchema(config, schema, name)` walks schema, clamps numbers, coerces types, strips unknown keys. |
| `assertions` | stable | `src/helper/` | Runtime validation primitives. |
| `assetApiConfig` | stable | `src/configs/assetApiConfig.js` | Asset-registry HTTP backend config. |
| `MenuManager` | stable | `src/menu/index.js` | `new MenuManager()`. Manages editor dropdown menus (asset, logger, position, aquon). `registerMenu(type, factory)`. Used in node entry files to power Node-RED editor forms. |
---
## Child registration
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `childRegistrationUtils` | stable | `src/helper/childRegistrationUtils.js` | `new childRegistrationUtils(parentDomain)`. `registerChild(child, positionVsParent, distance?)` stores child by softwareType/category with alias normalisation. `getChildrenOfType(softwareType, category?)`, `getChildById(id)`, `getAllChildren()`. Normally used via `ChildRouter` &mdash; direct use is for advanced cases. |
---
## Unit conversion + physics
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `convert` | stable | `src/convert/index.js` | Unit-converter factory. `convert(value).from(unit).to(unit)`. `convert.possibilities(measure)` lists accepted units. Measures: `volumeFlowRate`, `pressure`, `power`, `temperature`, `volume`, `length`, `mass`, `energy`, `reactivePower`, `apparentPower`, `reactiveEnergy`, and more. |
| `Fysics` | stable | `src/convert/fysics.js` | `new Fysics()`. Physical constants: `air_density`, `g0`; methods for gravity and viscosity calculations. |
| `gravity` | stable | `src/helper/gravity.js` | Singleton-style `Gravity` instance. `getStandardGravity() → 9.80665 m/s²`. WGS-84 latitude / altitude corrections available. |
| `coolprop` | stable | `src/coolprop-node/src/index.js` | CoolProp fluid/gas thermodynamic property lookup. Used by nodes that model heat transfer or gas compression. |
---
## Control & prediction
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `PIDController` | stable | `src/pid/PIDController.js` | Discrete PID with bumpless auto/manual transfer, anti-windup, derivative filtering, rate limiting, gain scheduling, feedforward. |
| `CascadePIDController` | stable | `src/pid/PIDController.js` | Outer-inner PID cascade built on `PIDController`. |
| `createPidController` | stable | `src/pid/index.js` | Factory shorthand: `createPidController(options) → PIDController`. |
| `createCascadePidController` | stable | `src/pid/index.js` | Factory shorthand for cascade PID. |
| `predict` | stable | `src/predict/predict_class.js` | `new predict(config, logger)`. Multidimensional characteristic-curve predictor; emits results via internal `EventEmitter`. |
| `interpolation` | stable | `src/predict/interpolation.js` | Class for 1-D and 2-D curve interpolation (linear, cubic-spline). Used internally by `predict`. |
| `nrmse` | stable | `src/nrmse/index.js` | `ErrorMetrics` class for normalised-root-mean-squared-error tracking. Multi-metric via `registerMetric(id)`, `update(id, predicted, measured)`. |
| `stats` | stable | `src/stats/index.js` | Pure functions: `mean(arr)`, `stdDev(arr)`, `median(arr)`. No state; safe to call on any numeric array. |
| `state` | stable | `src/state/index.js` | `new state(config, logger)`. FSM for valve / machine: `StateManager` (transitions) + `MovementManager` (timed moves). Emits state-change events. |
---
## Asset registry
| Export | Stability | Source | Contract |
|:---|:---|:---|:---|
| `assetResolver` | stable | `src/registry/index.js` | Singleton. `.resolve(category, modelId)` &mdash; sync, case-insensitive, returns `null` on miss. |
| `AssetResolver` | stable | `src/registry/index.js` | Resolver class (for testing / alternate backends). |
| `FileBackend` | stable | `src/registry/` | File-system asset backend. |
| `HttpBackend` | stable | `src/registry/` | HTTP asset backend. |
| `loadCurve` | **deprecated** | `index.js` (shim) | Thin shim over `assetResolver.resolve('curves', modelId)`. New code uses the resolver directly. |
---
## Canonical units (the platform-wide contract)
`MeasurementContainer` and all internal processing assume canonical units. Unit conversion happens at system boundaries (input via `CommandRegistry.units` normalisation, output via `UnitPolicy.output` rendering) &mdash; never in core logic.
| Quantity | Canonical (internal) | Typical output | Typical curve |
|:---|:---|:---|:---|
| Pressure | `Pa` | `mbar` | `mbar` |
| Atmospheric pressure | `Pa` | `Pa` | &mdash; |
| Flow | `m3/s` | `m3/h` | `m3/h` |
| Power | `W` | `kW` | `kW` |
| Temperature | `K` | `°C` | &mdash; |
| Control | &mdash; | &mdash; | `%` |
Each node declares its own `UnitPolicy` (typically as `static unitPolicy = UnitPolicy.declare({...})` on the domain class). The policy is passed to `MeasurementContainer` via `unitPolicy.containerOptions()`.
---
## Output ports (provided by `BaseNodeAdapter`)
Every node that extends `BaseNodeAdapter` automatically gets three ports:
| Port | Carries | Built by | Notes |
|:---|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot &mdash; the `getOutput()` return | `outputUtils.formatMsg(snapshot, config, 'process')` | Emits only when fields change. Consumers must cache and merge. |
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `outputUtils.formatMsg(snapshot, config, 'influxdb')` | Tags + fields per the schema. |
| 2 (register / control) | Parent-child handshake messages | `childRegistrationUtils` via `BaseNodeAdapter` | `child.register` at startup; subsequent `child.measurement` / `child.prediction` events. |
---
## Adding a new export &mdash; the dance
See [Reference &mdash; Architecture](Reference-Architecture#adding-a-new-export--the-dance) for the full step-by-step. Summary:
1. Implement under `src/<concern>/`.
2. Re-export from `index.js` (alphabetical within concern block).
3. Add a row to the appropriate table in [`CONTRACT.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md) with stability tag.
4. If it's a new platform shape, also update [`.claude/refactor/CONTRACTS.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md).
5. Add a test under `test/`.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
| [Reference &mdash; Examples](Reference-Examples) | Usage patterns: extending base classes, registering commands, declaring child routes |
| [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 platform base-class + protocol spec |
| [Library CONTRACT.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md) | Per-export source-of-truth with stability tags |

361
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,361 @@
# 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 |

View File

@@ -0,0 +1,217 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-48fa543-blue)
> [!NOTE]
> What `generalFunctions` does not do, current rough edges, stability/versioning rules, and open questions. For an intuitive overview, return to [Home](Home).
---
## When NOT to depend on this library
- **Passive HTTP gateway nodes** (e.g. `dashboardAPI`) may skip `BaseDomain` and `BaseNodeAdapter` entirely if they hold no domain state. A plain Node-RED node with HTTP endpoints needs only `logger`, `outputUtils`, and `configManager`. See the `dashboardAPI` wiki for the rationale.
- **External scripts or standalone tools** that need only unit conversion can import just `const { convert } = require('generalFunctions')` without pulling in the full domain stack.
- **Nodes at a different S88 level** that inherit from a third-party base class must not import from `src/domain/` or `src/nodered/` internal paths &mdash; they may only use root-level exports.
---
## Known limitations
### 1. `loadCurve` is deprecated
`loadCurve(modelId)` is kept as a thin shim over `assetResolver.resolve('curves', modelId)` so legacy consumers don't have to change in one go. New code should use `assetResolver` directly. Replacement `loadModel` exists but not every node has migrated.
- **Tracked in**: `OPEN_QUESTIONS.md` &mdash; Phase 8.5 cleanup.
### 2. `outlierDetection` (`DynamicClusterDeviation`) prints to `console.log`
The dynamic-cluster outlier detector emits diagnostic lines via `console.log` directly, bypassing the structured `logger`. This means its output cannot be silenced per-node and doesn't honour `logLevel`. Fix is routing through `logger` like the rest of the library.
- **Tracked in**: Code review backlog.
### 3. `configUtils.initConfig` silently strips unknown keys
When the user config carries a key that isn't in the schema, `configUtils.initConfig` (via `validationUtils.validateSchema`) silently drops it. This means a typo in an editor field name or a missed schema entry results in the default value being used &mdash; with no error, no warning, no log line.
Workaround: the schema must include every key the domain reads, with a sensible default. The 2026-05-11 monster schema fix was a direct consequence of this gotcha.
- **Tracked in**: `OPEN_QUESTIONS.md` &mdash; e.g. monster schema fix.
### 4. `state` (FSM) and `predict` are not yet integrated with `BaseDomain` lifecycle
The state machine and the prediction class are exported but not lifecycle-managed by `BaseDomain`. Consumer nodes wire them manually in `configure()` &mdash; constructor, event subscriptions, teardown. A second wave of refactor work will move them under the `BaseDomain` umbrella so subclasses get them for free.
- **Tracked in**: Architecture backlog.
### 5. `menuUtils` / `MenuManager` bypass the Node.js import path
These are served as browser JavaScript via the admin `endpointUtils` and run in the Node-RED editor's iframe. Deep changes require testing in both environments (Node-side schema validation, browser-side editor form rendering). There is no automated test harness for the browser side.
- **Tracked in**: `endpointUtils.js` comments.
### 6. `CascadePIDController` has no dedicated test suite
`PIDController` is unit-tested; the cascade variant is not. Adding tests is on the backlog.
- **Tracked in**: Test backlog.
### 7. Wiki autogen is hand-maintained
The API surface section is hand-maintained between the `<!-- BEGIN/END AUTOGEN: api-surface -->` markers in `CONTRACT.md`. There is no `npm run wiki:all` script (yet); when an export is added or changed, the table must be edited by hand. Mitigation: the source-of-truth is the barrel (`index.js`); when in doubt, trust the barrel.
- **Tracked in**: Phase 9 follow-up.
### 8. Single-side pressure handling lives in consumers
Consumer-node concerns like single-side pressure degradation, residue handling, and sequence-abort semantics are NOT centralised in this library &mdash; each consumer (`rotatingMachine`, `valveGroupControl`, &hellip;) implements its own variant. Cross-node consistency is by convention, not by enforcement. A future `BaseDomain` extension could pull common pressure-routing patterns up.
- **Tracked in**: Internal architecture notes.
### 9. Asset registry backends are not fully symmetric
`FileBackend` is the production default (sync, in-process JSON). `HttpBackend` is provided for remote-resolver scenarios but has fewer call sites and less test coverage. If you switch to `HttpBackend` in production, expect to find edge-case differences.
- **Tracked in**: Internal &mdash; not yet ticketed.
### 10. No editor form
`generalFunctions` is never placed in a flow. It has no Node-RED type registration, no `.html`, no admin endpoint of its own. Consumer nodes expose their own editor forms; each form field writes into a config key that `configManager.buildConfig` validates against the node's schema in `src/configs/<nodeName>.json`. This is a deliberate design choice, not a limitation &mdash; documented here for visitors searching for "where's the editor form".
---
## Stability + versioning
Source of truth: [`.claude/rules/general-functions.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/general-functions.md) in the superproject.
| Category | Rule |
|:---|:---|
| **Safe to add** | New named exports. New optional methods on existing classes. New config keys with defaults in the schema. |
| **Requires decision-gate interview** | Removing or renaming any export. Changing a method signature. Changing the output key format of `MeasurementContainer.getFlattenedOutput()`. Changing the `formatMsg` delta-compression behaviour. |
| **Forbidden without migration** | Breaking the 4-segment key shape (`type.variant.position.childId`). Changing Port 0/1/2 payload envelope. Changing the [CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) §1&ndash;§9 shapes. |
### Cross-node impact
`generalFunctions` is a git submodule shared by all 12 node repos. **Any change here can break any node.** Before modifying any module:
```bash
# Identify all consumers of the symbol you're touching.
grep -r "require('generalFunctions')" nodes/*/
# Or for a specific export:
grep -rn "BaseDomain\|UnitPolicy\|MeasurementContainer" nodes/*/src/
```
After changes, run the test suites of every affected consumer node, not just `generalFunctions/test/`.
### Canonical units
`MeasurementContainer` and all internal processing assume canonical units:
| Quantity | Canonical |
|:---|:---|
| Pressure | `Pa` |
| Flow | `m3/s` |
| Power | `W` |
| Temperature | `K` |
Unit conversion happens at system boundaries (input via `CommandRegistry.units` normalisation, output via `UnitPolicy.output` rendering) &mdash; never in core logic. Code that assumes anything else is a bug.
---
## Deprecations
| Symbol | Status | Replacement | Plan |
|:---|:---|:---|:---|
| `loadCurve(modelId)` | deprecated | `assetResolver.resolve('curves', modelId)` | Remove after every consumer migrates. Tracked in Phase 8.5. |
When a symbol is marked deprecated:
1. The row in `CONTRACT.md` flips to `deprecated` and gains a "removed-in" line.
2. Consumers in `nodes/*` are updated to the replacement.
3. Each touched node's submodule pin is bumped in the superproject.
4. After one release on `development` with no consumers, the export and its row are removed.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Phase 8.5: complete `loadCurve` &rarr; `assetResolver` migration | Internal |
| Route `DynamicClusterDeviation` log lines through `logger` | Code review backlog |
| Surface a warning when `configUtils.initConfig` strips a key not in schema | `OPEN_QUESTIONS.md` |
| Move `state` (FSM) and `predict` under `BaseDomain` lifecycle | Architecture backlog |
| Browser-side test harness for `menuUtils` | `endpointUtils.js` |
| Test suite for `CascadePIDController` | Test backlog |
| Wiki autogen script (`npm run wiki:all`) for the API surface section | Phase 9 follow-up |
| `HttpBackend` test coverage parity with `FileBackend` | Internal |
| Centralised single-side-pressure handling pattern in `BaseDomain` | Internal architecture notes |
---
## Migration notes
### Pre-refactor: per-node `registerChild` switch
The `ChildRouter` replaces hand-written `registerChild(child)` methods. The mechanical migration:
```js
// Before:
registerChild(child) {
switch (child.softwareType) {
case 'measurement':
if (child.config.asset.type === 'pressure' && child.positionVsParent === 'upstream') {
this._onUpstream(child);
} else if (child.config.asset.type === 'flow') {
this._onFlow(child);
}
break;
case 'machinegroup':
this._onMgcChild(child);
break;
}
}
// After (in configure()):
this.router
.onMeasurement('measurement', { type: 'pressure', position: 'upstream' }, (data, child) => this._onUpstream(child))
.onMeasurement('measurement', { type: 'flow' }, (data, child) => this._onFlow(child))
.onRegister('machinegroup', (child) => this._onMgcChild(child));
```
Behaviour is identical (the underlying `childRegistrationUtils` calls are unchanged); the wiring is just declarative.
### Pre-refactor: per-node `getStatusBadge` duplication
The `statusBadge` pure-function helpers replaced 12 copies of slightly different status-text formatters. New domains should use `statusBadge.compose(parts, opts)`, `statusBadge.error(msg)`, `statusBadge.idle(label)` instead of building `{fill, shape, text}` by hand. Text is clipped to 60 chars to fit the Node-RED editor.
### Pre-AssetResolver: `loadCurve` shim
Old code:
```js
const { loadCurve } = require('generalFunctions');
const curve = loadCurve('SomeModel');
```
New code (preferred):
```js
const { assetResolver } = require('generalFunctions');
const curve = assetResolver.resolve('curves', 'SomeModel');
```
The shim still works, but the next API-surface review may remove it. Migrate when next touching the file.
---
## 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; Examples](Reference-Examples) | Usage patterns: extending base classes, registering commands, declaring child routes |
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative platform base-class + protocol spec |
| [`.claude/rules/general-functions.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/general-functions.md) | Stability + change-impact rules |

22
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,22 @@
### generalFunctions (Library)
- [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)
- [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md)
- [Library CONTRACT.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home)
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/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)