Compare commits
14 Commits
c59da5ca98
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c091cdce9 | ||
|
|
c0be50d02c | ||
|
|
bc79de133e | ||
|
|
6c4db03aba | ||
|
|
ae30cef89c | ||
|
|
8252a5f898 | ||
|
|
4f715e8ad6 | ||
|
|
8b28f8969e | ||
|
|
48fa54363d | ||
|
|
ab481357d2 | ||
|
|
49c77f262f | ||
|
|
34a4ef0610 | ||
|
|
af02d36b07 | ||
|
|
f8f71a4f1c |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
28
.npmignore
Normal 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
|
||||||
116
CONTRACT.md
Normal file
116
CONTRACT.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# generalFunctions — Library Contract
|
||||||
|
|
||||||
|
> The public API surface that every EVOLV node depends on. Different shape from
|
||||||
|
> per-node `CONTRACT.md` files: nodes contract on `msg.topic`, this library
|
||||||
|
> contracts on **what `require('generalFunctions')` exports**.
|
||||||
|
|
||||||
|
For deep contracts on the post-refactor platform shapes (`BaseDomain`,
|
||||||
|
`BaseNodeAdapter`, command registry, `UnitPolicy`, `ChildRouter`,
|
||||||
|
`LatestWinsGate`, `HealthStatus`, `statusBadge`), see the platform-level
|
||||||
|
[`.claude/refactor/CONTRACTS.md`](../../.claude/refactor/CONTRACTS.md) in the
|
||||||
|
EVOLV superproject. This file is the index and stability tag per export.
|
||||||
|
|
||||||
|
**Stability tags:**
|
||||||
|
- `stable` — API change requires a deprecation cycle and a CONTRACT update here.
|
||||||
|
- `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 (post-refactor)
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Spec |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `BaseDomain` | class | stable | `src/domain/BaseDomain.js` | [.claude/refactor/CONTRACTS.md §3](../../.claude/refactor/CONTRACTS.md) — extend for all specific domain classes |
|
||||||
|
| `BaseNodeAdapter` | class | stable | `src/nodered/BaseNodeAdapter.js` | [.claude/refactor/CONTRACTS.md §2](../../.claude/refactor/CONTRACTS.md) — extend for all nodeClass adapters |
|
||||||
|
| `CommandRegistry` / `createRegistry` | class / factory | stable | `src/nodered/commandRegistry.js` | [.claude/refactor/CONTRACTS.md §4](../../.claude/refactor/CONTRACTS.md) — builds `Map<topic\|alias, descriptor>` |
|
||||||
|
| `ChildRouter` | class | stable | `src/domain/ChildRouter.js` | [.claude/refactor/CONTRACTS.md §5](../../.claude/refactor/CONTRACTS.md) — declarative parent-side child routing |
|
||||||
|
| `UnitPolicy` | class | stable | `src/domain/UnitPolicy.js` | [.claude/refactor/CONTRACTS.md §6](../../.claude/refactor/CONTRACTS.md) — canonical-unit declaration + render |
|
||||||
|
| `statusBadge` | function | stable | `src/nodered/statusBadge.js` | [.claude/refactor/CONTRACTS.md §7](../../.claude/refactor/CONTRACTS.md) — Node-RED status text/colour |
|
||||||
|
| `StatusUpdater` | class | stable | `src/nodered/statusUpdater.js` | Drives `node.status()` every tick |
|
||||||
|
| `HealthStatus` | class | stable | `src/domain/HealthStatus.js` | [.claude/refactor/CONTRACTS.md §9](../../.claude/refactor/CONTRACTS.md) — prediction-quality / drift state |
|
||||||
|
| `LatestWinsGate` | class | stable | `src/domain/LatestWinsGate.js` | Idempotent-setter gate; prevents redundant dispatches |
|
||||||
|
|
||||||
|
## Output formatting
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `outputUtils` | object | stable | `src/helper/` (re-export) | `.formatMsg(payload, mode)`; `mode ∈ {'process','influxdb'}`; delta compression on `'process'` |
|
||||||
|
| `logger` | object | stable | `src/helper/` (re-export) | Structured logger — use instead of `console.log` |
|
||||||
|
|
||||||
|
## Measurements
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `MeasurementContainer` | class | stable | `src/measurements/` | Chainable `.type().variant().position(childId)` store; emits `<type>.<variant>.<position>` on its `emitter` |
|
||||||
|
| `POSITIONS`, `POSITION_VALUES`, `isValidPosition` | const + helper | stable | `src/constants/positions.js` | Canonical position labels (`upstream`/`downstream`/`atequipment`/…) |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `configManager` | class | stable | `src/configs/` | Loads node-specific JSON schemas from `src/configs/<n>.json`; serves admin endpoint |
|
||||||
|
| `configUtils` | object | stable | `src/helper/` | Schema helpers used by `configManager` |
|
||||||
|
| `assetApiConfig` | object | stable | `src/configs/assetApiConfig.js` | Asset-registry HTTP backend config |
|
||||||
|
| `validation`, `assertions` | object | stable | `src/helper/` | Runtime validation primitives |
|
||||||
|
| `MenuManager` | class | stable | `src/menu/` | Dynamic editor dropdown endpoints |
|
||||||
|
|
||||||
|
## Child registration
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `childRegistrationUtils` | object | stable | `src/helper/` | The handshake utilities `BaseNodeAdapter` uses for parent-child wiring |
|
||||||
|
|
||||||
|
## Conversion & physics
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `convert` | object | stable | `src/convert/` | Unit conversions (used by `UnitPolicy`) |
|
||||||
|
| `Fysics` | class | stable | `src/convert/fysics.js` | Fluid/hydraulic helpers |
|
||||||
|
| `coolprop` | object | stable | `src/coolprop-node/src/index.js` | Thermodynamic property calculations |
|
||||||
|
| `gravity` | object | stable | `src/helper/` | Gravity constants and helpers |
|
||||||
|
|
||||||
|
## Control & prediction
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `PIDController` | class | stable | `src/pid/` | Standard PID; positional and velocity forms |
|
||||||
|
| `CascadePIDController` | class | stable | `src/pid/` | Cascaded outer/inner PID |
|
||||||
|
| `createPidController`, `createCascadePidController` | factory | stable | `src/pid/` | Convenience builders from config |
|
||||||
|
| `predict` | object | stable | `src/predict/` | Series prediction / smoothing |
|
||||||
|
| `interpolation` | object | stable | `src/predict/` | 1-D and 3-D interpolation primitives |
|
||||||
|
| `nrmse` | function | stable | `src/nrmse/` | Normalised RMSE metric (with profile variants) |
|
||||||
|
| `stats` | object | stable | `src/stats/` | Mean/variance/quantile helpers |
|
||||||
|
| `state` | object | stable | `src/state/` | Generic state-machine helpers |
|
||||||
|
|
||||||
|
## Asset registry
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `assetResolver` | singleton | stable | `src/registry/` | `.resolve(category, modelId)` — sync, case-insensitive, returns `null` on miss |
|
||||||
|
| `AssetResolver` | class | stable | `src/registry/` | Resolver type (for testing / alt backends) |
|
||||||
|
| `FileBackend`, `HttpBackend` | class | stable | `src/registry/` | Resolver backends |
|
||||||
|
| `loadCurve` | function | **deprecated** | `index.js` (shim) | Thin shim over `assetResolver.resolve('curves', ...)`. New code uses the resolver directly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new export
|
||||||
|
|
||||||
|
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 above 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](../../.claude/refactor/CONTRACTS.md) in the superproject.
|
||||||
|
5. Add a test under `test/`.
|
||||||
|
|
||||||
|
## Removing an export
|
||||||
|
|
||||||
|
1. Mark it **deprecated** in this file (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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Source of truth for the export list: `index.js` (barrel). If this file and the
|
||||||
|
barrel disagree, the barrel wins — fix this file in the same PR.*
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "machine",
|
|
||||||
"label": "machine",
|
|
||||||
"softwareType": "machine",
|
|
||||||
"suppliers": [
|
|
||||||
{
|
|
||||||
"id": "hidrostal",
|
|
||||||
"name": "Hidrostal",
|
|
||||||
"types": [
|
|
||||||
{
|
|
||||||
"id": "pump-centrifugal",
|
|
||||||
"name": "Centrifugal",
|
|
||||||
"models": [
|
|
||||||
{ "id": "hidrostal-H05K-S03R", "name": "hidrostal-H05K-S03R", "units": ["l/s","m3/h"] },
|
|
||||||
{ "id": "hidrostal-C5-D03R-SHN1", "name": "hidrostal-C5-D03R-SHN1", "units": ["l/s"] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
34
datasets/assetData/rotatingmachine.json
Normal file
34
datasets/assetData/rotatingmachine.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "rotatingmachine",
|
||||||
|
"label": "rotatingMachine",
|
||||||
|
"softwareType": "rotatingmachine",
|
||||||
|
"suppliers": [
|
||||||
|
{
|
||||||
|
"id": "hidrostal",
|
||||||
|
"name": "Hidrostal",
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"id": "pump-centrifugal",
|
||||||
|
"name": "Centrifugal",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "hidrostal-H05K-S03R",
|
||||||
|
"name": "hidrostal-H05K-S03R",
|
||||||
|
"units": [
|
||||||
|
"l/s",
|
||||||
|
"m3/h"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hidrostal-C5-D03R-SHN1",
|
||||||
|
"name": "hidrostal-C5-D03R-SHN1",
|
||||||
|
"units": [
|
||||||
|
"l/s"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -117,18 +117,24 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add asset section if UI provides asset fields
|
// Asset section is emitted per-key: only fields the editor actually
|
||||||
if (uiConfig.supplier || uiConfig.category || uiConfig.assetType || uiConfig.model) {
|
// set propagate to the domain config. Schemas that omit a key (e.g.
|
||||||
config.asset = {
|
// rotatingMachine deliberately drops asset.supplier/category/type
|
||||||
uuid: uiConfig.uuid || uiConfig.assetUuid || null,
|
// because those come from the asset registry at runtime) no longer
|
||||||
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null,
|
// get those keys injected and then stripped by ValidationUtils with
|
||||||
supplier: uiConfig.supplier || 'Unknown',
|
// a warning. Empty strings from HTML defaults stay falsy → omitted →
|
||||||
category: uiConfig.category || 'sensor',
|
// schema default applies.
|
||||||
type: uiConfig.assetType || 'Unknown',
|
const asset = {};
|
||||||
model: uiConfig.model || 'Unknown',
|
const uuid = uiConfig.uuid || uiConfig.assetUuid;
|
||||||
unit: uiConfig.unit || 'unitless'
|
const tagCode = uiConfig.tagCode || uiConfig.assetTagCode;
|
||||||
};
|
if (uuid) asset.uuid = uuid;
|
||||||
}
|
if (tagCode) asset.tagCode = tagCode;
|
||||||
|
if (uiConfig.supplier) asset.supplier = uiConfig.supplier;
|
||||||
|
if (uiConfig.category) asset.category = uiConfig.category;
|
||||||
|
if (uiConfig.assetType) asset.type = uiConfig.assetType;
|
||||||
|
if (uiConfig.model) asset.model = uiConfig.model;
|
||||||
|
if (uiConfig.unit) asset.unit = uiConfig.unit;
|
||||||
|
if (Object.keys(asset).length > 0) config.asset = asset;
|
||||||
|
|
||||||
// Merge domain-specific sections. Must be a DEEP merge: domainConfig
|
// Merge domain-specific sections. Must be a DEEP merge: domainConfig
|
||||||
// commonly returns subsets of `general` / `asset` (e.g. {general:
|
// commonly returns subsets of `general` / `asset` (e.g. {general:
|
||||||
|
|||||||
@@ -91,7 +91,72 @@
|
|||||||
],
|
],
|
||||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"default": null,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional spatial offset from the parent equipment reference. Populated from the editor when hasDistance is enabled; null otherwise."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"distanceUnit": {
|
||||||
|
"default": "m",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unit for the functionality.distance offset (e.g. 'm', 'cm')."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"distanceDescription": {
|
||||||
|
"default": "",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Free-text description of what the distance offset represents."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "process", "description": "Delta-compressed process message (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the process payload emitted on output port 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dbase": {
|
||||||
|
"default": "influxdb",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the telemetry payload emitted on output port 1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"planner": {
|
||||||
|
"useRendezvous": {
|
||||||
|
"default": true,
|
||||||
|
"rules": {
|
||||||
|
"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)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
"current": {
|
"current": {
|
||||||
@@ -107,10 +172,6 @@
|
|||||||
"value": "priorityControl",
|
"value": "priorityControl",
|
||||||
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added."
|
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"value": "prioritypercentagecontrol",
|
|
||||||
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added based on a percentage of the total demand."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": "maintenance",
|
"value": "maintenance",
|
||||||
"description": "The group is in maintenance mode with limited actions (monitoring only)."
|
"description": "The group is in maintenance mode with limited actions (monitoring only)."
|
||||||
@@ -140,14 +201,6 @@
|
|||||||
"description": "Actions allowed in priorityControl mode."
|
"description": "Actions allowed in priorityControl mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prioritypercentagecontrol": {
|
|
||||||
"default": ["statusCheck", "execSequentialControl", "balanceLoad", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in manualOverride mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"default": ["statusCheck"],
|
"default": ["statusCheck"],
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -165,7 +218,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"schema": {
|
"schema": {
|
||||||
"optimalcontrol": {
|
"optimalControl": {
|
||||||
"default": ["parent", "GUI", "physical", "API"],
|
"default": ["parent", "GUI", "physical", "API"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "set",
|
"type": "set",
|
||||||
@@ -173,7 +226,7 @@
|
|||||||
"description": "Command sources allowed in optimalControl mode."
|
"description": "Command sources allowed in optimalControl mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prioritycontrol": {
|
"priorityControl": {
|
||||||
"default": ["parent", "GUI", "physical", "API"],
|
"default": ["parent", "GUI", "physical", "API"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "set",
|
"type": "set",
|
||||||
@@ -181,36 +234,17 @@
|
|||||||
"description": "Command sources allowed in priorityControl mode."
|
"description": "Command sources allowed in priorityControl mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prioritypercentagecontrol": {
|
"maintenance": {
|
||||||
"default": ["parent", "GUI", "physical", "API"],
|
"default": ["parent", "GUI"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "set",
|
"type": "set",
|
||||||
"itemType": "string",
|
"itemType": "string",
|
||||||
"description": "Command sources allowed "
|
"description": "Command sources allowed in maintenance mode. Status/inspection only — physical/HMI and API writes are dropped."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Specifies the valid command sources recognized by the machine group controller for each mode."
|
"description": "Specifies the valid command sources recognized by the machine group controller for each mode."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"scaling": {
|
|
||||||
"current": {
|
|
||||||
"default": "normalized",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "normalized",
|
|
||||||
"description": "Scales the demand between 0–100% of the total flow capacity, interpolating to calculate the effective demand."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "absolute",
|
|
||||||
"description": "Uses the absolute demand value directly, capped between the min and max machine flow capacities."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The scaling mode for demand calculations."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,10 +96,38 @@
|
|||||||
"default": null,
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "process", "description": "Delta-compressed process message (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the process payload emitted on output port 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dbase": {
|
||||||
|
"default": "influxdb",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the telemetry payload emitted on output port 1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
"uuid": {
|
"uuid": {
|
||||||
"default": null,
|
"default": null,
|
||||||
@@ -485,4 +513,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/helper/formatters/frostFormatter.js
Normal file
23
src/helper/formatters/frostFormatter.js
Normal 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 };
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class AssetMenu {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const softwareType = category.softwareType || key;
|
||||||
return {
|
return {
|
||||||
...category,
|
...category,
|
||||||
label: category.label || category.softwareType || key,
|
label: category.label || category.softwareType || key,
|
||||||
@@ -28,11 +29,18 @@ class AssetMenu {
|
|||||||
types: (supplier.types || []).map((type) => ({
|
types: (supplier.types || []).map((type) => ({
|
||||||
...type,
|
...type,
|
||||||
id: type.id || type.name,
|
id: type.id || type.name,
|
||||||
models: (type.models || []).map((model) => ({
|
models: (type.models || []).map((model) => {
|
||||||
...model,
|
const id = model.id || model.name;
|
||||||
id: model.id || model.name,
|
// Enrich each model with a slim preview curve (or null) so the
|
||||||
units: model.units || []
|
// editor wizard can draw a sparkline without a round-trip.
|
||||||
}))
|
const previewCurve = this.buildPreviewCurve(softwareType, id, model.name);
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
id,
|
||||||
|
units: model.units || [],
|
||||||
|
previewCurve: previewCurve || null
|
||||||
|
};
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
@@ -55,7 +63,17 @@ class AssetMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return keys[0];
|
// Previously fell back to keys[0] (alphabetically first category),
|
||||||
|
// which meant a softwareType mismatch silently showed the wrong asset
|
||||||
|
// tree — e.g. rotatingMachine (softwareType='rotatingmachine') with
|
||||||
|
// no matching registry file saw 'diffuser' models in the dropdown.
|
||||||
|
// Return null so the menu renders empty and the operator sees a clear
|
||||||
|
// 'No suppliers available' placeholder instead of a wrong category.
|
||||||
|
console.warn(
|
||||||
|
`[AssetMenu] No asset category matches softwareType='${this.softwareType}' or nodeName='${nodeName}'. ` +
|
||||||
|
`Available categories: [${keys.join(', ')}]. Menu will render empty.`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllMenuData(nodeName) {
|
getAllMenuData(nodeName) {
|
||||||
@@ -76,12 +94,359 @@ class AssetMenu {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-side wizard layer: chips, combobox, spec strip, curve mini-chart.
|
||||||
|
// Listens to change events on the hidden <select>s that wireEvents already
|
||||||
|
// populates — so cascade/reset logic stays in one place.
|
||||||
|
getVisualInjectionCode(nodeName) {
|
||||||
|
return `
|
||||||
|
// Asset wizard visuals for ${nodeName}
|
||||||
|
(function injectAssetWizardCss() {
|
||||||
|
const id = 'evolv-asset-wizard-css';
|
||||||
|
if (document.getElementById(id)) return;
|
||||||
|
const css = [
|
||||||
|
// Asset wizard — tightened layout (smaller radius/padding, no
|
||||||
|
// uppercase label transform, single-line chip text) so the strip
|
||||||
|
// reads as a compact form control instead of a row of pill cards.
|
||||||
|
'.evolv-asset-hidden-natives { position:absolute !important; left:-9999px !important; height:0 !important; overflow:hidden; }',
|
||||||
|
'.evolv-asset-wizard { display:flex; flex-direction:column; gap:8px; margin:6px 0 4px 0; max-width:460px; }',
|
||||||
|
'.evolv-asset-chips { display:flex; flex-wrap:wrap; gap:4px; align-items:center; }',
|
||||||
|
'.evolv-asset-chip {',
|
||||||
|
' display:inline-flex; align-items:baseline; gap:6px;',
|
||||||
|
' border:1px solid #d0d0d0; border-radius:4px; background:#fff;',
|
||||||
|
' padding:3px 8px; cursor:pointer; user-select:none;',
|
||||||
|
' font:inherit; color:#333; height:26px; box-sizing:border-box;',
|
||||||
|
' transition:border-color 80ms ease-out, background 80ms ease-out;',
|
||||||
|
'}',
|
||||||
|
'.evolv-asset-chip:hover { border-color:#86bbdd; background:#f5fafd; }',
|
||||||
|
'.evolv-asset-chip[aria-selected="true"] { border-color:#1F4E79; background:#eaf4fb; }',
|
||||||
|
'.evolv-asset-chip[disabled] { opacity:0.5; cursor:not-allowed; }',
|
||||||
|
'.evolv-asset-chip-icon { color:#607484; font-size:11px; align-self:center; }',
|
||||||
|
'.evolv-asset-chip-text { display:inline-flex; align-items:baseline; gap:5px; line-height:1; }',
|
||||||
|
'.evolv-asset-chip-label { font-size:11px; font-weight:normal; color:#888; letter-spacing:0; text-transform:none; }',
|
||||||
|
'.evolv-asset-chip-label::after { content:":"; color:#bbb; margin-left:1px; }',
|
||||||
|
'.evolv-asset-chip-value { font-size:12px; font-weight:600; color:#1F4E79; max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
|
||||||
|
'.evolv-asset-chip-value[data-empty="true"] { color:#aaa; font-weight:400; font-style:italic; }',
|
||||||
|
'.evolv-asset-chip-sep { color:#bbb; font-size:13px; line-height:1; user-select:none; padding:0 2px; }',
|
||||||
|
'.evolv-asset-combobox { display:flex; flex-direction:column; gap:4px; border:1px solid #d0d0d0; border-radius:3px; background:#fff; padding:6px; }',
|
||||||
|
'.evolv-asset-combobox-search { width:100%; box-sizing:border-box; padding:5px 7px; border:1px solid #ccc; border-radius:3px; font:inherit; font-size:12px; }',
|
||||||
|
'.evolv-asset-combobox-search:focus { outline:none; border-color:#1F4E79; box-shadow:0 0 0 2px rgba(31,78,121,0.15); }',
|
||||||
|
'.evolv-asset-combobox-list { max-height:200px; overflow-y:auto; }',
|
||||||
|
'.evolv-asset-combobox-option {',
|
||||||
|
' padding:5px 8px; cursor:pointer; border-radius:2px;',
|
||||||
|
' font-size:12px; color:#333;',
|
||||||
|
'}',
|
||||||
|
'.evolv-asset-combobox-option:hover,',
|
||||||
|
'.evolv-asset-combobox-option.evolv-asset-combobox-option-active { background:#eaf4fb; color:#1F4E79; }',
|
||||||
|
'.evolv-asset-combobox-empty { padding:5px 8px; color:#888; font-size:11px; font-style:italic; }',
|
||||||
|
'.evolv-asset-summary { display:grid; grid-template-columns:1fr 220px; gap:10px; border:1px solid #e2e2e2; border-radius:3px; padding:8px 10px; background:#fafafa; align-items:center; }',
|
||||||
|
'.evolv-asset-specs { font-size:11.5px; color:#333; display:flex; flex-direction:column; gap:2px; }',
|
||||||
|
'.evolv-asset-spec-row { display:flex; gap:6px; }',
|
||||||
|
'.evolv-asset-spec-key { color:#888; min-width:74px; }',
|
||||||
|
'.evolv-asset-spec-val { color:#1F4E79; font-weight:600; }',
|
||||||
|
'.evolv-asset-curve { width:220px; height:110px; }',
|
||||||
|
'.evolv-asset-curve svg { width:100%; height:100%; display:block; }',
|
||||||
|
'.evolv-asset-curve-empty { display:flex; align-items:center; justify-content:center; color:#aaa; font-size:11px; font-style:italic; text-align:center; }',
|
||||||
|
'.evolv-asset-tag-row { margin-top:2px; align-items:center; }',
|
||||||
|
'.evolv-asset-tag-row > label { width:110px; white-space:nowrap; }',
|
||||||
|
'.evolv-asset-tag-row input[type=text] { width:auto !important; max-width:200px; min-width:140px; font-size:12px; padding:3px 6px; }',
|
||||||
|
'@media (max-width:560px) {',
|
||||||
|
' .evolv-asset-chips { flex-direction:column; align-items:stretch; }',
|
||||||
|
' .evolv-asset-chip-sep { display:none; }',
|
||||||
|
' .evolv-asset-chip { width:100%; justify-content:flex-start; }',
|
||||||
|
' .evolv-asset-summary { grid-template-columns:1fr; }',
|
||||||
|
' .evolv-asset-curve { width:100%; }',
|
||||||
|
'}'
|
||||||
|
].join('\\n');
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = id;
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.EVOLV.nodes.${nodeName}.assetMenu.initVisuals = function(node) {
|
||||||
|
const wizard = document.getElementById('evolv-asset-wizard');
|
||||||
|
if (!wizard) return;
|
||||||
|
|
||||||
|
const stageMap = { supplier: 'node-input-supplier', type: 'node-input-assetType', model: 'node-input-model', unit: 'node-input-unit' };
|
||||||
|
const downstreamOf = { supplier: ['type','model','unit'], type: ['model','unit'], model: ['unit'], unit: [] };
|
||||||
|
const getSelect = (stage) => document.getElementById(stageMap[stage]);
|
||||||
|
|
||||||
|
const chips = Array.from(wizard.querySelectorAll('.evolv-asset-chip'));
|
||||||
|
const combobox = document.getElementById('evolv-asset-combobox');
|
||||||
|
const search = combobox ? combobox.querySelector('.evolv-asset-combobox-search') : null;
|
||||||
|
const list = combobox ? combobox.querySelector('.evolv-asset-combobox-list') : null;
|
||||||
|
const summary = document.getElementById('evolv-asset-summary');
|
||||||
|
const specsEl = document.getElementById('evolv-asset-specs');
|
||||||
|
const curveEl = document.getElementById('evolv-asset-curve');
|
||||||
|
|
||||||
|
let activeStage = null;
|
||||||
|
let activeIndex = -1;
|
||||||
|
|
||||||
|
// Update the chip value text from the live <select>. Empty selects
|
||||||
|
// show the placeholder; populated selects show the option label.
|
||||||
|
function syncChip(stage) {
|
||||||
|
const chip = chips.find((c) => c.getAttribute('data-stage') === stage);
|
||||||
|
if (!chip) return;
|
||||||
|
const select = getSelect(stage);
|
||||||
|
const valueEl = chip.querySelector('.evolv-asset-chip-value');
|
||||||
|
const labelDefault = stage === 'supplier' ? 'Select…' : '—';
|
||||||
|
if (!select || !select.value) {
|
||||||
|
valueEl.textContent = labelDefault;
|
||||||
|
valueEl.setAttribute('data-empty', 'true');
|
||||||
|
chip.disabled = false; // stage is reachable but empty
|
||||||
|
} else {
|
||||||
|
const opt = select.options[select.selectedIndex];
|
||||||
|
valueEl.textContent = (opt && opt.textContent) ? opt.textContent : select.value;
|
||||||
|
valueEl.removeAttribute('data-empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAllChips() {
|
||||||
|
['supplier','type','model','unit'].forEach(syncChip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAriaSelected() {
|
||||||
|
chips.forEach((c) => c.setAttribute('aria-selected', c.getAttribute('data-stage') === activeStage ? 'true' : 'false'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCombobox() {
|
||||||
|
activeStage = null;
|
||||||
|
combobox.hidden = true;
|
||||||
|
refreshAriaSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStage(stage) {
|
||||||
|
const select = getSelect(stage);
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
// Skip if the parent stage hasn't been resolved (e.g. type before supplier).
|
||||||
|
// The parent select would have an empty value in that case.
|
||||||
|
const parentOrder = ['supplier','type','model','unit'];
|
||||||
|
const idx = parentOrder.indexOf(stage);
|
||||||
|
for (let i = 0; i < idx; i += 1) {
|
||||||
|
const parentSel = getSelect(parentOrder[i]);
|
||||||
|
if (!parentSel || !parentSel.value) {
|
||||||
|
if (window.RED && window.RED.notify) {
|
||||||
|
window.RED.notify('Pick ' + parentOrder[i] + ' first.', 'info');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStage = stage;
|
||||||
|
combobox.hidden = false;
|
||||||
|
search.value = '';
|
||||||
|
search.placeholder = 'Filter ' + stage + '…';
|
||||||
|
renderList('');
|
||||||
|
refreshAriaSelected();
|
||||||
|
// Move focus to the search box so keyboard users get an immediate
|
||||||
|
// typing context after clicking a chip.
|
||||||
|
setTimeout(() => search.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStageOptions(stage) {
|
||||||
|
const select = getSelect(stage);
|
||||||
|
if (!select) return [];
|
||||||
|
return Array.from(select.options)
|
||||||
|
.filter((o) => o.value !== '' && !o.disabled)
|
||||||
|
.map((o) => ({ value: o.value, label: o.textContent || o.value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(filter) {
|
||||||
|
if (!activeStage || !list) return;
|
||||||
|
const items = getStageOptions(activeStage);
|
||||||
|
const lc = String(filter || '').toLowerCase();
|
||||||
|
const matches = items.filter((it) => it.label.toLowerCase().includes(lc) || it.value.toLowerCase().includes(lc));
|
||||||
|
list.innerHTML = '';
|
||||||
|
activeIndex = matches.length ? 0 : -1;
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'evolv-asset-combobox-empty';
|
||||||
|
empty.textContent = items.length ? 'No matches.' : 'Nothing available — pick the previous stage first.';
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.forEach((it, i) => {
|
||||||
|
const opt = document.createElement('div');
|
||||||
|
opt.className = 'evolv-asset-combobox-option';
|
||||||
|
if (i === 0) opt.classList.add('evolv-asset-combobox-option-active');
|
||||||
|
opt.setAttribute('role', 'option');
|
||||||
|
opt.setAttribute('data-value', it.value);
|
||||||
|
opt.textContent = it.label;
|
||||||
|
opt.addEventListener('mousedown', (e) => { e.preventDefault(); pickValue(it.value); });
|
||||||
|
opt.addEventListener('mouseenter', () => {
|
||||||
|
activeIndex = i;
|
||||||
|
list.querySelectorAll('.evolv-asset-combobox-option').forEach((el, j) => el.classList.toggle('evolv-asset-combobox-option-active', j === i));
|
||||||
|
});
|
||||||
|
list.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickValue(value) {
|
||||||
|
const select = getSelect(activeStage);
|
||||||
|
if (!select) return;
|
||||||
|
// Reset downstream selects so the cascade refreshes cleanly.
|
||||||
|
(downstreamOf[activeStage] || []).forEach((s) => {
|
||||||
|
const ds = getSelect(s);
|
||||||
|
if (ds) { ds.value = ''; ds.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||||
|
});
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
syncAllChips();
|
||||||
|
updateSummary();
|
||||||
|
closeCombobox();
|
||||||
|
|
||||||
|
// Auto-advance to the next empty stage so the flow feels guided.
|
||||||
|
const order = ['supplier','type','model','unit'];
|
||||||
|
const i = order.indexOf(activeStage);
|
||||||
|
for (let n = i + 1; n < order.length; n += 1) {
|
||||||
|
const next = getSelect(order[n]);
|
||||||
|
if (next && (!next.value || next.options.length > 1)) {
|
||||||
|
openStage(order[n]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary() {
|
||||||
|
const modelSel = getSelect('model');
|
||||||
|
if (!modelSel || !modelSel.value) {
|
||||||
|
if (summary) summary.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (summary) summary.hidden = false;
|
||||||
|
|
||||||
|
// Lookup the chosen model in the menuData tree to pull metadata + previewCurve.
|
||||||
|
const data = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||||
|
const categories = data.categories || {};
|
||||||
|
let chosenModel = null;
|
||||||
|
Object.keys(categories).forEach((catKey) => {
|
||||||
|
const cat = categories[catKey];
|
||||||
|
(cat.suppliers || []).forEach((sup) => (sup.types || []).forEach((t) => (t.models || []).forEach((m) => {
|
||||||
|
if (String(m.id || m.name) === String(modelSel.value)) chosenModel = m;
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSpecs(chosenModel);
|
||||||
|
renderCurve(chosenModel && chosenModel.previewCurve);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpecs(model) {
|
||||||
|
if (!specsEl) return;
|
||||||
|
specsEl.innerHTML = '';
|
||||||
|
if (!model) return;
|
||||||
|
const rows = [];
|
||||||
|
if (model.name) rows.push({ key: 'Name', val: model.name });
|
||||||
|
if (model.id && model.id !== model.name) rows.push({ key: 'ID', val: model.id });
|
||||||
|
if (Array.isArray(model.units) && model.units.length) rows.push({ key: 'Units', val: model.units.join(', ') });
|
||||||
|
// Pull any leftover scalar keys (rated_kW, voltage, etc.) — heuristic.
|
||||||
|
Object.keys(model).forEach((k) => {
|
||||||
|
if (['name','id','units','previewCurve','product_model_id','product_model_uuid'].indexOf(k) >= 0) return;
|
||||||
|
const v = model[k];
|
||||||
|
if (v == null) return;
|
||||||
|
if (typeof v === 'object') return;
|
||||||
|
rows.push({ key: k, val: String(v) });
|
||||||
|
});
|
||||||
|
rows.slice(0, 5).forEach((r) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'evolv-asset-spec-row';
|
||||||
|
row.innerHTML = '<span class="evolv-asset-spec-key">' + r.key + '</span><span class="evolv-asset-spec-val">' + r.val + '</span>';
|
||||||
|
specsEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurve(curve) {
|
||||||
|
if (!curveEl) return;
|
||||||
|
curveEl.innerHTML = '';
|
||||||
|
if (!curve || !Array.isArray(curve.x) || !Array.isArray(curve.y) || curve.x.length < 2) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'evolv-asset-curve-empty';
|
||||||
|
empty.textContent = 'no curve available';
|
||||||
|
curveEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const W = 200, H = 90, P = 6;
|
||||||
|
const xs = curve.x, ys = curve.y;
|
||||||
|
const xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
|
||||||
|
const yMin = Math.min.apply(null, ys), yMax = Math.max.apply(null, ys);
|
||||||
|
const xRange = xMax - xMin || 1, yRange = yMax - yMin || 1;
|
||||||
|
const px = (x) => P + (W - 2*P) * (x - xMin) / xRange;
|
||||||
|
const py = (y) => (H - P) - (H - 2*P) * (y - yMin) / yRange;
|
||||||
|
const pts = xs.map((x, i) => px(x).toFixed(1) + ',' + py(ys[i]).toFixed(1)).join(' ');
|
||||||
|
const svg = [
|
||||||
|
'<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">',
|
||||||
|
' <rect x="0" y="0" width="' + W + '" height="' + H + '" fill="#fff" stroke="#e5e5e5"/>',
|
||||||
|
' <polyline fill="none" stroke="#1F4E79" stroke-width="1.6" points="' + pts + '"/>',
|
||||||
|
' <g font-size="8" fill="#888" font-family="Arial, sans-serif">',
|
||||||
|
' <text x="' + P + '" y="9">' + (curve.yLabel || '') + '</text>',
|
||||||
|
' <text x="' + (W - P) + '" y="' + (H - 2) + '" text-anchor="end">' + (curve.xLabel || '') + '</text>',
|
||||||
|
(curve.legend ? '<text x="' + (W - P) + '" y="9" text-anchor="end" fill="#1F4E79">' + curve.legend + '</text>' : ''),
|
||||||
|
' </g>',
|
||||||
|
'</svg>'
|
||||||
|
].join('');
|
||||||
|
curveEl.innerHTML = svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire chip clicks + select-change → chip refresh -------------
|
||||||
|
chips.forEach((chip) => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
const stage = chip.getAttribute('data-stage');
|
||||||
|
if (activeStage === stage) {
|
||||||
|
closeCombobox();
|
||||||
|
} else {
|
||||||
|
openStage(stage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['supplier','type','model','unit'].forEach((stage) => {
|
||||||
|
const sel = getSelect(stage);
|
||||||
|
if (sel) sel.addEventListener('change', () => { syncChip(stage); if (stage === 'model' || stage === 'unit') updateSummary(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Combobox interactions -------------------------------------
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener('input', () => renderList(search.value));
|
||||||
|
search.addEventListener('keydown', (e) => {
|
||||||
|
const optEls = Array.from(list.querySelectorAll('.evolv-asset-combobox-option'));
|
||||||
|
if (!optEls.length) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex + 1) % optEls.length;
|
||||||
|
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
|
||||||
|
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex - 1 + optEls.length) % optEls.length;
|
||||||
|
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
|
||||||
|
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0 && optEls[activeIndex]) {
|
||||||
|
pickValue(optEls[activeIndex].getAttribute('data-value'));
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeCombobox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render — fires after loadData has populated the natives.
|
||||||
|
syncAllChips();
|
||||||
|
updateSummary();
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
getClientInitCode(nodeName) {
|
getClientInitCode(nodeName) {
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
const eventsCode = this.getEventInjectionCode(nodeName);
|
||||||
const syncCode = this.getSyncInjectionCode(nodeName);
|
const syncCode = this.getSyncInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- AssetMenu for ${nodeName} ---
|
// --- AssetMenu for ${nodeName} ---
|
||||||
@@ -93,14 +458,19 @@ class AssetMenu {
|
|||||||
${eventsCode}
|
${eventsCode}
|
||||||
${syncCode}
|
${syncCode}
|
||||||
${saveCode}
|
${saveCode}
|
||||||
|
${visualCode}
|
||||||
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
||||||
console.log('Initializing asset properties for ${nodeName}');
|
console.log('Initializing asset properties for ${nodeName}');
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
this.loadData(node).catch((error) =>
|
const self = this;
|
||||||
console.error('Asset menu load failed:', error)
|
this.loadData(node)
|
||||||
);
|
.then(() => { if (self.initVisuals) self.initVisuals(node); })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Asset menu load failed:', error);
|
||||||
|
if (self.initVisuals) self.initVisuals(node);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -253,6 +623,26 @@ class AssetMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suppliers = activeCategory ? activeCategory.suppliers : [];
|
const suppliers = activeCategory ? activeCategory.suppliers : [];
|
||||||
|
|
||||||
|
// The save handler intentionally discards node.supplier / node.assetType
|
||||||
|
// (denormalized copies of registry data — only node.model + node.unit
|
||||||
|
// are persisted identity). So on reopen we re-derive them from the
|
||||||
|
// saved model id by walking the registry tree. Without this the
|
||||||
|
// cascade always boots at "Select..." even when a model is saved.
|
||||||
|
if (node.model && (!node.supplier || !node.assetType)) {
|
||||||
|
for (const supplier of suppliers) {
|
||||||
|
const match = (supplier.types || []).find((type) =>
|
||||||
|
(type.models || []).some((model) =>
|
||||||
|
String(model.id || model.name) === String(node.model))
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
node.supplier = supplier.id || supplier.name;
|
||||||
|
node.assetType = match.id || match.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
populate(
|
populate(
|
||||||
elems.supplier,
|
elems.supplier,
|
||||||
suppliers,
|
suppliers,
|
||||||
@@ -577,35 +967,165 @@ class AssetMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getHtmlTemplate() {
|
getHtmlTemplate() {
|
||||||
|
// Wizard layout:
|
||||||
|
// 1. Section heading + chip strip (Supplier › Type › Model › Unit).
|
||||||
|
// Chips are clickable buttons; clicking re-opens that stage's combobox
|
||||||
|
// and resets everything to its right.
|
||||||
|
// 2. Active-stage combobox: search input + filtered option list.
|
||||||
|
// 3. Spec strip + curve mini-chart (visible once a Model is picked).
|
||||||
|
// 4. Asset Tag row (still read-only, auto-resolved by syncAsset).
|
||||||
|
// 5. Hidden native <select>s (canonical save targets — Node-RED reads
|
||||||
|
// these on save; chip clicks mirror values into them).
|
||||||
return `
|
return `
|
||||||
<!-- Asset Properties -->
|
|
||||||
<hr />
|
<hr />
|
||||||
<h3>Asset selection</h3>
|
<h3>Asset selection</h3>
|
||||||
<div class="form-row">
|
<div class="evolv-asset-wizard" id="evolv-asset-wizard">
|
||||||
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
|
<div class="evolv-asset-chips" role="tablist" aria-label="Asset selection stages">
|
||||||
<select id="node-input-supplier" style="width:70%;"></select>
|
<button type="button" class="evolv-asset-chip" data-stage="supplier" aria-selected="false">
|
||||||
</div>
|
<span class="evolv-asset-chip-icon"><i class="fa fa-industry"></i></span>
|
||||||
<div class="form-row">
|
<span class="evolv-asset-chip-text">
|
||||||
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
|
<span class="evolv-asset-chip-label">Supplier</span>
|
||||||
<select id="node-input-assetType" style="width:70%;"></select>
|
<span class="evolv-asset-chip-value" data-empty="true">Select…</span>
|
||||||
</div>
|
</span>
|
||||||
<div class="form-row">
|
</button>
|
||||||
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
<select id="node-input-model" style="width:70%;"></select>
|
<button type="button" class="evolv-asset-chip" data-stage="type" aria-selected="false">
|
||||||
</div>
|
<span class="evolv-asset-chip-icon"><i class="fa fa-puzzle-piece"></i></span>
|
||||||
<div class="form-row">
|
<span class="evolv-asset-chip-text">
|
||||||
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
|
<span class="evolv-asset-chip-label">Type</span>
|
||||||
<select id="node-input-unit" style="width:70%;"></select>
|
<span class="evolv-asset-chip-value" data-empty="true">—</span>
|
||||||
</div>
|
</span>
|
||||||
<div class="form-row">
|
</button>
|
||||||
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
|
<button type="button" class="evolv-asset-chip" data-stage="model" aria-selected="false">
|
||||||
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
|
<span class="evolv-asset-chip-icon"><i class="fa fa-wrench"></i></span>
|
||||||
|
<span class="evolv-asset-chip-text">
|
||||||
|
<span class="evolv-asset-chip-label">Model</span>
|
||||||
|
<span class="evolv-asset-chip-value" data-empty="true">—</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
|
<button type="button" class="evolv-asset-chip" data-stage="unit" aria-selected="false">
|
||||||
|
<span class="evolv-asset-chip-icon"><i class="fa fa-balance-scale"></i></span>
|
||||||
|
<span class="evolv-asset-chip-text">
|
||||||
|
<span class="evolv-asset-chip-label">Unit</span>
|
||||||
|
<span class="evolv-asset-chip-value" data-empty="true">—</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evolv-asset-combobox" id="evolv-asset-combobox" hidden>
|
||||||
|
<input type="text" class="evolv-asset-combobox-search" placeholder="Type to filter…" autocomplete="off" />
|
||||||
|
<div class="evolv-asset-combobox-list" role="listbox"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evolv-asset-summary" id="evolv-asset-summary" hidden>
|
||||||
|
<div class="evolv-asset-specs" id="evolv-asset-specs"></div>
|
||||||
|
<div class="evolv-asset-curve" id="evolv-asset-curve"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row evolv-asset-tag-row">
|
||||||
|
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
|
||||||
|
<input type="text" id="node-input-assetTagNumber" readonly />
|
||||||
|
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evolv-asset-hidden-natives" aria-hidden="true">
|
||||||
|
<select id="node-input-supplier"></select>
|
||||||
|
<select id="node-input-assetType"></select>
|
||||||
|
<select id="node-input-model"></select>
|
||||||
|
<select id="node-input-unit"></select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a slim preview curve `{x[], y[], xLabel, yLabel}` per model so the
|
||||||
|
// editor wizard can render a sparkline without round-tripping. Picks a
|
||||||
|
// representative slice for each software type's curve format.
|
||||||
|
buildPreviewCurve(softwareType, modelId, modelName) {
|
||||||
|
if (!modelId && !modelName) return null;
|
||||||
|
let loadCurve;
|
||||||
|
try {
|
||||||
|
// Lazy require — keep AssetMenu importable in environments that don't
|
||||||
|
// ship the curves dataset (e.g. unit tests with mocked managers).
|
||||||
|
loadCurve = require('../../index.js').loadCurve;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof loadCurve !== 'function') return null;
|
||||||
|
|
||||||
|
// Try id first, then name (legacy curve files are named after the
|
||||||
|
// model name rather than id — e.g. ECDV.json).
|
||||||
|
let curve = null;
|
||||||
|
try { curve = loadCurve(modelId) || (modelName ? loadCurve(modelName) : null); } catch (e) { curve = null; }
|
||||||
|
if (!curve) return null;
|
||||||
|
|
||||||
|
const type = String(softwareType || '').toLowerCase();
|
||||||
|
|
||||||
|
// Helpers — pick a "middle" key from an object whose keys are numeric strings.
|
||||||
|
const middleKey = (obj) => {
|
||||||
|
const keys = Object.keys(obj || {});
|
||||||
|
if (!keys.length) return null;
|
||||||
|
const sorted = keys.slice().sort((a, b) => Number(a) - Number(b));
|
||||||
|
return sorted[Math.floor(sorted.length / 2)];
|
||||||
|
};
|
||||||
|
const maxKey = (obj) => {
|
||||||
|
const keys = Object.keys(obj || {});
|
||||||
|
if (!keys.length) return null;
|
||||||
|
return keys.slice().sort((a, b) => Number(b) - Number(a))[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === 'rotatingmachine') {
|
||||||
|
// { np: { rpm: { x:[%speed], y:[..] } } } — pick top RPM slice.
|
||||||
|
const np = curve.np || curve;
|
||||||
|
const rpm = maxKey(np);
|
||||||
|
if (!rpm || !np[rpm] || !Array.isArray(np[rpm].x)) return null;
|
||||||
|
return {
|
||||||
|
x: np[rpm].x.slice(),
|
||||||
|
y: np[rpm].y.slice(),
|
||||||
|
xLabel: 'Speed (%)',
|
||||||
|
yLabel: 'Power',
|
||||||
|
legend: rpm + ' rpm'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'valve') {
|
||||||
|
// { density: { dp: { x:[%opening], y:[m3/h] } } } — pick mid density/dp.
|
||||||
|
const densityKey = middleKey(curve);
|
||||||
|
if (!densityKey) return null;
|
||||||
|
const dpMap = curve[densityKey] || {};
|
||||||
|
const dpKey = middleKey(dpMap);
|
||||||
|
if (!dpKey || !dpMap[dpKey] || !Array.isArray(dpMap[dpKey].x)) return null;
|
||||||
|
return {
|
||||||
|
x: dpMap[dpKey].x.slice(),
|
||||||
|
y: dpMap[dpKey].y.slice(),
|
||||||
|
xLabel: 'Opening (%)',
|
||||||
|
yLabel: 'Flow (m³/h)',
|
||||||
|
legend: 'ρ=' + densityKey + ' · Δp=' + dpKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'diffuser') {
|
||||||
|
// { sote_curve: { coverage: { x:[flux], y:[%] } }, ... } — pick mid coverage on sote_curve.
|
||||||
|
const sote = curve.sote_curve || curve.SOTE_curve || curve;
|
||||||
|
const covKey = middleKey(sote);
|
||||||
|
if (!covKey || !sote[covKey] || !Array.isArray(sote[covKey].x)) return null;
|
||||||
|
return {
|
||||||
|
x: sote[covKey].x.slice(),
|
||||||
|
y: sote[covKey].y.slice(),
|
||||||
|
xLabel: 'Flux (Nm³/h·m²)',
|
||||||
|
yLabel: 'SOTE (%)',
|
||||||
|
legend: covKey + '% coverage'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// measurement + unknowns: no representative curve yet.
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getHtmlInjectionCode(nodeName) {
|
getHtmlInjectionCode(nodeName) {
|
||||||
const htmlTemplate = this.getHtmlTemplate()
|
const htmlTemplate = this.getHtmlTemplate()
|
||||||
.replace(/`/g, '\\`')
|
.replace(/`/g, '\\`')
|
||||||
|
|||||||
359
src/menu/iconHelpers.js
Normal file
359
src/menu/iconHelpers.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// iconHelpers.js — shared visual layer for EVOLV editor menus.
|
||||||
|
//
|
||||||
|
// The other menu modules (logger, physicalPosition, …) render their HTML
|
||||||
|
// as plain Node-RED form rows with native <select>/<input> controls. This
|
||||||
|
// module emits a single client-side helper bundle (`window.EVOLV.iconHelpers`)
|
||||||
|
// that those menus call from their `initVisuals(node)` step to upgrade the
|
||||||
|
// native controls in-place to icon cards.
|
||||||
|
//
|
||||||
|
// The native controls stay in the DOM (hidden) so Node-RED's load/save
|
||||||
|
// path is untouched — clicks on the cards mirror back into the original
|
||||||
|
// <select>/<input>.
|
||||||
|
|
||||||
|
class IconHelpers {
|
||||||
|
static getClientInitCode() {
|
||||||
|
// Single IIFE so multiple menus on the same editor session share one
|
||||||
|
// copy of the helpers + one <style> tag.
|
||||||
|
return `
|
||||||
|
window.EVOLV = window.EVOLV || {};
|
||||||
|
if (!window.EVOLV.iconHelpers) {
|
||||||
|
window.EVOLV.iconHelpers = (function () {
|
||||||
|
const BLUE = '#1F4E79';
|
||||||
|
const STEEL = '#607484';
|
||||||
|
const UNIT = '#50a8d9';
|
||||||
|
const RED = '#B03A2E';
|
||||||
|
const AMBER = '#B7791F';
|
||||||
|
|
||||||
|
// ---- CSS (injected once) -----------------------------------
|
||||||
|
const CSS_ID = 'evolv-icon-pickers-css';
|
||||||
|
if (!document.getElementById(CSS_ID)) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = CSS_ID;
|
||||||
|
style.textContent = [
|
||||||
|
'.evolv-icon-picker { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 8px 0; }',
|
||||||
|
'.evolv-icon-option {',
|
||||||
|
' width:72px; height:72px; box-sizing:border-box;',
|
||||||
|
' border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;',
|
||||||
|
' padding:4px; cursor:pointer; user-select:none;',
|
||||||
|
' display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;',
|
||||||
|
' transition:border-color 80ms ease-out, background 80ms ease-out, opacity 80ms ease-out;',
|
||||||
|
'}',
|
||||||
|
'.evolv-icon-option:hover { border-color:#86bbdd; background:#f5fafd; }',
|
||||||
|
'.evolv-icon-option:focus { outline:2px solid #1F4E79; outline-offset:2px; }',
|
||||||
|
'.evolv-icon-option-on { border-color:#50a8d9; background:#eaf4fb; }',
|
||||||
|
'.evolv-icon-glyph { width:100%; height:46px; display:flex; align-items:center; justify-content:center; }',
|
||||||
|
'.evolv-icon-option svg { width:100%; height:100%; display:block; }',
|
||||||
|
'.evolv-icon-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }',
|
||||||
|
'.evolv-icon-option:not(.evolv-icon-option-on) .evolv-icon-label { color:#888; }',
|
||||||
|
'.evolv-icon-option-on .evolv-off-cross { display:none; }',
|
||||||
|
'.evolv-native-hidden { position:absolute !important; opacity:0 !important; width:1px !important; height:1px !important; pointer-events:none !important; }',
|
||||||
|
'.evolv-native-row-compact label { display:none; }',
|
||||||
|
'.evolv-compact-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:6px 0 8px 0; }',
|
||||||
|
'.evolv-log-toggle:not(.evolv-icon-option-on) svg .evolv-log-symbol,',
|
||||||
|
'.evolv-distance-toggle:not(.evolv-icon-option-on) svg .evolv-ruler-body { opacity:0.45; filter:grayscale(1); }',
|
||||||
|
].join('\\n');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SVG library (inline, no external assets) --------------
|
||||||
|
const SVG = {
|
||||||
|
error: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<circle cx="40" cy="29" r="13" fill="#fff" stroke="\${RED}" stroke-width="3"/>
|
||||||
|
<line x1="34" y1="23" x2="46" y2="35" stroke="\${RED}" stroke-width="3.4" stroke-linecap="round"/>
|
||||||
|
<line x1="46" y1="23" x2="34" y2="35" stroke="\${RED}" stroke-width="3.4" stroke-linecap="round"/>
|
||||||
|
</svg>\`,
|
||||||
|
warn: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M40 17 L54 41 H26 Z" fill="#fff" stroke="\${AMBER}" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<line x1="40" y1="25" x2="40" y2="33" stroke="\${AMBER}" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<circle cx="40" cy="38" r="2.2" fill="\${AMBER}"/>
|
||||||
|
</svg>\`,
|
||||||
|
info: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<circle cx="40" cy="29" r="13" fill="#fff" stroke="\${BLUE}" stroke-width="3"/>
|
||||||
|
<line x1="40" y1="28" x2="40" y2="37" stroke="\${BLUE}" stroke-width="3.2" stroke-linecap="round"/>
|
||||||
|
<circle cx="40" cy="22" r="2.4" fill="\${BLUE}"/>
|
||||||
|
</svg>\`,
|
||||||
|
debug: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="13" y="12" width="54" height="34" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M20 34 H29 L33 24 L40 39 L46 29 H59" fill="none" stroke="\${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="59" cy="29" r="3" fill="\${UNIT}" stroke="#fff" stroke-width="1"/>
|
||||||
|
</svg>\`,
|
||||||
|
logToggle: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<g class="evolv-log-symbol">
|
||||||
|
<rect x="10" y="10" width="60" height="38" rx="3" fill="#1F2933" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M18 22 L26 29 L18 36" fill="none" stroke="#7ED957" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="30" y1="38" x2="50" y2="38" stroke="#7ED957" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<g class="evolv-off-cross" stroke="\${RED}" stroke-width="4.5" stroke-linecap="round">
|
||||||
|
<line x1="14" y1="12" x2="66" y2="46"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
// Position icons — depict the PARENT equipment (pump volute +
|
||||||
|
// motor stub) plus a sensor marker located in the suction pipe
|
||||||
|
// (upstream), atop the equipment (atEquipment), or in the
|
||||||
|
// discharge pipe (downstream). Flow direction: left → right.
|
||||||
|
upstream: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<!-- suction pipe + flow arrow -->
|
||||||
|
<rect x="2" y="26" width="40" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="6" y1="31" x2="34" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="32,27 32,35 39,31" fill="\${BLUE}"/>
|
||||||
|
<!-- sensor marker on suction pipe -->
|
||||||
|
<line x1="20" y1="14" x2="20" y2="26" stroke="\${RED}" stroke-width="1.8"/>
|
||||||
|
<circle cx="20" cy="11" r="5" fill="#fff" stroke="\${RED}" stroke-width="2"/>
|
||||||
|
<circle cx="20" cy="11" r="1.6" fill="\${RED}"/>
|
||||||
|
<!-- pump (volute) + impeller hint + motor stub -->
|
||||||
|
<circle cx="60" cy="31" r="13" fill="#fff" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<path d="M 60 22 Q 68 26 68 31 Q 68 36 60 40 Q 52 36 52 31 Q 52 26 60 22" fill="none" stroke="#86bbdd" stroke-width="1.3"/>
|
||||||
|
<rect x="55" y="13" width="10" height="6" fill="\${STEEL}" stroke="#333" stroke-width="0.8"/>
|
||||||
|
</svg>\`,
|
||||||
|
atEquipment: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<!-- inlet stub -->
|
||||||
|
<rect x="2" y="26" width="22" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="4" y1="31" x2="20" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="18,27 18,35 24,31" fill="\${BLUE}"/>
|
||||||
|
<!-- outlet stub -->
|
||||||
|
<rect x="56" y="26" width="22" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="58" y1="31" x2="74" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="72,27 72,35 78,31" fill="\${BLUE}"/>
|
||||||
|
<!-- pump (volute) + impeller hint -->
|
||||||
|
<circle cx="40" cy="31" r="13" fill="#fff" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<path d="M 40 22 Q 48 26 48 31 Q 48 36 40 40 Q 32 36 32 31 Q 32 26 40 22" fill="none" stroke="#86bbdd" stroke-width="1.3"/>
|
||||||
|
<!-- sensor marker AT equipment (top, on the volute itself) -->
|
||||||
|
<line x1="40" y1="6" x2="40" y2="18" stroke="\${RED}" stroke-width="1.8"/>
|
||||||
|
<circle cx="40" cy="6" r="5" fill="#fff" stroke="\${RED}" stroke-width="2"/>
|
||||||
|
<circle cx="40" cy="6" r="1.6" fill="\${RED}"/>
|
||||||
|
</svg>\`,
|
||||||
|
downstream: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<!-- pump (volute) + impeller hint + motor stub -->
|
||||||
|
<circle cx="20" cy="31" r="13" fill="#fff" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<path d="M 20 22 Q 28 26 28 31 Q 28 36 20 40 Q 12 36 12 31 Q 12 26 20 22" fill="none" stroke="#86bbdd" stroke-width="1.3"/>
|
||||||
|
<rect x="15" y="13" width="10" height="6" fill="\${STEEL}" stroke="#333" stroke-width="0.8"/>
|
||||||
|
<!-- discharge pipe + flow arrow -->
|
||||||
|
<rect x="38" y="26" width="40" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="42" y1="31" x2="70" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="68,27 68,35 75,31" fill="\${BLUE}"/>
|
||||||
|
<!-- sensor marker on discharge pipe -->
|
||||||
|
<line x1="60" y1="14" x2="60" y2="26" stroke="\${RED}" stroke-width="1.8"/>
|
||||||
|
<circle cx="60" cy="11" r="5" fill="#fff" stroke="\${RED}" stroke-width="2"/>
|
||||||
|
<circle cx="60" cy="11" r="1.6" fill="\${RED}"/>
|
||||||
|
</svg>\`,
|
||||||
|
// Output-format icons — used by the shared
|
||||||
|
// renderOutputFormatPicker helper so every node renders the
|
||||||
|
// process/json/csv/influxdb dropdowns with the same visuals.
|
||||||
|
outputProcess: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="10" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<rect x="50" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<line x1="30" y1="29" x2="46" y2="29" stroke="\${BLUE}" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M42 24 L48 29 L42 34" fill="none" stroke="\${BLUE}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>\`,
|
||||||
|
outputJson: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<g fill="none" stroke="\${BLUE}" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M30 14 C22 16 22 26 27 29 C22 32 22 42 30 44"/>
|
||||||
|
<path d="M50 14 C58 16 58 26 53 29 C58 32 58 42 50 44"/>
|
||||||
|
</g>
|
||||||
|
<g fill="\${STEEL}">
|
||||||
|
<circle cx="36" cy="29" r="2.2"/>
|
||||||
|
<circle cx="44" cy="29" r="2.2"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
outputCsv: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="12" y="12" width="56" height="34" rx="2" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<line x1="12" y1="22" x2="68" y2="22" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<g stroke="\${STEEL}" stroke-width="1.6">
|
||||||
|
<line x1="12" y1="34" x2="68" y2="34"/>
|
||||||
|
<line x1="31" y1="12" x2="31" y2="46"/>
|
||||||
|
<line x1="49" y1="12" x2="49" y2="46"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
outputInflux: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<ellipse cx="40" cy="15" rx="22" ry="6" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M18 15 V42 C18 46 28 49 40 49 C52 49 62 46 62 42 V15" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M18 28 C26 32 54 32 62 28" fill="none" stroke="\${STEEL}" stroke-width="1.6" opacity="0.6"/>
|
||||||
|
<path d="M22 39 L30 32 L38 41 L46 34 L54 38" fill="none" stroke="\${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>\`,
|
||||||
|
distance: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<g class="evolv-ruler-body">
|
||||||
|
<rect x="12" y="22" width="56" height="14" rx="1.5" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<g stroke="\${STEEL}" stroke-width="1.8" stroke-linecap="round">
|
||||||
|
<line x1="20" y1="22" x2="20" y2="30"/>
|
||||||
|
<line x1="28" y1="22" x2="28" y2="27"/>
|
||||||
|
<line x1="36" y1="22" x2="36" y2="30"/>
|
||||||
|
<line x1="44" y1="22" x2="44" y2="27"/>
|
||||||
|
<line x1="52" y1="22" x2="52" y2="30"/>
|
||||||
|
<line x1="60" y1="22" x2="60" y2="27"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g class="evolv-off-cross" stroke="\${RED}" stroke-width="4.5" stroke-linecap="round">
|
||||||
|
<line x1="16" y1="14" x2="64" y2="46"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Helpers -----------------------------------------------
|
||||||
|
function dispatchChange(el) {
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSelectPicker: replace a native <select> with a row of
|
||||||
|
// icon cards. labels object maps option.value → display string.
|
||||||
|
function renderSelectPicker(select, holder, icons, labels) {
|
||||||
|
if (!select || !holder || holder.dataset.evolvReady === '1') return;
|
||||||
|
holder.dataset.evolvReady = '1';
|
||||||
|
select.classList.add('evolv-native-hidden');
|
||||||
|
|
||||||
|
const options = Array.from(select.options).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
title: option.textContent || option.value,
|
||||||
|
label: (labels && labels[option.value]) || option.textContent || option.value,
|
||||||
|
svg: icons[option.value],
|
||||||
|
})).filter((option) => option.svg);
|
||||||
|
|
||||||
|
holder.innerHTML = options.map((option) => (
|
||||||
|
'<div class="evolv-icon-option" data-value="' + option.value + '" role="radio" tabindex="0"' +
|
||||||
|
' aria-label="' + option.title + '" aria-checked="false" title="' + option.title + '">' +
|
||||||
|
' <div class="evolv-icon-glyph">' + option.svg + '</div>' +
|
||||||
|
' <div class="evolv-icon-label">' + option.label + '</div>' +
|
||||||
|
'</div>'
|
||||||
|
)).join('');
|
||||||
|
|
||||||
|
const buttons = Array.from(holder.querySelectorAll('.evolv-icon-option'));
|
||||||
|
function sync() {
|
||||||
|
const current = select.value || (options[0] && options[0].value) || '';
|
||||||
|
for (const button of buttons) {
|
||||||
|
const on = button.getAttribute('data-value') === current;
|
||||||
|
button.classList.toggle('evolv-icon-option-on', on);
|
||||||
|
button.setAttribute('aria-checked', String(on));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pick(value) {
|
||||||
|
select.value = value;
|
||||||
|
dispatchChange(select);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.addEventListener('click', () => pick(button.getAttribute('data-value')));
|
||||||
|
button.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
pick(button.getAttribute('data-value'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
select.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderToggle: replace a checkbox with a single icon card whose
|
||||||
|
// label flips between {on, off}. Passing a string for label
|
||||||
|
// uses the same string for both states.
|
||||||
|
function renderToggle(checkbox, holder, svg, label) {
|
||||||
|
if (!checkbox || !holder || holder.dataset.evolvReady === '1') return;
|
||||||
|
holder.dataset.evolvReady = '1';
|
||||||
|
checkbox.classList.add('evolv-native-hidden');
|
||||||
|
const labels = typeof label === 'string' ? { on: label, off: label } : label;
|
||||||
|
holder.innerHTML =
|
||||||
|
'<div class="evolv-icon-glyph">' + svg + '</div>' +
|
||||||
|
'<div class="evolv-icon-label">' + labels.off + '</div>';
|
||||||
|
const labelEl = holder.querySelector('.evolv-icon-label');
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const on = checkbox.checked;
|
||||||
|
holder.classList.toggle('evolv-icon-option-on', on);
|
||||||
|
holder.setAttribute('aria-checked', String(on));
|
||||||
|
if (labelEl) labelEl.textContent = on ? labels.on : labels.off;
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
dispatchChange(checkbox);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
holder.addEventListener('click', toggle);
|
||||||
|
holder.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
checkbox.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOutputFormatPicker: shared widget for the process &
|
||||||
|
// dbase output-format <select>s carried by most EVOLV nodes.
|
||||||
|
// Encapsulates the icon set + labels so every node renders the
|
||||||
|
// same visuals. Pass the native <select> and an empty holder
|
||||||
|
// <div class="evolv-icon-picker">.
|
||||||
|
const OUTPUT_FORMAT_ICONS = {
|
||||||
|
process: SVG.outputProcess,
|
||||||
|
json: SVG.outputJson,
|
||||||
|
csv: SVG.outputCsv,
|
||||||
|
influxdb: SVG.outputInflux,
|
||||||
|
};
|
||||||
|
const OUTPUT_FORMAT_LABELS = {
|
||||||
|
process: 'Process',
|
||||||
|
json: 'JSON',
|
||||||
|
csv: 'CSV',
|
||||||
|
influxdb: 'Influx',
|
||||||
|
};
|
||||||
|
function renderOutputFormatPicker(select, holder) {
|
||||||
|
renderSelectPicker(select, holder, OUTPUT_FORMAT_ICONS, OUTPUT_FORMAT_LABELS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upgradeOutputFormatSelects: idempotent platform-wide upgrade.
|
||||||
|
// Scans the open editor dialog for the two canonical output-format
|
||||||
|
// selects and replaces each with the icon picker. Skips selects
|
||||||
|
// that are already upgraded (class evolv-native-hidden) or that
|
||||||
|
// already have a sibling picker placed by the node's HTML.
|
||||||
|
// Called from MenuManager's initEditor wrapper so every node
|
||||||
|
// inherits the picker without per-node template edits.
|
||||||
|
function upgradeOutputFormatSelects() {
|
||||||
|
const specs = [
|
||||||
|
{ id: 'node-input-processOutputFormat', aria: 'Process output format' },
|
||||||
|
{ id: 'node-input-dbaseOutputFormat', aria: 'Database output format' }
|
||||||
|
];
|
||||||
|
specs.forEach((spec) => {
|
||||||
|
const select = document.getElementById(spec.id);
|
||||||
|
if (!select) return;
|
||||||
|
if (select.classList && select.classList.contains('evolv-native-hidden')) return;
|
||||||
|
const parent = select.parentNode;
|
||||||
|
if (!parent) return;
|
||||||
|
// Skip if a sibling picker already exists (manual wiring).
|
||||||
|
const siblings = parent.children || [];
|
||||||
|
for (let i = 0; i < siblings.length; i += 1) {
|
||||||
|
const sib = siblings[i];
|
||||||
|
if (sib !== select && sib.classList && sib.classList.contains('evolv-icon-picker')) return;
|
||||||
|
}
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.className = 'evolv-icon-picker';
|
||||||
|
holder.setAttribute('role', 'radiogroup');
|
||||||
|
holder.setAttribute('aria-label', spec.aria);
|
||||||
|
parent.appendChild(holder);
|
||||||
|
renderOutputFormatPicker(select, holder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { SVG, renderSelectPicker, renderToggle, renderOutputFormatPicker, upgradeOutputFormatSelects };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = IconHelpers;
|
||||||
@@ -3,6 +3,7 @@ const AssetMenu = require('./asset.js');
|
|||||||
const LoggerMenu = require('./logger.js');
|
const LoggerMenu = require('./logger.js');
|
||||||
const PhysicalPositionMenu = require('./physicalPosition.js');
|
const PhysicalPositionMenu = require('./physicalPosition.js');
|
||||||
const AquonSamplesMenu = require('./aquonSamples.js');
|
const AquonSamplesMenu = require('./aquonSamples.js');
|
||||||
|
const IconHelpers = require('./iconHelpers.js');
|
||||||
const ConfigManager = require('../configs');
|
const ConfigManager = require('../configs');
|
||||||
|
|
||||||
class MenuManager {
|
class MenuManager {
|
||||||
@@ -138,6 +139,9 @@ class MenuManager {
|
|||||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||||
|
|
||||||
|
// Shared icon-picker helpers (no-op if already loaded by another node)
|
||||||
|
${IconHelpers.getClientInitCode()}
|
||||||
|
|
||||||
// Initialize menu namespaces
|
// Initialize menu namespaces
|
||||||
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
|
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
|
||||||
|
|
||||||
@@ -163,12 +167,26 @@ class MenuManager {
|
|||||||
try {
|
try {
|
||||||
${menuTypes.map(type => `
|
${menuTypes.map(type => `
|
||||||
try {
|
try {
|
||||||
|
// initEditor is responsible for calling initVisuals
|
||||||
|
// at the right time (after any async data load).
|
||||||
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
|
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
|
||||||
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
|
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
|
||||||
}
|
}
|
||||||
} catch (${type}Error) {
|
} catch (${type}Error) {
|
||||||
console.error('Error initializing ${type} menu for ${nodeName}:', ${type}Error);
|
console.error('Error initializing ${type} menu for ${nodeName}:', ${type}Error);
|
||||||
}`).join('')}
|
}`).join('')}
|
||||||
|
|
||||||
|
// Platform-wide: upgrade output-format <select>s
|
||||||
|
// (process/dbase) to icon pickers. Idempotent — no-op
|
||||||
|
// for nodes whose HTML already wires the picker, and
|
||||||
|
// skips when the selects aren't present.
|
||||||
|
try {
|
||||||
|
if (window.EVOLV && window.EVOLV.iconHelpers && window.EVOLV.iconHelpers.upgradeOutputFormatSelects) {
|
||||||
|
window.EVOLV.iconHelpers.upgradeOutputFormatSelects();
|
||||||
|
}
|
||||||
|
} catch (outputUpgradeError) {
|
||||||
|
console.error('Error upgrading output-format selects for ${nodeName}:', outputUpgradeError);
|
||||||
|
}
|
||||||
} catch (editorError) {
|
} catch (editorError) {
|
||||||
console.error('Error in main editor initialization for ${nodeName}:', editorError);
|
console.error('Error in main editor initialization for ${nodeName}:', editorError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,12 +103,68 @@ getHtmlInjectionCode(nodeName) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Compose everything into one client‐side payload
|
// 5) Client-side: upgrade native controls to icon cards.
|
||||||
|
//
|
||||||
|
// Runs after wireEvents (which has already hooked the checkbox + select).
|
||||||
|
// Adds a small toggle card next to the native checkbox and a 4-icon
|
||||||
|
// picker row next to the native select; the natives are then hidden.
|
||||||
|
getVisualInjectionCode(nodeName) {
|
||||||
|
return `
|
||||||
|
// Logger visual upgrade for ${nodeName}
|
||||||
|
window.EVOLV.nodes.${nodeName}.loggerMenu.initVisuals = function(node) {
|
||||||
|
const helpers = window.EVOLV && window.EVOLV.iconHelpers;
|
||||||
|
if (!helpers) return;
|
||||||
|
|
||||||
|
// --- Log toggle (replaces native checkbox + label) ----------
|
||||||
|
const checkbox = document.getElementById('node-input-enableLog');
|
||||||
|
if (checkbox) {
|
||||||
|
const row = checkbox.closest('.form-row');
|
||||||
|
if (row && !document.getElementById('evolv-log-toggle-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-log-toggle-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-option evolv-log-toggle';
|
||||||
|
holder.setAttribute('role', 'switch');
|
||||||
|
holder.setAttribute('tabindex', '0');
|
||||||
|
holder.setAttribute('aria-label', 'Logging');
|
||||||
|
holder.setAttribute('aria-checked', 'false');
|
||||||
|
holder.setAttribute('title', 'Logging');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderToggle(checkbox, holder, helpers.SVG.logToggle, { on: 'Log', off: 'Off' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Log-level picker (replaces native select) --------------
|
||||||
|
const select = document.getElementById('node-input-logLevel');
|
||||||
|
if (select) {
|
||||||
|
const row = document.getElementById('row-logLevel');
|
||||||
|
if (row && !document.getElementById('evolv-log-level-picker-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-log-level-picker-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-picker';
|
||||||
|
holder.setAttribute('role', 'radiogroup');
|
||||||
|
holder.setAttribute('aria-label', 'Log level');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderSelectPicker(
|
||||||
|
select,
|
||||||
|
holder,
|
||||||
|
{ error: helpers.SVG.error, warn: helpers.SVG.warn, info: helpers.SVG.info, debug: helpers.SVG.debug },
|
||||||
|
{ error: 'Error', warn: 'Warn', info: 'Info', debug: 'Debug' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Compose everything into one client‐side payload
|
||||||
getClientInitCode(nodeName) {
|
getClientInitCode(nodeName) {
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventCode = this.getEventInjectionCode(nodeName);
|
const eventCode = this.getEventInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- LoggerMenu for ${nodeName} ---
|
// --- LoggerMenu for ${nodeName} ---
|
||||||
@@ -119,13 +175,16 @@ getHtmlInjectionCode(nodeName) {
|
|||||||
${dataCode}
|
${dataCode}
|
||||||
${eventCode}
|
${eventCode}
|
||||||
${saveCode}
|
${saveCode}
|
||||||
|
${visualCode}
|
||||||
|
|
||||||
// oneditprepare calls this
|
// oneditprepare calls this. Visual upgrade runs last so the natives
|
||||||
|
// are already populated + wired.
|
||||||
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
|
||||||
// ------------------ BELOW sequence is important! -------------------------------
|
// ------------------ BELOW sequence is important! -------------------------------
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.loadData(node);
|
this.loadData(node);
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
|
if (this.initVisuals) this.initVisuals(node);
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,12 +245,69 @@ getSaveInjectionCode(nodeName) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) Compose everything into one client bundle
|
// 7) Client-side: upgrade native controls to icon cards.
|
||||||
|
//
|
||||||
|
// Runs after wireEvents. Wraps the position <select> with a 3-card row
|
||||||
|
// (upstream / atEquipment / downstream) and the hasDistance checkbox
|
||||||
|
// with a single toggle card. The native controls are hidden but stay
|
||||||
|
// in the DOM as save targets.
|
||||||
|
getVisualInjectionCode(nodeName) {
|
||||||
|
return `
|
||||||
|
// PhysicalPosition visual upgrade for ${nodeName}
|
||||||
|
window.EVOLV.nodes.${nodeName}.positionMenu.initVisuals = function(node) {
|
||||||
|
const helpers = window.EVOLV && window.EVOLV.iconHelpers;
|
||||||
|
if (!helpers) return;
|
||||||
|
|
||||||
|
// --- Position picker (replaces native <select>) -------------
|
||||||
|
const select = document.getElementById('node-input-positionVsParent');
|
||||||
|
if (select) {
|
||||||
|
const row = select.closest('.form-row');
|
||||||
|
if (row && !document.getElementById('evolv-position-picker-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-position-picker-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-picker';
|
||||||
|
holder.setAttribute('role', 'radiogroup');
|
||||||
|
holder.setAttribute('aria-label', 'Physical position vs parent');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderSelectPicker(
|
||||||
|
select,
|
||||||
|
holder,
|
||||||
|
{ upstream: helpers.SVG.upstream, atEquipment: helpers.SVG.atEquipment, downstream: helpers.SVG.downstream },
|
||||||
|
{ upstream: 'Upstream', atEquipment: 'At', downstream: 'Downstream' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Distance toggle (replaces native checkbox) -------------
|
||||||
|
const checkbox = document.getElementById('node-input-hasDistance');
|
||||||
|
if (checkbox) {
|
||||||
|
const row = checkbox.closest('.form-row');
|
||||||
|
if (row && !document.getElementById('evolv-distance-toggle-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-distance-toggle-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-option evolv-distance-toggle';
|
||||||
|
holder.setAttribute('role', 'switch');
|
||||||
|
holder.setAttribute('tabindex', '0');
|
||||||
|
holder.setAttribute('aria-label', 'Distance');
|
||||||
|
holder.setAttribute('aria-checked', 'false');
|
||||||
|
holder.setAttribute('title', 'Distance');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderToggle(checkbox, holder, helpers.SVG.distance, { on: 'Distance', off: 'Off' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) Compose everything into one client bundle
|
||||||
getClientInitCode(nodeName) {
|
getClientInitCode(nodeName) {
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventCode = this.getEventInjectionCode(nodeName);
|
const eventCode = this.getEventInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- PhysicalPositionMenu for ${nodeName} ---
|
// --- PhysicalPositionMenu for ${nodeName} ---
|
||||||
@@ -261,12 +318,15 @@ getSaveInjectionCode(nodeName) {
|
|||||||
${dataCode}
|
${dataCode}
|
||||||
${eventCode}
|
${eventCode}
|
||||||
${saveCode}
|
${saveCode}
|
||||||
|
${visualCode}
|
||||||
|
|
||||||
// hook into oneditprepare
|
// hook into oneditprepare. Visual upgrade runs last so the natives
|
||||||
|
// are already populated + wired.
|
||||||
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.loadData(node);
|
this.loadData(node);
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
|
if (this.initVisuals) this.initVisuals(node);
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -71,14 +71,22 @@ class Predict {
|
|||||||
// Capture share-source BEFORE config validation strips it (ConfigUtils
|
// Capture share-source BEFORE config validation strips it (ConfigUtils
|
||||||
// mutates the input config to drop unknown keys, which would remove
|
// mutates the input config to drop unknown keys, which would remove
|
||||||
// shareInputsFrom because it's not in predictConfig.json's schema).
|
// shareInputsFrom because it's not in predictConfig.json's schema).
|
||||||
|
// Detach on a shallow clone so validateSchema doesn't see the key at all
|
||||||
|
// — leaving it on the input would emit a `[interpolation] Unknown key
|
||||||
|
// 'shareInputsFrom'` warning per group-predictor on every curve refresh.
|
||||||
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
|
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
|
||||||
? config.shareInputsFrom
|
? config.shareInputsFrom
|
||||||
: null;
|
: null;
|
||||||
|
let _initConfig = config;
|
||||||
|
if (_initConfig && 'shareInputsFrom' in _initConfig) {
|
||||||
|
_initConfig = { ..._initConfig };
|
||||||
|
delete _initConfig.shareInputsFrom;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize dependencies
|
// Initialize dependencies
|
||||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
this.emitter = new EventEmitter(); // Own EventEmitter
|
||||||
this.configUtils = new ConfigUtils(defaultConfig);
|
this.configUtils = new ConfigUtils(defaultConfig);
|
||||||
this.config = this.configUtils.initConfig(config);
|
this.config = this.configUtils.initConfig(_initConfig);
|
||||||
|
|
||||||
// Init after config is set
|
// Init after config is set
|
||||||
this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
|
this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
|
||||||
|
|||||||
@@ -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 startTime = Date.now();
|
||||||
const intervalSec = intervalMs / 1000;
|
|
||||||
const stepSize = direction * velocity * intervalSec;
|
|
||||||
|
|
||||||
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"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ class state{
|
|||||||
|
|
||||||
this.delayedMove = null;
|
this.delayedMove = null;
|
||||||
this.mode = this.config.mode.current;
|
this.mode = this.config.mode.current;
|
||||||
|
// Monotonic counter incremented on every EXTERNAL abort (i.e. one
|
||||||
|
// initiated outside the in-flight sequence — typically MGC reacting
|
||||||
|
// to a new demand). executeSequence captures the value at entry and
|
||||||
|
// breaks its for-loop if the counter advances mid-sequence, so a
|
||||||
|
// shutdown that was already past its ramp-down step doesn't barge
|
||||||
|
// through stopping → coolingdown when a re-engage arrives.
|
||||||
|
this.sequenceAbortToken = 0;
|
||||||
|
|
||||||
// Log initialization
|
// Log initialization
|
||||||
this.logger.info("State class initialized.");
|
this.logger.info("State class initialized.");
|
||||||
@@ -151,6 +158,14 @@ class state{
|
|||||||
if (this.abortController && !this.abortController.signal.aborted) {
|
if (this.abortController && !this.abortController.signal.aborted) {
|
||||||
this.logger.warn(`Aborting movement: ${reason}`);
|
this.logger.warn(`Aborting movement: ${reason}`);
|
||||||
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
||||||
|
// Only external aborts (returnToOperational=false) advance the
|
||||||
|
// sequence-abort token. Sequence-internal aborts (e.g. shutdown's
|
||||||
|
// own setpoint(0) being pre-empted by a fresher shutdown/estop)
|
||||||
|
// come from inside executeSequence and must not terminate their
|
||||||
|
// own loop.
|
||||||
|
if (!options.returnToOperational) {
|
||||||
|
this.sequenceAbortToken += 1;
|
||||||
|
}
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
class stateManager {
|
class stateManager {
|
||||||
constructor(config, logger) {
|
constructor(config, logger) {
|
||||||
this.currentState = config.state.current;
|
this.currentState = config.state.current;
|
||||||
|
// Wall-clock entry timestamp into currentState. Used by
|
||||||
|
// getRemainingTransitionS() so callers (e.g. MGC movement planner)
|
||||||
|
// can compute exact remaining time for timed states without
|
||||||
|
// approximating from the full configured duration.
|
||||||
|
this.stateEnteredAt = Date.now();
|
||||||
this.availableStates = config.state.available;
|
this.availableStates = config.state.available;
|
||||||
this.descriptions = config.state.descriptions;
|
this.descriptions = config.state.descriptions;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
@@ -63,7 +68,18 @@ class stateManager {
|
|||||||
getCurrentState() {
|
getCurrentState() {
|
||||||
return this.currentState;
|
return this.currentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seconds remaining in the current timed state (warmingup, coolingdown,
|
||||||
|
// starting, stopping, …). Returns 0 for untimed states or once the
|
||||||
|
// configured duration has elapsed. The MGC movement planner uses this to
|
||||||
|
// compute exact rendezvous time for protected (non-interruptible) states.
|
||||||
|
getRemainingTransitionS() {
|
||||||
|
const d = this.transitionTimes?.[this.currentState] || 0;
|
||||||
|
if (d <= 0) return 0;
|
||||||
|
const elapsed = (Date.now() - this.stateEnteredAt) / 1000;
|
||||||
|
return Math.max(0, d - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
transitionTo(newState,signal) {
|
transitionTo(newState,signal) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal && signal.aborted) {
|
if (signal && signal.aborted) {
|
||||||
@@ -89,6 +105,7 @@ class stateManager {
|
|||||||
if (transitionDuration > 0) {
|
if (transitionDuration > 0) {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
this.currentState = newState;
|
this.currentState = newState;
|
||||||
|
this.stateEnteredAt = Date.now();
|
||||||
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
|
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
|
||||||
}, transitionDuration * 1000);
|
}, transitionDuration * 1000);
|
||||||
if (signal) {
|
if (signal) {
|
||||||
@@ -99,6 +116,7 @@ class stateManager {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.currentState = newState;
|
this.currentState = newState;
|
||||||
|
this.stateEnteredAt = Date.now();
|
||||||
resolve(`Immediate transition to ${this.currentState} completed.`);
|
resolve(`Immediate transition to ${this.currentState} completed.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
77
test/basic/stateManagerRemaining.basic.test.js
Normal file
77
test/basic/stateManagerRemaining.basic.test.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const StateManager = require('../../src/state/stateManager');
|
||||||
|
|
||||||
|
// Minimal config that satisfies the stateManager constructor's expectations.
|
||||||
|
// Real configs come from configs/<node>.json; we hand-roll one here so the
|
||||||
|
// test doesn't drag the whole node-config plumbing in for a 30-line getter.
|
||||||
|
function makeConfig(initial = 'idle', times = { idle: 0, warmingup: 5 }) {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
current: initial,
|
||||||
|
available: ['idle', 'warmingup', 'operational'],
|
||||||
|
descriptions: { idle: 'off', warmingup: 'warming', operational: 'running' },
|
||||||
|
allowedTransitions: {
|
||||||
|
idle: new Set(['warmingup']),
|
||||||
|
warmingup: new Set(['operational']),
|
||||||
|
operational: new Set(['idle']),
|
||||||
|
},
|
||||||
|
activeStates: new Set(['operational']),
|
||||||
|
},
|
||||||
|
time: times,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||||
|
|
||||||
|
test('getRemainingTransitionS returns 0 for untimed initial state', () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle'), noopLogger);
|
||||||
|
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRemainingTransitionS returns ≈full duration just after entering a timed state', async () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||||
|
// Force-enter "warmingup" via the constructor's state machinery: simulate
|
||||||
|
// by manually setting fields the way transitionTo would.
|
||||||
|
sm.currentState = 'warmingup';
|
||||||
|
sm.stateEnteredAt = Date.now();
|
||||||
|
const remaining = sm.getRemainingTransitionS();
|
||||||
|
assert.ok(remaining > 4.9 && remaining <= 5.0, `expected ~5s, got ${remaining}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRemainingTransitionS decays with elapsed time', async () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||||
|
sm.currentState = 'warmingup';
|
||||||
|
sm.stateEnteredAt = Date.now() - 2000; // pretend we entered 2s ago
|
||||||
|
const remaining = sm.getRemainingTransitionS();
|
||||||
|
assert.ok(remaining > 2.9 && remaining <= 3.0, `expected ~3s, got ${remaining}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRemainingTransitionS clamps to 0 once duration has elapsed', () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||||
|
sm.currentState = 'warmingup';
|
||||||
|
sm.stateEnteredAt = Date.now() - 60_000; // a minute ago, way past 5s
|
||||||
|
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transitionTo refreshes stateEnteredAt on the immediate branch', async () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0 }), noopLogger);
|
||||||
|
const before = sm.stateEnteredAt;
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
await sm.transitionTo('warmingup');
|
||||||
|
assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance on transition');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transitionTo refreshes stateEnteredAt on the timed branch', async () => {
|
||||||
|
// Tiny duration so the test stays fast.
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0.05, warmingup: 0 }), noopLogger);
|
||||||
|
const before = sm.stateEnteredAt;
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
await sm.transitionTo('warmingup');
|
||||||
|
assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance after timed transition');
|
||||||
|
// And remaining should now be 0 (we're in warmingup, but warmingup duration is 0).
|
||||||
|
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||||
|
});
|
||||||
78
test/movement-manager.test.js
Normal file
78
test/movement-manager.test.js
Normal 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}`);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
436
wiki/Home.md
436
wiki/Home.md
@@ -1,33 +1,41 @@
|
|||||||
# generalFunctions
|
# generalFunctions
|
||||||
|
|
||||||
> **Reflects code as of `f21e2aa` · regenerated `2026-05-11` (hand-written)**
|
  
|
||||||
> 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 — 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 — 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 — `const { BaseDomain, UnitPolicy } = require('generalFunctions');` |
|
||||||
|
| Side effects on a flow | None — the library has no editor form, no node registration |
|
||||||
|
| Cross-node coupling | Through this library's API surface + Node-RED messages only — 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 — 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 — Contracts](Reference-Contracts).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Module map
|
## Module map — 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,298 +174,93 @@ 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 |
|
||||||
| `src/outliers/` | `DynamicClusterDeviation` | Online outlier detection |
|
| `src/outliers/` | `DynamicClusterDeviation` | Online outlier detection |
|
||||||
| `src/state/` | `state`, `StateManager`, `MovementManager` | FSM for valve/machine state machines |
|
| `src/state/` | `state`, `StateManager`, `MovementManager` | FSM for valve / machine state machines |
|
||||||
| `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 — 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 — 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: 0–3, 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 — see [Reference — 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 ↔ output ↔ 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 — Contracts](Reference-Contracts) | Full public API surface table — one row per export, with source file, stability tag, and signature |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Three-tier rules, `src/` directory tree, how 12 nodes consume the library, additive-only export discipline |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Usage patterns: extending `BaseDomain` and `BaseNodeAdapter`, registering commands, declaring child routes, `MeasurementContainer` chaining |
|
||||||
|
| [Reference — 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) · [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) · [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 1–12 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 |
|
|
||||||
|
|||||||
286
wiki/Reference-Architecture.md
Normal file
286
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# Reference — Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!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 — 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 → domain instantiation → registration delay → output strategy → status loop → input dispatch → 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()` — `dashboardAPI` has the lightest (HTTP gateway, no FSM); `rotatingMachine` and `machineGroupControl` have the densest (full curve loading, drift assessor, multi-source pressure routing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lifecycle — 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;` — 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 — no registration step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stability — 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–§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) — never in core logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new export — 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 — 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 — Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Usage patterns from real consumer nodes |
|
||||||
|
| [Reference — 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 — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||||
180
wiki/Reference-Contracts.md
Normal file
180
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Reference — Contracts
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The full public API surface — 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 → 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](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)` — non-blocking. `fireAndWait(value) → Promise` that resolves with dispatch result or `LatestWinsGate.SUPERSEDED`. `drain()` — 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` — 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 — e.g. `atequipment`, not `atEquipment`. |
|
||||||
|
| `childId` | `default`, `<child.general.id>`, `dashboard-sim-upstream`, … | `default` for the node's own predictions; otherwise the registering child's id. |
|
||||||
|
|
||||||
|
Changing this shape is a forbidden breaking change — see [Reference — 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` — 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)` — 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) — never in core logic.
|
||||||
|
|
||||||
|
| Quantity | Canonical (internal) | Typical output | Typical curve |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| Pressure | `Pa` | `mbar` | `mbar` |
|
||||||
|
| Atmospheric pressure | `Pa` | `Pa` | — |
|
||||||
|
| Flow | `m3/s` | `m3/h` | `m3/h` |
|
||||||
|
| Power | `W` | `kW` | `kW` |
|
||||||
|
| Temperature | `K` | `°C` | — |
|
||||||
|
| Control | — | — | `%` |
|
||||||
|
|
||||||
|
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 — 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 — the dance
|
||||||
|
|
||||||
|
See [Reference — 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 — Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Usage patterns: extending base classes, registering commands, declaring child routes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known issues, deprecations, stability rules |
|
||||||
|
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative 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
361
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
# Reference — Examples
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
> [!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 — 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` — 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 — 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` — 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()` — 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 — each consumer (`rotatingMachine`, `valveGroupControl`, …) 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 — 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 — 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–§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) — 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` → `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 — Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
|
||||||
|
| [Reference — 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
22
wiki/_Sidebar.md
Normal 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)
|
||||||
Reference in New Issue
Block a user