18 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Schema fixes only — no runtime behaviour changes.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:01:02 +02:00
znetsixe
8b28f8969e docs(wiki): full 5-page wiki matching the rotatingMachine reference format
Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:42:15 +02:00
znetsixe
48fa54363d docs: drop substrate_score reference from wiki Home (repo-mem MCP retired)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:30:26 +02:00
znetsixe
ab481357d2 feat(menu): globalize output-format picker + tighter asset wizard
iconHelpers.js:
* Add SVG.outputProcess/outputJson/outputCsv/outputInflux icons.
* Add renderOutputFormatPicker(select, holder) shared helper.
* Add idempotent upgradeOutputFormatSelects() that scans for
  node-input-processOutputFormat / dbaseOutputFormat selects and replaces
  them with icon pickers — no per-node HTML edits required.
* Redesign SVG.upstream/atEquipment/downstream to a pump volute + sensor
  marker matching the rotatingMachine pump banner style.

menu/index.js:
* Auto-invoke upgradeOutputFormatSelects from MenuManager's initEditor
  wrapper so every node inherits the picker.

asset.js:
* Tighter chip styling: 1px border, 4px radius, 3x8 padding, no uppercase
  transform — single-line "Label: Value" instead of stacked card.
* Narrower Asset Tag input (max 200px); wizard max-width 460px.
* Restore curve preview to 220x110 (was over-compacted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:54 +02:00
Rene De Ren
49c77f262f docs: add CONTRACT.md — library API contract for generalFunctions
Different shape from per-node CONTRACT.md files: nodes contract on msg.topic,
this library contracts on what require('generalFunctions') exports. Every
export tagged stable/experimental/deprecated, grouped by concern, with
pointers to .claude/refactor/CONTRACTS.md §N in the EVOLV superproject for
the deep platform-shape specs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:48:05 +02:00
znetsixe
34a4ef0610 feat(menu): global icon-picker visual layer + asset wizard
* iconHelpers.js (new): shared SVG library + renderSelectPicker /
  renderToggle helpers, injected once per editor session by MenuManager.
  Pulls the visual layer out of machineGroupControl so every node that
  loads /<node>/menu.js inherits the cards without per-node code.
* logger.js, physicalPosition.js: new initVisuals() step that upgrades
  the native checkbox + select to icon cards using the shared helpers.
  Native controls stay in the DOM (hidden) as the save targets.
* asset.js: rewrite the asset selector into a left->right wizard —
  chip strip (Supplier > Type > Model > Unit), per-stage type-to-filter
  combobox, node-aware spec strip + curve mini-chart sparkline. Models
  are server-side enriched with a slim previewCurve per softwareType
  (rotatingMachine Q-H, valve Cv, diffuser SOTE; measurement has no
  curve data yet). Hidden native selects remain canonical save targets.
* MenuManager: each menu's initEditor now owns its own initVisuals
  call so async-data menus (asset) can sequence visuals after loadData.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:10:28 +02:00
znetsixe
af02d36b07 feat(mgc-config + state): planner.useRendezvous schema + remaining-transition reads
Three coherent additions that the MGC rendezvous planner depends on:

- machineGroupControl.json: new `planner.useRendezvous` boolean (default
  true). Used by both `_optimalControl` and `equalFlowControl` (via the
  shared `_dispatchFlowDistribution` helper) to gate same-time-landing.

- state.js: external aborts (returnToOperational=false) bump a monotonic
  `sequenceAbortToken`. executeSequence captures it at entry and bails
  out of its for-loop if it advances mid-sequence, so a shutdown that's
  past its ramp-down step doesn't barge through stopping → coolingdown
  when a fresher demand re-engages the pump.

- stateManager.js: new `getRemainingTransitionS()` returns the seconds
  remaining in a timed state by reading the wall-clock entry timestamp.
  buildProfile() reads it so the planner can compute exact eta for a
  child that's currently mid-ladder (warmingup / starting / cooling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:21 +02:00
znetsixe
f8f71a4f1c schema + asset menu fixes
- configs/machineGroupControl.json: drop prioritypercentagecontrol mode
  (unused — set.demand became unit-self-describing, so percentage-vs-absolute
  is decided per-message, not by a node-wide scaling mode). Add output.process
  / output.dbase enums + functionality.distance{,Unit,Description} so the
  editor's distance offset persists. Fixes the runtime warnings 'Unknown key
  optimization/scaling/movement/curvePressureUnit etc.' the validator was
  logging on every MGC instantiation.
- configs/measurement.json: same output.process/dbase block + nullable
  position.x for the rare case a measurement has no parent yet.
- datasets/assetData/machine.json -> rotatingmachine.json: rename so
  AssetMenu's softwareType lookup matches. AssetMenu.getActiveCategoryKey
  no longer silently falls back to keys[0] (which mis-showed diffuser models
  for rotatingMachine nodes) — returns null with a console.warn instead.
- menu/asset.js: re-derive supplier/assetType from saved model id on reopen.
  The save handler intentionally discards the denormalized registry copies
  to keep the persisted node small, so the cascade dropdown booted at
  'Select...' even when a model was saved. Walk the registry tree to
  reconstitute.
- predict/predict_class.js: minor.
- configs/index.js: minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:51:57 +02:00
znetsixe
c59da5ca98 refactor(curves): canonical axis Nm³/(h·m² membrane) for all diffuser suppliers
Previously each curve file stored x in whatever convention the vendor
used (per-element Nm³/h for Jäger/GVA, per-element Sm³/h for PIK/PRK,
per-m² for Aerostrip). That meant the diffuser physics couldn't read
the data uniformly — selecting Aerostrip vs Jäger would feed wildly
different axes to the same interpolator.

All five curves now use the same canonical X: specific air flux in
Nm³/(h·m² membrane). Y stays SSOTR in g O₂/(Nm³·m); coverage % is the
parametric key.

Per-file conversions:
- jaeger-jetflex-td-65-2-g-epdm-1000: x divided by 0.18 m² perforated
  area (stated on the data sheet).
- gva-elastox-r: x divided by 0.18 m² placeholder mirroring Jäger
  TD-65 (no real GVA sheet exists — see _meta note).
- aerostrip-phoenix: native already in Nm³/(h·m²) — no x change. _meta
  area normalised to 1.0 m² per "element" so users set `elements` =
  total installed membrane area in m².
- pik300, prk300 (Sulzer ABS 300 mm disc): native was Sm³/h/disc on
  X and g O₂/(Sm³·m) on Y. Converted to canonical Nm³ basis (DIN-1343)
  by X × 0.9319 / 0.07, Y × 1.0732. Each disc = 0.07 m² membrane.

Supplier naming fixed: pikprk → sulzer, aquaconsult → aquaconsult-entec.
PIK = perforated EPDM, PRK = perforated PUR.

Config schema: new diffuser.membraneAreaPerElement field (nullable,
default null) so a node can override the curve's stored area. When
null, specificClass reads _meta.membraneArea_m2_per_element from the
resolved curve.

246/246 generalFunctions tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:16:34 +02:00
znetsixe
0a4b52f517 feat(registry): AssetResolver + diffuser supplier curves (Jäger / Aerostrip / PIK / PRK)
Two related changes bundled together because the diffuser curve files
only make sense once the registry namespace they live in exists.

src/registry — new asset-metadata resolver:
- AssetResolver with synchronous resolve(namespace, id) + lazy cache,
  async refresh() for future remote pulls.
- FileBackend (per-id or single-file layouts, case-insensitive) and a
  stub HttpBackend (disabled unless EVOLV_ASSET_REMOTE=1).
- Namespaces: curves, menu, monsterSamples, monsterSpecs, units. Menu
  namespace re-keys by inner softwareType + filename so editors that
  pass either string resolve to the same tree.
- README explains how to add a namespace.
- AssetCategoryManager (datasets/assetData/index.js) becomes a thin
  facade over the resolver so existing consumers don't move.
- 246/246 tests pass — including the 39-test registry suite.

datasets/assetData — file moves + new diffuser data:
- modelData/*.json deleted; curves/*.json is the canonical home.
- New diffuser.json menu tree with GVA, Jäger, Aquaconsult/Entec,
  PIK/PRK suppliers.
- gva-elastox-r.json migrated from the inline _loadSpecs hardcode,
  re-tagged coverageBasis="bottom-coverage-pct" (the legacy 2.4
  elements/m² was a prior mis-conversion; we can't recover the
  original % so it's a single-point curve under key "0").
- jaeger-jetflex-td-65-2-g-epdm-1000.json — extracted from the Jäger
  EPDM-1000mm SSOTE/DWP chart on the data sheet (vector-PDF read).
  SSOTE 8.20→6.40 %/m, DWP 25→48 mbar across Q 2-12 Nm³/h. Single
  coverage (vendor doesn't state test conditions).
- aerostrip-phoenix.json — 4-coverage SOTE family at 4.75 m water
  depth (DD 5/10/15/20 %, flux 10-70 Nm³/h·m²) from the Entec/de
  Winter 2023-11-22 dataset; DWP curve from the 21 % @ 4.05 m chart.
- pik300.json / prk300.json — 5-coverage SOTE + SSOTR (DD 5-25 %)
  with split DWP per model variant, water depth ≈ 4.0 m inferred from
  the SOTE↔SSOTR ratio in the source spreadsheet.

src/configs/diffuser.json:
- New asset.{model, assetTagNumber} block so the editor's selected
  model id survives validation.
- diffuser.density description corrected to "Bottom coverage [%]";
  default 2.4 → 15 (typical fine-bubble install).

src/configs/{rotatingMachine,valve}.json: small alignment edits that
came with the registry phase.

src/menu/asset.js + src/menu/aquonSamples.js: rewritten as facades
over assetResolver, keeping the editor-side cascade behaviour intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:12:13 +02:00
znetsixe
84a4430266 fix(configManager): deep-merge domainConfig so general.id and asset.model survive
buildConfig() was doing Object.assign(config, domainConfig) — a shallow
merge. When buildDomainConfig returned subsets like {general: {unit}} or
{asset: {curveUnits}}, the assign replaced config.general / config.asset
wholesale, wiping general.id (nodeId), general.name, asset.model, and
asset.supplier that buildConfig had just populated.

Downstream consequences:
- MGC.onRegister('machine') keys by config.general.id; two children with
  the same null id collide and the second registration is rejected.
- rotatingMachine curve-lookup uses asset.model; with model "Unknown" the
  pump curve fails to resolve.

Replace Object.assign with an in-place recursive _deepMerge: arrays and
primitives in src replace dst, plain objects merge key-by-key so siblings
on dst survive.

Two regression tests added — single-config field survival and the MGC
multi-child id-collision case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:06:43 +02:00
znetsixe
1b6b43349f configs(pumpingStation): realistic basin defaults + ramp-foot clarification
Schema defaults now match a realistic 50 m³ × 4 m basin (volume=50,
height=4, inflowLevel=1.5, overflowLevel=3.8, maxLevel=3.8, minLevel=0.3).
stopLevel default stays null so the hysteresis remains opt-in for
programmatic configs (per levelBased.js:111-113); the editor HTML supplies
0.5 m as the drag-in default.

startLevel description corrected: the ramp foot is inflowLevel, not
startLevel — demand stays at 0 % across [startLevel, inflowLevel] and
scales 0 → 100 % across [inflowLevel, maxLevel]. The previous text claimed
[startLevel, maxLevel], contradicting the runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:51:10 +02:00
67 changed files with 4295 additions and 3022 deletions

9
.gitignore vendored
View File

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

28
.npmignore Normal file
View File

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

116
CONTRACT.md Normal file
View 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.*

View File

@@ -0,0 +1,46 @@
{
"_meta": {
"supplier": "Aquaconsult Anlagenbau / Entec",
"type": "Strip",
"model": "AEROSTRIP",
"membrane": "PHOENIX",
"membraneArea_m2_per_element": 1.0,
"membraneArea_m2_per_element_note": "Aerostrip strips are sized continuously rather than as discrete fixed-area elements. Setting per-element area to 1.0 m² is a normalisation choice: configure the diffuser node with `elements` equal to the total installed strip membrane area in m².",
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
"coverageBasis": "bottom-coverage-pct",
"coverageReference": [5, 10, 15, 20],
"dataQuality": "multi-coverage",
"xAxisBasis": "per-m2-membrane-Nm3h",
"yAxisBasis": "ssotr-g-per-Nm3-per-m",
"waterDepth_m": 4.75,
"sources": [
"Floris de Winter (Entec Holland) email to R. de Ren on 2023-11-22 — tabulated SOTE [%] at 4.75 m water depth for bottom coverage 5/10/15/20 % at fluxes 10/25/40/55/70 Nm³/(h*m² membrane). Original chart in 'SSOTE_4.75m different density.pdf'.",
"'SSOTR_dP.pdf' — AEROSTRIP fine-bubble diffuser SSOTR + Druckverlust (DWP) chart at water depth 4.05 m, blow-in depth 4.00 m, 21 % bottom coverage. Used for the DWP curve only (read off the vector chart)."
],
"note": "SSOTR values are SOTE [%] / water_depth_m × 0.299 kg-O₂/Nm³ × 10 (linear depth scaling). DWP curve was measured at 21 % bottom coverage; pressure loss is intrinsic to the diffuser geometry so the curve is shared across coverage values (single 'p_curve' entry under key '0')."
},
"sote_curve": {
"5": { "x": [10, 25, 40, 55, 70], "y": [34.20, 28.75, 26.16, 24.89, 24.19] },
"10": { "x": [10, 25, 40, 55, 70], "y": [42.01, 35.32, 32.14, 30.58, 29.71] },
"15": { "x": [10, 25, 40, 55, 70], "y": [43.39, 36.48, 33.20, 31.59, 30.69] },
"20": { "x": [10, 25, 40, 55, 70], "y": [43.80, 36.82, 33.51, 31.88, 30.97] }
},
"ssote_curve": {
"5": { "x": [10, 25, 40, 55, 70], "y": [7.20, 6.05, 5.51, 5.24, 5.09] },
"10": { "x": [10, 25, 40, 55, 70], "y": [8.84, 7.44, 6.77, 6.44, 6.26] },
"15": { "x": [10, 25, 40, 55, 70], "y": [9.14, 7.68, 6.99, 6.65, 6.46] },
"20": { "x": [10, 25, 40, 55, 70], "y": [9.22, 7.75, 7.06, 6.71, 6.52] }
},
"otr_curve": {
"5": { "x": [10, 25, 40, 55, 70], "y": [21.53, 18.10, 16.47, 15.67, 15.23] },
"10": { "x": [10, 25, 40, 55, 70], "y": [26.44, 22.23, 20.23, 19.25, 18.70] },
"15": { "x": [10, 25, 40, 55, 70], "y": [27.31, 22.96, 20.90, 19.89, 19.32] },
"20": { "x": [10, 25, 40, 55, 70], "y": [27.57, 23.18, 21.10, 20.06, 19.49] }
},
"p_curve": {
"0": {
"x": [5, 10, 25, 40, 55, 70, 80],
"y": [46.0, 47.3, 51.1, 54.9, 58.7, 62.4, 65.0]
}
}
}

View File

@@ -0,0 +1,29 @@
{
"_meta": {
"supplier": "GVA",
"type": "Tube",
"model": "ELASTOX-R",
"membraneArea_m2_per_element": 0.18,
"membraneArea_m2_per_element_source": "placeholder — mirror of Jäger JetFlex TD-65-2-G EPDM 1000 mm (0.18 m²) until a real GVA ELASTOX-R sheet is supplied. Change here when the real value is known; specificClass reads it from this _meta field.",
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
"coverageBasis": "bottom-coverage-pct",
"coverageReference": null,
"dataQuality": "point",
"xAxisBasis": "per-m2-membrane-Nm3h",
"yAxisBasis": "ssotr-g-per-Nm3-per-m",
"waterDepth_m": null,
"note": "Migrated 2026-05-12 from nodes/diffuser/src/specificClass.js _loadSpecs(); the legacy '2.4 elements/m²' tag was a prior mis-conversion of the % bottom-coverage convention. Single-coverage point estimate (key '0' = unspecified). Native data was per-element Nm³/h; converted to per-m²-membrane Nm³/(h·m²) by dividing by the placeholder 0.18 m² element area — those numbers will shift the moment we get a real GVA sheet."
},
"otr_curve": {
"0": {
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56],
"y": [26, 25, 24, 23.5, 23, 22.75, 22.5, 22.25, 22]
}
},
"p_curve": {
"0": {
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
"y": [40, 42.5, 45, 47.5, 50, 51.5, 53, 54.5, 56, 57.5, 59]
}
}
}

View File

@@ -1,148 +0,0 @@
const fs = require('fs');
const path = require('path');
class AssetLoader {
constructor(maxCacheSize = 100) {
this.relPath = './'
this.baseDir = path.resolve(__dirname, this.relPath);
this.cache = new Map();
this.maxCacheSize = maxCacheSize;
}
/**
* Load a specific curve by type
* @param {string} curveType - The curve identifier (e.g., 'hidrostal-H05K-S03R')
* @returns {Object|null} The curve data object or null if not found
*/
loadCurve(curveType) {
return this.loadAsset('curves', curveType);
}
/**
* Load any asset from a specific dataset folder
* @param {string} datasetType - The dataset folder name (e.g., 'curves', 'assetData')
* @param {string} assetId - The specific asset identifier
* @returns {Object|null} The asset data object or null if not found
*/
loadAsset(datasetType, assetId) {
//const cacheKey = `${datasetType}/${assetId}`;
const normalizedAssetId = String(assetId || '').trim();
if (!normalizedAssetId) {
return null;
}
const cacheKey = normalizedAssetId.toLowerCase();
// Check cache first
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
try {
const filePath = this._resolveAssetPath(normalizedAssetId);
// Check if file exists
if (!filePath || !fs.existsSync(filePath)) {
console.warn(`Asset not found for id '${normalizedAssetId}' in ${this.baseDir}`);
return null;
}
// Load and parse JSON
const rawData = fs.readFileSync(filePath, 'utf8');
const assetData = JSON.parse(rawData);
// Cache the result (evict oldest if at capacity)
if (this.cache.size >= this.maxCacheSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(cacheKey, assetData);
return assetData;
} catch (error) {
console.error(`Error loading asset ${cacheKey}:`, error.message);
return null;
}
}
_resolveAssetPath(assetId) {
const exactPath = path.join(this.baseDir, `${assetId}.json`);
if (fs.existsSync(exactPath)) {
return exactPath;
}
const target = `${assetId}.json`.toLowerCase();
const files = fs.readdirSync(this.baseDir);
const matched = files.find((file) => file.toLowerCase() === target);
if (!matched) {
return null;
}
return path.join(this.baseDir, matched);
}
/**
* Get all available assets in a dataset
* @param {string} datasetType - The dataset folder name
* @returns {string[]} Array of available asset IDs
*/
getAvailableAssets(datasetType) {
try {
const datasetPath = path.join(this.baseDir, datasetType);
if (!fs.existsSync(datasetPath)) {
return [];
}
return fs.readdirSync(datasetPath)
.filter(file => file.endsWith('.json'))
.map(file => file.replace('.json', ''));
} catch (error) {
console.error(`Error reading dataset ${datasetType}:`, error.message);
return [];
}
}
/**
* Clear the cache (useful for development/testing)
*/
clearCache() {
this.cache.clear();
}
}
// Create and export a singleton instance
const assetLoader = new AssetLoader();
module.exports = {
AssetLoader,
assetLoader,
// Convenience methods for backward compatibility
loadCurve: (curveType) => assetLoader.loadCurve(curveType),
loadAsset: (datasetType, assetId) => assetLoader.loadAsset(datasetType, assetId),
getAvailableAssets: (datasetType) => assetLoader.getAvailableAssets(datasetType)
};
/*
// Example usage in your scripts
const loader = new AssetLoader();
// Load a specific curve
const curve = loader.loadCurve('hidrostal-H05K-S03R');
if (curve) {
console.log('Curve loaded:', curve);
} else {
console.log('Curve not found');
}
/*
// Load any asset from any dataset
const someAsset = loadAsset('assetData', 'some-asset-id');
// Get list of available curves
const availableCurves = getAvailableAssets('curves');
console.log('Available curves:', availableCurves);
// Using the class directly for more control
const { AssetLoader } = require('./index.js');
const customLoader = new AssetLoader();
const data = customLoader.loadCurve('hidrostal-H05K-S03R');
*/

View File

@@ -0,0 +1,44 @@
{
"_meta": {
"supplier": "Jäger Umwelt-Technik",
"type": "Tube",
"model": "JetFlex TD 65-2 G",
"membrane": "EPDM",
"tubeLength_mm": 1000,
"totalLength_mm": 1062.5,
"outerDiameter_mm": 65,
"membraneArea_m2_per_element": 0.18,
"operating": {
"continuousFlow_Nm3h_per_element": [2, 12],
"maxOverloadFlow_Nm3h_per_element": 20,
"operatingMode": "continuous-or-intermittent"
},
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
"coverageBasis": "bottom-coverage-pct",
"coverageReference": null,
"dataQuality": "point",
"xAxisBasis": "per-m2-membrane-Nm3h",
"yAxisBasis": "ssotr-g-per-Nm3-per-m",
"waterDepth_m": null,
"source": "Jäger Umwelt-Technik 'JETFLEX TD 65-2 G Tube Diffuser' data sheet — vector chart on page 2 ('SSOTE and headloss for EPDM 1000 mm'). Curve coordinates recovered directly from the PDF vector paths on 2026-05-12 (bezier endpoints of the red SSOTE polyline and blue DWP polyline); axis calibration against the gridlines is exact.",
"note": "Vendor sheet states neither tank-floor coverage nor water depth at which the SSOTE curve was measured — single-coverage point estimate (key '0' = unspecified), do not extrapolate across density. Native x-axis in the sheet is Nm³/h per tube; converted to canonical Nm³/(h·m² membrane) by dividing by perforated area 0.18 m²."
},
"ssote_curve": {
"0": {
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
"y": [8.20, 7.85, 7.57, 7.30, 7.10, 6.97, 6.85, 6.72, 6.60, 6.50, 6.40]
}
},
"otr_curve": {
"0": {
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
"y": [24.52, 23.47, 22.63, 21.83, 21.23, 20.84, 20.48, 20.09, 19.73, 19.44, 19.14]
}
},
"p_curve": {
"0": {
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
"y": [25.0, 27.5, 30.0, 32.5, 35.0, 37.5, 40.0, 42.0, 44.0, 46.0, 48.0]
}
}
}

View File

@@ -0,0 +1,39 @@
{
"_meta": {
"supplier": "Sulzer ABS",
"type": "Disc",
"model": "PIK300",
"membrane": "Perforated EPDM",
"membraneArea_m2_per_element": 0.07,
"membraneArea_m2_per_element_note": "Sulzer ABS PIK/PRK 300 mm fine-bubble disc, ~295 mm active membrane diameter (π × 0.1475² ≈ 0.068 m², rounded to 0.07 m²). Confirm against Sulzer spec sheet if available.",
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
"coverageBasis": "bottom-coverage-pct",
"coverageReference": [5, 10, 15, 20, 25],
"dataQuality": "multi-coverage",
"xAxisBasis": "per-m2-membrane-Nm3h",
"yAxisBasis": "ssotr-g-per-Nm3-per-m",
"waterDepth_m": 4.0,
"source": "'PIK & PRK300 data from QM.xlsx' (Sheet1). Sister model PRK300 shares the SOTE/SSOTR curves; only DWP differs (PIK300 = perforated EPDM, PRK300 = perforated PUR).",
"note": "Native data was Sm³/h/disc on X and g O₂/(Sm³·m) on Y (US standard, 20 °C, 1.204 kg/Sm³ → 278.6 g O₂/Sm³). Converted to canonical Nm³ basis (DIN-1343, 0 °C, 1.293 kg/Nm³ → 299.0 g O₂/Nm³): X × 0.9319 / 0.07, Y × 1.0732. Water depth ≈ 4.0 m falls out of the SOTE↔SSOTR ratio under Sm³ conventions — verify if precision matters."
},
"sote_curve": {
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [27.87, 26.99, 25.80, 24.97, 24.38, 23.89, 23.46, 23.12] },
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [30.18, 29.33, 28.15, 27.33, 26.73, 26.21, 25.83, 25.49] },
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [31.51, 30.53, 29.16, 28.27, 27.57, 27.01, 26.55, 26.15] },
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [32.52, 31.39, 29.88, 28.84, 28.06, 27.45, 26.92, 26.49] },
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [33.26, 32.04, 30.39, 29.27, 28.45, 27.77, 27.22, 26.76] }
},
"otr_curve": {
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [20.937, 20.276, 19.382, 18.759, 18.316, 17.947, 17.624, 17.369] },
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [22.673, 22.034, 21.148, 20.532, 20.081, 19.690, 19.405, 19.149] },
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [23.672, 22.936, 21.907, 21.238, 20.712, 20.291, 19.946, 19.645] },
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.431, 23.582, 22.447, 21.666, 21.080, 20.622, 20.224, 19.901] },
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.987, 24.070, 22.831, 21.989, 21.373, 20.862, 20.449, 20.104] }
},
"p_curve": {
"0": {
"x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49],
"y": [25.5, 26.0, 27.5, 30.3, 34.0, 39.0, 45.0, 52.0]
}
}
}

View File

@@ -0,0 +1,39 @@
{
"_meta": {
"supplier": "Sulzer ABS",
"type": "Disc",
"model": "PRK300",
"membrane": "Perforated PUR",
"membraneArea_m2_per_element": 0.07,
"membraneArea_m2_per_element_note": "Sulzer ABS PIK/PRK 300 mm fine-bubble disc, ~295 mm active membrane diameter (π × 0.1475² ≈ 0.068 m², rounded to 0.07 m²). Confirm against Sulzer spec sheet if available.",
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
"coverageBasis": "bottom-coverage-pct",
"coverageReference": [5, 10, 15, 20, 25],
"dataQuality": "multi-coverage",
"xAxisBasis": "per-m2-membrane-Nm3h",
"yAxisBasis": "ssotr-g-per-Nm3-per-m",
"waterDepth_m": 4.0,
"source": "'PIK & PRK300 data from QM.xlsx' (Sheet1). SOTE/SSOTR curves identical to the sibling PIK300; the only difference is the DWP curve (PRK = perforated PUR vs PIK = perforated EPDM).",
"note": "Native data was Sm³/h/disc on X and g O₂/(Sm³·m) on Y. Converted to canonical Nm³ basis (DIN-1343): X × 0.9319 / 0.07, Y × 1.0732."
},
"sote_curve": {
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [27.87, 26.99, 25.80, 24.97, 24.38, 23.89, 23.46, 23.12] },
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [30.18, 29.33, 28.15, 27.33, 26.73, 26.21, 25.83, 25.49] },
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [31.51, 30.53, 29.16, 28.27, 27.57, 27.01, 26.55, 26.15] },
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [32.52, 31.39, 29.88, 28.84, 28.06, 27.45, 26.92, 26.49] },
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [33.26, 32.04, 30.39, 29.27, 28.45, 27.77, 27.22, 26.76] }
},
"otr_curve": {
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [20.937, 20.276, 19.382, 18.759, 18.316, 17.947, 17.624, 17.369] },
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [22.673, 22.034, 21.148, 20.532, 20.081, 19.690, 19.405, 19.149] },
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [23.672, 22.936, 21.907, 21.238, 20.712, 20.291, 19.946, 19.645] },
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.431, 23.582, 22.447, 21.666, 21.080, 20.622, 20.224, 19.901] },
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.987, 24.070, 22.831, 21.989, 21.373, 20.862, 20.449, 20.104] }
},
"p_curve": {
"0": {
"x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49],
"y": [21.3, 24.0, 29.3, 35.3, 41.3, 46.8, 52.4, 58.6]
}
}
}

View File

@@ -0,0 +1,68 @@
{
"id": "diffuser",
"label": "diffuser",
"softwareType": "diffuser",
"suppliers": [
{
"id": "gva",
"name": "GVA",
"types": [
{
"id": "diffuser-tube",
"name": "Tube",
"models": [
{ "id": "gva-elastox-r", "name": "ELASTOX-R", "units": ["Nm3/h"] }
]
}
]
},
{
"id": "jaeger",
"name": "Jäger Umwelt-Technik",
"types": [
{
"id": "diffuser-tube",
"name": "Tube",
"models": [
{
"id": "jaeger-jetflex-td-65-2-g-epdm-1000",
"name": "JetFlex TD 65-2 G — EPDM 1000 mm",
"units": ["Nm3/h"]
}
]
}
]
},
{
"id": "aquaconsult-entec",
"name": "Aquaconsult Anlagenbau (Entec)",
"types": [
{
"id": "diffuser-strip",
"name": "Strip",
"models": [
{
"id": "aerostrip-phoenix",
"name": "AEROSTRIP — Phoenix membrane",
"units": ["Nm3/h"]
}
]
}
]
},
{
"id": "sulzer",
"name": "Sulzer ABS",
"types": [
{
"id": "diffuser-disc",
"name": "Disc",
"models": [
{ "id": "pik300", "name": "PIK300 — perforated EPDM", "units": ["Nm3/h"] },
{ "id": "prk300", "name": "PRK300 — perforated PUR", "units": ["Nm3/h"] }
]
}
]
}
]
}

View File

@@ -1,89 +1,83 @@
const fs = require('fs');
const path = require('path');
'use strict';
// AssetCategoryManager is now a thin facade over src/registry/assetResolver.
// The public surface (getCategory / listCategories / hasCategory / searchCategories)
// is preserved so existing consumers (src/menu/asset.js, src/helper/assetUtils.js)
// don't need to change in this phase. New code should use assetResolver directly.
const { assetResolver } = require('../../src/registry');
class AssetCategoryManager {
constructor(relPath = '.') {
this.assetDir = path.resolve(__dirname, relPath);
this.cache = new Map();
}
// relPath is retained for signature compatibility with the prior on-disk
// implementation; it is unused now — the resolver owns file locations.
constructor(/* relPath = '.' */) {}
getCategory(softwareType) {
if (!softwareType) {
throw new Error('softwareType is required');
}
if (this.cache.has(softwareType)) {
return this.cache.get(softwareType);
}
const filePath = path.resolve(this.assetDir, `${softwareType}.json`);
if (!fs.existsSync(filePath)) {
throw new Error(`Asset data '${softwareType}' not found in ${this.assetDir}`);
}
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache.set(softwareType, parsed);
return parsed;
}
hasCategory(softwareType) {
const filePath = path.resolve(this.assetDir, `${softwareType}.json`);
return fs.existsSync(filePath);
}
listCategories({ withMeta = false } = {}) {
const files = fs.readdirSync(this.assetDir, { withFileTypes: true });
return files
.filter(
(entry) =>
entry.isFile() &&
entry.name.endsWith('.json') &&
entry.name !== 'index.json' &&
entry.name !== 'assetData.json'
)
.map((entry) => path.basename(entry.name, '.json'))
.map((name) => {
if (!withMeta) {
return name;
getCategory(softwareType) {
if (!softwareType) {
throw new Error('softwareType is required');
}
const data = this.getCategory(name);
return {
softwareType: data.softwareType || name,
label: data.label || name,
file: `${name}.json`
};
});
}
searchCategories(query) {
const term = (query || '').trim().toLowerCase();
if (!term) {
return [];
const data = assetResolver.resolve('menu', softwareType);
if (!data) {
throw new Error(`Asset data '${softwareType}' not found in menu namespace`);
}
return data;
}
return this.listCategories({ withMeta: true }).filter(
({ softwareType, label }) =>
softwareType.toLowerCase().includes(term) ||
label.toLowerCase().includes(term)
);
}
hasCategory(softwareType) {
if (!softwareType) return false;
return assetResolver.resolve('menu', softwareType) != null;
}
clearCache() {
this.cache.clear();
}
listCategories({ withMeta = false } = {}) {
// The resolver indexes each menu file under BOTH its inner softwareType
// and its filename slug — those may differ. Dedupe by payload identity
// so we return one entry per source file.
const seen = new Set();
const out = [];
for (const key of assetResolver.list('menu')) {
const data = assetResolver.resolve('menu', key);
if (!data || seen.has(data)) continue;
seen.add(data);
const softwareType = data.softwareType || key;
if (withMeta) {
out.push({
softwareType,
label: data.label || softwareType,
file: `${softwareType}.json`,
});
} else {
out.push(softwareType);
}
}
return out;
}
searchCategories(query) {
const term = (query || '').trim().toLowerCase();
if (!term) return [];
return this.listCategories({ withMeta: true }).filter(
({ softwareType, label }) =>
softwareType.toLowerCase().includes(term) ||
(label || '').toLowerCase().includes(term),
);
}
clearCache() {
// Caches live in the resolver namespaces. Force-refresh menu.
// refresh() is async but the legacy contract here is sync —
// fire-and-forget; the next resolve() lazily warms in the worst case.
assetResolver.refresh('menu').catch(() => {});
}
}
const assetCategoryManager = new AssetCategoryManager();
module.exports = {
AssetCategoryManager,
assetCategoryManager,
getCategory: (softwareType) => assetCategoryManager.getCategory(softwareType),
listCategories: (options) => assetCategoryManager.listCategories(options),
searchCategories: (query) => assetCategoryManager.searchCategories(query),
hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType),
clearCache: () => assetCategoryManager.clearCache()
AssetCategoryManager,
assetCategoryManager,
getCategory: (softwareType) => assetCategoryManager.getCategory(softwareType),
listCategories: (options) => assetCategoryManager.listCategories(options),
searchCategories: (query) => assetCategoryManager.searchCategories(query),
hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType),
clearCache: () => assetCategoryManager.clearCache(),
};

View File

@@ -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"] }
]
}
]
}
]
}

View File

@@ -1,16 +0,0 @@
{
"1.204": {
"125": {
"x": [0,10,20,30,40,50,60,70,80,90,100],
"y": [0,18,50,95,150,216,337,564,882,1398,1870]
},
"150": {
"x": [0,10,20,30,40,50,60,70,80,90,100],
"y": [0,25,73,138,217,314,490,818,1281,2029,2715]
},
"400": {
"x": [0,10,20,30,40,50,60,70,80,90,100],
"y": [0,155,443,839,1322,1911,2982,4980,7795,12349,16524]
}
}
}

View File

@@ -1,838 +0,0 @@
{
"np": {
"400": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5953611390998625,
1.6935085477165994,
3.801139124304824,
7.367829525776738,
12.081735423116616
]
},
"500": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.8497068236812997,
3.801139124304824,
7.367829525776738,
12.081735423116616
]
},
"600": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.7497197821018213,
3.801139124304824,
7.367829525776738,
12.081735423116616
]
},
"700": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.788320579602724,
3.9982668237045984,
7.367829525776738,
12.081735423116616
]
},
"800": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.7824519364844427,
3.9885060367793064,
7.367829525776738,
12.081735423116616
]
},
"900": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6934482683506376,
3.9879559558537054,
7.367829525776738,
12.081735423116616
]
},
"1000": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6954385513069579,
4.0743508382926795,
7.422392692482345,
12.081735423116616
]
},
"1100": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
4.160745720731654,
7.596626714476177,
12.081735423116616
]
},
"1200": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
4.302551231007837,
7.637247864947884,
12.081735423116616
]
},
"1300": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
4.37557913990704,
7.773442147000839,
12.081735423116616
]
},
"1400": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
4.334434337766139,
7.940911352646818,
12.081735423116616
]
},
"1500": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
4.2327206586037995,
8.005238800611183,
12.254836577088351
]
},
"1600": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
4.195405588464695,
7.991827302945298,
12.423663269044452
]
},
"1700": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
14.255458319309813,
8.096768422220196,
12.584668380908582
]
},
"1800": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
31.54620347513727,
12.637080520201405
]
},
"1900": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
8.148423429611098,
12.74916725120127
]
},
"2000": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
8.146439484120116,
12.905178964345618
]
},
"2100": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
8.149576025637684,
13.006940917309247
]
},
"2200": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
8.126246430368305,
13.107503837410825
]
},
"2300": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
8.104379361635342,
13.223235973280122
]
},
"2400": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
8.135190080423746,
13.36128347785936
]
},
"2500": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
7.981219508598527,
13.473697427231842
]
},
"2600": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
7.863899404441271,
13.50303289156837
]
},
"2700": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
7.658860522528131,
13.485230880073107
]
},
"2800": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
7.44407948309266,
13.446135725634615
]
},
"2900": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
0.5522732775894703,
1.6920721090317592,
3.8742719210788685,
7.44407948309266,
13.413693596332184
]
}
},
"nq": {
"400": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
7.6803204433986965,
25.506609120436963,
35.4,
44.4,
52.5
]
},
"500": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
22.622804921188227,
35.4,
44.4,
52.5
]
},
"600": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
19.966301579194372,
35.4,
44.4,
52.5
]
},
"700": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
17.430763940163832,
33.79508340848005,
44.4,
52.5
]
},
"800": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
14.752921911234477,
31.71885034449889,
44.4,
52.5
]
},
"900": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
11.854693031181021,
29.923046639543475,
44.4,
52.5
]
},
"1000": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.549433913822687,
26.734189128096668,
43.96760750800311,
52.5
]
},
"1100": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
26.26933164936586,
42.23523193272671,
52.5
]
},
"1200": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
24.443114637042832,
40.57167959798151,
52.5
]
},
"1300": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
22.41596168949836,
39.04561852479495,
52.5
]
},
"1400": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
20.276864821170303,
37.557663261443224,
52.252852231224054
]
},
"1500": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
18.252772588147742,
35.9974418607538,
50.68604059588987
]
},
"1600": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
16.31441663648616,
34.51170378091407,
49.20153034100798
]
},
"1700": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
14.255458319309813,
33.043410795291045,
47.820213744181245
]
},
"1800": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
31.54620347513727,
46.51705619739449
]
},
"1900": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
29.986013742375484,
45.29506741639918
]
},
"2000": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
28.432646044605782,
44.107822395271945
]
},
"2100": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
26.892634464336055,
42.758175515158776
]
},
"2200": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
25.270679127870263,
41.467063889795895
]
},
"2300": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
23.531132157718837,
40.293041104955826
]
},
"2400": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
21.815645106750623,
39.03109248860755
]
},
"2500": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
20.34997949463564,
37.71320701654063
]
},
"2600": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
18.81710568651804,
36.35563657017404
]
},
"2700": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
17.259072160217805,
35.02979557646653
]
},
"2800": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
16,
33.74372254979665
]
},
"2900": {
"x": [
0,
25.510204081632654,
51.020408163265309,
76.530612244897952,
100
],
"y": [
6.4,
9.500000000000002,
12.7,
16,
32.54934541379723
]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,124 +0,0 @@
const fs = require('fs');
const path = require('path');
class AssetLoader {
constructor() {
this.relPath = './'
this.baseDir = path.resolve(__dirname, this.relPath);
this.cache = new Map(); // Cache loaded JSON files for better performance
}
/**
* Load a specific curve by type
* @param {string} curveType - The curve identifier (e.g., 'hidrostal-H05K-S03R')
* @returns {Object|null} The curve data object or null if not found
*/
loadModel(modelType) {
return this.loadAsset('models', modelType);
}
/**
* Load any asset from a specific dataset folder
* @param {string} datasetType - The dataset folder name (e.g., 'curves', 'assetData')
* @param {string} assetId - The specific asset identifier
* @returns {Object|null} The asset data object or null if not found
*/
loadAsset(datasetType, assetId) {
//const cacheKey = `${datasetType}/${assetId}`;
const cacheKey = `${assetId}`;
// Check cache first
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
try {
const filePath = path.join(this.baseDir, `${assetId}.json`);
// Check if file exists
if (!fs.existsSync(filePath)) {
console.warn(`Asset not found: ${filePath}`);
return null;
}
// Load and parse JSON
const rawData = fs.readFileSync(filePath, 'utf8');
const assetData = JSON.parse(rawData);
// Cache the result
this.cache.set(cacheKey, assetData);
return assetData;
} catch (error) {
console.error(`Error loading asset ${cacheKey}:`, error.message);
return null;
}
}
/**
* Get all available assets in a dataset
* @param {string} datasetType - The dataset folder name
* @returns {string[]} Array of available asset IDs
*/
getAvailableAssets(datasetType) {
try {
const datasetPath = path.join(this.baseDir, datasetType);
if (!fs.existsSync(datasetPath)) {
return [];
}
return fs.readdirSync(datasetPath)
.filter(file => file.endsWith('.json'))
.map(file => file.replace('.json', ''));
} catch (error) {
console.error(`Error reading dataset ${datasetType}:`, error.message);
return [];
}
}
/**
* Clear the cache (useful for development/testing)
*/
clearCache() {
this.cache.clear();
}
}
// Create and export a singleton instance
const assetLoader = new AssetLoader();
module.exports = {
AssetLoader,
assetLoader,
// Convenience methods for backward compatibility
loadModel: (modelType) => assetLoader.loadModel(modelType),
loadAsset: (datasetType, assetId) => assetLoader.loadAsset(datasetType, assetId),
getAvailableAssets: (datasetType) => assetLoader.getAvailableAssets(datasetType)
};
/*
// Example usage in your scripts
const loader = new AssetLoader();
// Load a specific curve
const curve = loader.loadModel('hidrostal-H05K-S03R');
if (curve) {
console.log('Model loaded:', curve);
} else {
console.log('Model not found');
}
/*
// Load any asset from any dataset
const someAsset = loadAsset('assetData', 'some-asset-id');
// Get list of available models
const availableCurves = getAvailableAssets('curves');
console.log('Available curves:', availableCurves);
// Using the class directly for more control
const { AssetLoader } = require('./index.js');
const customLoader = new AssetLoader();
const data = customLoader.loadCurve('hidrostal-H05K-S03R');
*/

View 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"
]
}
]
}
]
}
]
}

View File

@@ -30,8 +30,15 @@ const convert = require('./src/convert/index.js');
const MenuManager = require('./src/menu/index.js');
const { predict, interpolation } = require('./src/predict/index.js');
const { PIDController, CascadePIDController, createPidController, createCascadePidController } = require('./src/pid/index.js');
const { loadCurve } = require('./datasets/assetData/curves/index.js'); //deprecated replace with load model data
const { loadModel } = require('./datasets/assetData/modelData/index.js');
const { AssetResolver, FileBackend, HttpBackend, assetResolver } = require('./src/registry/index.js');
// loadCurve(model) is now a thin shim over assetResolver.resolve('curves', model).
// Same contract: sync, case-insensitive, returns null on miss. New code should
// prefer `assetResolver.resolve('curves', ...)` directly; this shim is kept so
// external consumers don't have to change in one go.
function loadCurve(modelId) {
return assetResolver.resolve('curves', modelId);
}
const { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js');
const Fysics = require('./src/convert/fysics.js');
@@ -72,8 +79,7 @@ module.exports = {
createPidController,
createCascadePidController,
childRegistrationUtils,
loadCurve, //deprecated replace with loadModel
loadModel,
loadCurve,
gravity,
POSITIONS,
POSITION_VALUES,
@@ -90,5 +96,12 @@ module.exports = {
createRegistry,
CommandRegistry,
BaseNodeAdapter,
stats
stats,
// Asset metadata registry (replaces loadCurve / AssetCategoryManager /
// ad-hoc JSON readers — see src/registry/README.md). Backend-swappable;
// sync at runtime by contract.
AssetResolver,
FileBackend,
HttpBackend,
assetResolver,
};

View File

@@ -44,6 +44,22 @@
}
}
},
"asset": {
"model": {
"default": "gva-elastox-r",
"rules": {
"type": "string",
"description": "Asset model id resolved via assetResolver.resolve('curves', model). Selected from the asset-menu cascade in the editor; defaults to GVA ELASTOX-R for backward compatibility with the legacy hardcoded curve."
}
},
"assetTagNumber": {
"default": "",
"rules": {
"type": "string",
"description": "External asset registry tag number (e.g. Bedrijfsmiddelenregister), assigned by the asset-menu sync to the WBD asset API."
}
}
},
"functionality": {
"softwareType": {
"default": "diffuser",
@@ -86,11 +102,19 @@
"description": "Number of diffuser elements in the zone."
}
},
"density": {
"default": 2.4,
"membraneAreaPerElement": {
"default": null,
"rules": {
"type": "number",
"description": "Installed diffuser density per square meter."
"nullable": true,
"description": "Membrane area per element [m²] used to convert total airflow to canonical specific flux Nm³/(h·m² membrane) before curve lookup. Defaults to the selected curve's _meta.membraneArea_m2_per_element (Jäger 0.18, Sulzer 0.07, Aerostrip 1.0 normalisation). Set explicitly only to override the curve metadata."
}
},
"density": {
"default": 15,
"rules": {
"type": "number",
"description": "Bottom coverage [%] — fraction of the tank floor area occupied by diffuser membrane. Typical fine-bubble installs run 1025 %. Used as the curve-family key in the supplier curve files (multi-coverage curves are interpolated; single-coverage curves are clamped). Replaces the legacy 'elements per m²' semantics, which was an incorrect re-tagging by an earlier refactor."
}
},
"waterHeight": {

View File

@@ -117,25 +117,60 @@ class ConfigManager {
}
};
// Add asset section if UI provides asset fields
if (uiConfig.supplier || uiConfig.category || uiConfig.assetType || uiConfig.model) {
config.asset = {
uuid: uiConfig.uuid || uiConfig.assetUuid || null,
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null,
supplier: uiConfig.supplier || 'Unknown',
category: uiConfig.category || 'sensor',
type: uiConfig.assetType || 'Unknown',
model: uiConfig.model || 'Unknown',
unit: uiConfig.unit || 'unitless'
};
}
// Asset section is emitted per-key: only fields the editor actually
// set propagate to the domain config. Schemas that omit a key (e.g.
// rotatingMachine deliberately drops asset.supplier/category/type
// because those come from the asset registry at runtime) no longer
// get those keys injected and then stripped by ValidationUtils with
// a warning. Empty strings from HTML defaults stay falsy → omitted →
// schema default applies.
const asset = {};
const uuid = uiConfig.uuid || uiConfig.assetUuid;
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
Object.assign(config, domainConfig);
// Merge domain-specific sections. Must be a DEEP merge: domainConfig
// commonly returns subsets of `general` / `asset` (e.g. {general:
// {unit}}, {asset: {curveUnits}}) and a shallow assign would wipe out
// sibling keys this method just populated — notably `general.id`
// (nodeId) and `asset.model`, causing child-registration id collisions
// and curve-lookup failures downstream.
ConfigManager._deepMerge(config, domainConfig);
return config;
}
static _isPlainObject(v) {
return Object.prototype.toString.call(v) === '[object Object]';
}
/**
* In-place recursive merge. Arrays and primitives in `src` replace `dst`;
* plain objects are merged key-by-key so siblings on `dst` survive.
*/
static _deepMerge(dst, src) {
if (!ConfigManager._isPlainObject(src)) return dst;
for (const key of Object.keys(src)) {
const v = src[key];
if (Array.isArray(v)) {
dst[key] = [...v];
} else if (ConfigManager._isPlainObject(v)) {
if (!ConfigManager._isPlainObject(dst[key])) dst[key] = {};
ConfigManager._deepMerge(dst[key], v);
} else {
dst[key] = v;
}
}
return dst;
}
/**
* Migrate a config object from one version to another by applying
* registered migration functions in sequence.

View File

@@ -91,7 +91,72 @@
],
"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": {
"current": {
@@ -107,10 +172,6 @@
"value": "priorityControl",
"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",
"description": "The group is in maintenance mode with limited actions (monitoring only)."
@@ -140,14 +201,6 @@
"description": "Actions allowed in priorityControl mode."
}
},
"prioritypercentagecontrol": {
"default": ["statusCheck", "execSequentialControl", "balanceLoad", "emergencyStop"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in manualOverride mode."
}
},
"maintenance": {
"default": ["statusCheck"],
"rules": {
@@ -165,7 +218,7 @@
"rules": {
"type": "object",
"schema": {
"optimalcontrol": {
"optimalControl": {
"default": ["parent", "GUI", "physical", "API"],
"rules": {
"type": "set",
@@ -173,7 +226,7 @@
"description": "Command sources allowed in optimalControl mode."
}
},
"prioritycontrol": {
"priorityControl": {
"default": ["parent", "GUI", "physical", "API"],
"rules": {
"type": "set",
@@ -181,36 +234,17 @@
"description": "Command sources allowed in priorityControl mode."
}
},
"prioritypercentagecontrol": {
"default": ["parent", "GUI", "physical", "API"],
"maintenance": {
"default": ["parent", "GUI"],
"rules": {
"type": "set",
"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."
}
}
},
"scaling": {
"current": {
"default": "normalized",
"rules": {
"type": "enum",
"values": [
{
"value": "normalized",
"description": "Scales the demand between 0100% 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."
}
}
}
}

View File

@@ -96,10 +96,38 @@
"default": null,
"rules": {
"type": "number",
"nullable": true,
"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": {
"uuid": {
"default": null,

View File

@@ -166,6 +166,10 @@
"value": "influxdb",
"description": "InfluxDB telemetry payload."
},
{
"value": "frost",
"description": "FROST/SensorThings CoreSync payload."
},
{
"value": "json",
"description": "JSON payload."
@@ -267,14 +271,14 @@
},
"basin": {
"volume": {
"default": "1",
"default": 50,
"rules": {
"type": "number",
"description": "Total volume of empty basin in m3"
}
},
"height": {
"default": "1",
"default": 4,
"rules": {
"type": "number",
"description": "Total height of basin in m"
@@ -288,11 +292,11 @@
}
},
"inflowLevel": {
"default": 2,
"default": 1.5,
"rules": {
"type": "number",
"min": 0,
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m)."
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m). Acts as the ramp foot in levelbased control: demand stays at 0 % below inflowLevel and scales 0 → 100 % across [inflowLevel, maxLevel]."
}
},
"outflowLevel": {
@@ -304,7 +308,7 @@
}
},
"overflowLevel": {
"default": 2.5,
"default": 3.8,
"rules": {
"type": "number",
"min": 0,
@@ -486,7 +490,7 @@
},
"levelbased": {
"minLevel": {
"default": 1,
"default": 0.3,
"rules": {
"type": "number",
"min": 0,
@@ -498,7 +502,7 @@
"rules": {
"type": "number",
"min": 0,
"description": "Pump-on threshold and ramp foot. Below this level demand is 0 %; at or above it demand scales 0 → 100 % across [startLevel, maxLevel] using the configured curve (linear or log). When enableShiftedRamp is on, this 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": {
@@ -507,11 +511,29 @@
"type": "number",
"nullable": true,
"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."
"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": {
"default": 4,
"default": 3.8,
"rules": {
"type": "number",
"min": 0,

View File

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

View File

@@ -134,6 +134,7 @@
"type": "enum",
"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." }
],
@@ -196,39 +197,20 @@
}
}
},
"supplier": {
"default": "Unknown",
"rules": {
"type": "string",
"description": "The supplier or manufacturer of the asset."
}
},
"category": {
"default": "pump",
"rules": {
"type": "string",
"description": "A general classification of the asset tied to the specific software. This is not chosen from the asset dropdown menu."
}
},
"type": {
"default": "Centrifugal",
"rules": {
"type": "string",
"description": "A more specific classification within 'type'. For example, 'centrifugal' for a centrifugal pump."
}
},
"model": {
"default": "Unknown",
"default": null,
"rules": {
"type": "string",
"description": "A user-defined or manufacturer-defined model identifier for the asset."
"nullable": true,
"description": "Product model id (e.g. 'hidrostal-H05K-S03R'). Required at startup: the node looks the curve up via assetResolver.resolve('curves', model). Supplier/type/units are derived from the asset registry (assetResolver.resolveAssetMetadata) — do NOT save them on the node."
}
},
"unit": {
"default": "unitless",
"default": null,
"rules": {
"type": "string",
"description": "The unit of measurement for this asset (e.g., 'meters', 'seconds', 'unitless')."
"nullable": true,
"description": "Deployment unit chosen by the user (e.g. 'm3/h'). Must appear in the registry's model.units list for this model. Validated at startup."
}
},
"curveUnits": {
@@ -478,27 +460,6 @@
"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": {
"default": 1,
"rules": {

View File

@@ -140,39 +140,20 @@
}
}
},
"supplier": {
"default": "Unknown",
"rules": {
"type": "string",
"description": "The supplier or manufacturer of the asset."
}
},
"category": {
"default": "valve",
"rules": {
"type": "string",
"description": "A general classification of the asset tied to the specific software. This is not chosen from the asset dropdown menu."
}
},
"type": {
"default": "gate",
"rules": {
"type": "string",
"description": "A more specific classification within 'type'. For example, 'centrifugal' for a centrifugal pump."
}
},
"model": {
"default": "Unknown",
"default": null,
"rules": {
"type": "string",
"description": "A user-defined or manufacturer-defined model identifier for the asset."
"nullable": true,
"description": "Product model id (e.g. 'binder-valve-001'). Required at startup: the node looks the curve up via assetResolver.resolve('curves', model). Supplier/type/units are derived from the asset registry (assetResolver.resolveAssetMetadata) — do NOT save them on the node."
}
},
"unit": {
"default": "unitless",
"default": null,
"rules": {
"type": "string",
"description": "The unit of measurement for this asset (e.g., 'meters', 'seconds', 'unitless')."
"nullable": true,
"description": "Deployment unit chosen by the user. Must appear in the registry's model.units list for this model. Validated at startup."
}
},
"accuracy": {
@@ -224,47 +205,6 @@
"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":{
"default": {},
"rules": {
@@ -361,27 +301,6 @@
},
"description": "Predefined sequences of states for the machine."
},
"calculationMode": {
"default": "medium",
"rules": {
"type": "enum",
"values": [
{
"value": "low",
"description": "Calculations run at fixed intervals (time-based)."
},
{
"value": "medium",
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
},
{
"value": "high",
"description": "Calculations run on all event-driven info, including every movement."
}
],
"description": "The frequency at which calculations are performed."
}
}
}

View File

@@ -176,47 +176,6 @@
"description": "The operational mode of the valveGroupControl."
}
},
"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":{
"default": {},
"rules": {
@@ -346,26 +305,5 @@
},
"description": "Predefined sequences of states for the valveGroupControl."
},
"calculationMode": {
"default": "medium",
"rules": {
"type": "enum",
"values": [
{
"value": "low",
"description": "Calculations run at fixed intervals (time-based)."
},
{
"value": "medium",
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
},
{
"value": "high",
"description": "Calculations run on all event-driven info, including every movement."
}
],
"description": "The frequency at which calculations are performed."
}
}
}

View File

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

View File

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

View File

@@ -2,8 +2,16 @@ const { getFormatter } = require('./formatters');
//this class will handle the output events for the node red node
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.alwaysEmit = new Set(options.alwaysEmit || []);
}
checkForChanges(output, format) {
@@ -13,7 +21,9 @@ class OutputUtils {
this.output[format] = this.output[format] || {};
const changedFields = {};
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];
// For fields: if the value is an object (and not a Date), stringify it.
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
@@ -79,7 +89,13 @@ class OutputUtils {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(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.
const flatChild = this.flattenTags(value);
for (const childKey in flatChild) {
@@ -104,9 +120,10 @@ class OutputUtils {
// functionality properties
softwareType: config.functionality?.softwareType,
role: config.functionality?.role,
positionVsParent: config.functionality?.positionVsParent,
// asset properties (exclude machineCurve)
uuid: config.asset?.uuid,
tagcode: config.asset?.tagcode,
tagcode: config.asset?.tagCode || config.asset?.tagcode,
geoLocation: config.asset?.geoLocation,
category: config.asset?.category,
type: config.asset?.type,

View File

@@ -1,41 +1,30 @@
const fs = require('fs');
const path = require('path');
'use strict';
// AquonSamplesMenu is now a thin facade over assetResolver.
// Backed by namespaces `monsterSamples` (sample codes, indexed by code)
// and `monsterSpecs` (sampling defaults + per-sample overrides).
const { assetResolver } = require('../registry');
class AquonSamplesMenu {
constructor(relPath = '../../datasets/assetData') {
this.baseDir = path.resolve(__dirname, relPath);
this.samplePath = path.resolve(this.baseDir, 'monsterSamples.json');
this.specPath = path.resolve(this.baseDir, 'specs/monster/index.json');
this.cache = new Map();
}
// relPath retained for signature compatibility with the previous on-disk
// implementation; unused — the registry owns file locations.
constructor(/* relPath */) {}
_loadJSON(filePath, cacheKey) {
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
getAllMenuData() {
const samples = assetResolver
.list('monsterSamples')
.map((id) => assetResolver.resolve('monsterSamples', id))
.filter(Boolean);
const specs = assetResolver.resolve('monsterSpecs', 'all') || { defaults: {}, bySample: {} };
return {
samples,
specs: {
defaults: specs.defaults || {},
bySample: specs.bySample || {},
},
};
}
if (!fs.existsSync(filePath)) {
throw new Error(`Aquon dataset not found: ${filePath}`);
}
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache.set(cacheKey, parsed);
return parsed;
}
getAllMenuData() {
const samples = this._loadJSON(this.samplePath, 'samples');
const specs = this._loadJSON(this.specPath, 'specs');
return {
samples: samples.samples || [],
specs: {
defaults: specs.defaults || {},
bySample: specs.bySample || {}
}
};
}
}
module.exports = AquonSamplesMenu;

View File

@@ -19,6 +19,7 @@ class AssetMenu {
return null;
}
const softwareType = category.softwareType || key;
return {
...category,
label: category.label || category.softwareType || key,
@@ -28,11 +29,18 @@ class AssetMenu {
types: (supplier.types || []).map((type) => ({
...type,
id: type.id || type.name,
models: (type.models || []).map((model) => ({
...model,
id: model.id || model.name,
units: model.units || []
}))
models: (type.models || []).map((model) => {
const id = model.id || model.name;
// Enrich each model with a slim preview curve (or null) so the
// 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) {
@@ -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) {
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventsCode = this.getEventInjectionCode(nodeName);
const syncCode = this.getSyncInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return `
// --- AssetMenu for ${nodeName} ---
@@ -93,14 +458,19 @@ class AssetMenu {
${eventsCode}
${syncCode}
${saveCode}
${visualCode}
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
console.log('Initializing asset properties for ${nodeName}');
this.injectHtml();
this.wireEvents(node);
this.loadData(node).catch((error) =>
console.error('Asset menu load failed:', error)
);
const self = this;
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 : [];
// 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(
elems.supplier,
suppliers,
@@ -577,35 +967,165 @@ class AssetMenu {
}
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 `
<!-- Asset Properties -->
<hr />
<h3>Asset selection</h3>
<div class="form-row">
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
<select id="node-input-supplier" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
<select id="node-input-assetType" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
<select id="node-input-model" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
<select id="node-input-unit" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
<div class="evolv-asset-wizard" id="evolv-asset-wizard">
<div class="evolv-asset-chips" role="tablist" aria-label="Asset selection stages">
<button type="button" class="evolv-asset-chip" data-stage="supplier" aria-selected="false">
<span class="evolv-asset-chip-icon"><i class="fa fa-industry"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Supplier</span>
<span class="evolv-asset-chip-value" data-empty="true">Select…</span>
</span>
</button>
<span class="evolv-asset-chip-sep" aria-hidden="true"></span>
<button type="button" class="evolv-asset-chip" data-stage="type" aria-selected="false">
<span class="evolv-asset-chip-icon"><i class="fa fa-puzzle-piece"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Type</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="model" aria-selected="false">
<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>
<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) {
const htmlTemplate = this.getHtmlTemplate()
.replace(/`/g, '\\`')
@@ -624,46 +1144,40 @@ class AssetMenu {
}
getSaveInjectionCode(nodeName) {
// After the AssetResolver cutover, only model + unit + tagCode are stored
// on the node. supplier / assetType / category were denormalized copies of
// registry data and are derived at runtime via
// assetResolver.resolveAssetMetadata(softwareType, model).
//
// We still READ the supplier/type DOM elements for validation (the user
// must have walked the cascade to pick a model), but we explicitly CLEAR
// them from the persisted node — so a saved flow only contains the
// identifier surface.
return `
// Asset save handler for ${nodeName}
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
console.log('Saving asset properties for ${nodeName}');
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
const categories = menuAsset.categories || {};
const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null;
const resolveCategoryKey = () => {
if (node.softwareType && categories[node.softwareType]) {
return node.softwareType;
}
if (node.category && categories[node.category]) {
return node.category;
}
return defaultCategory || '';
};
node.category = resolveCategoryKey();
const fields = ['supplier', 'assetType', 'model', 'unit', 'assetTagNumber'];
const errors = [];
fields.forEach((field) => {
const el = document.getElementById(\`node-input-\${field}\`);
node[field] = el ? el.value : '';
});
const modelEl = document.getElementById('node-input-model');
const unitEl = document.getElementById('node-input-unit');
const tagEl = document.getElementById('node-input-assetTagNumber');
if (node.assetType && !node.unit) {
errors.push('Unit must be set when a type is specified.');
}
if (!node.unit) {
errors.push('Unit is required.');
}
node.model = modelEl ? modelEl.value : '';
node.unit = unitEl ? unitEl.value : '';
node.assetTagNumber = tagEl ? tagEl.value : '';
// Identity surface only — registry derives the rest.
delete node.supplier;
delete node.category;
delete node.assetType;
if (!node.model) errors.push('Model is required.');
if (!node.unit) errors.push('Unit is required.');
errors.forEach((msg) => RED.notify(msg, 'error'));
const saved = fields.reduce((acc, field) => {
acc[field] = node[field];
return acc;
}, {});
const saved = { model: node.model, unit: node.unit, assetTagNumber: node.assetTagNumber };
if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') {
saved.modelId = node.modelMetadata.id;
}

359
src/menu/iconHelpers.js Normal file
View 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;

View File

@@ -3,6 +3,7 @@ const AssetMenu = require('./asset.js');
const LoggerMenu = require('./logger.js');
const PhysicalPositionMenu = require('./physicalPosition.js');
const AquonSamplesMenu = require('./aquonSamples.js');
const IconHelpers = require('./iconHelpers.js');
const ConfigManager = require('../configs');
class MenuManager {
@@ -138,6 +139,9 @@ class MenuManager {
window.EVOLV.nodes = window.EVOLV.nodes || {};
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
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
@@ -163,12 +167,26 @@ class MenuManager {
try {
${menuTypes.map(type => `
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) {
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
}
} catch (${type}Error) {
console.error('Error initializing ${type} menu for ${nodeName}:', ${type}Error);
}`).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) {
console.error('Error in main editor initialization for ${nodeName}:', editorError);
}

View File

@@ -103,12 +103,68 @@ getHtmlInjectionCode(nodeName) {
`;
}
// 5) Compose everything into one clientside 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 clientside payload
getClientInitCode(nodeName) {
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const htmlCode = this.getHtmlInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return `
// --- LoggerMenu for ${nodeName} ---
@@ -119,13 +175,16 @@ getHtmlInjectionCode(nodeName) {
${dataCode}
${eventCode}
${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) {
// ------------------ BELOW sequence is important! -------------------------------
this.injectHtml();
this.loadData(node);
this.wireEvents(node);
if (this.initVisuals) this.initVisuals(node);
};
`;
}

View File

@@ -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) {
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return `
// --- PhysicalPositionMenu for ${nodeName} ---
@@ -261,12 +318,15 @@ getSaveInjectionCode(nodeName) {
${dataCode}
${eventCode}
${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) {
this.injectHtml();
this.loadData(node);
this.wireEvents(node);
if (this.initVisuals) this.initVisuals(node);
};
`;
}

View File

@@ -82,7 +82,9 @@ class BaseNodeAdapter {
// pumpingStation/measurement nodeClass _attachInputHandler patterns.
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(
(c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units'))));
const mergedCommands = userHasUnitsQuery

View File

@@ -71,14 +71,22 @@ class Predict {
// Capture share-source BEFORE config validation strips it (ConfigUtils
// mutates the input config to drop unknown keys, which would remove
// 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)
? config.shareInputsFrom
: null;
let _initConfig = config;
if (_initConfig && 'shareInputsFrom' in _initConfig) {
_initConfig = { ..._initConfig };
delete _initConfig.shareInputsFrom;
}
// Initialize dependencies
this.emitter = new EventEmitter(); // Own EventEmitter
this.configUtils = new ConfigUtils(defaultConfig);
this.config = this.configUtils.initConfig(config);
this.config = this.configUtils.initConfig(_initConfig);
// Init after config is set
this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);

View File

@@ -0,0 +1,103 @@
'use strict';
/**
* AssetResolver — single entry point for all asset-side metadata in EVOLV.
*
* Namespaces are declared at construction (see ./namespaces/*). Each namespace
* exposes a `loadAll()` returning a `Map<id, payload>`; AssetResolver routes
* resolve(name, id) calls into the right namespace, caches the loaded map, and
* exposes async `refresh(name?)` for future HttpBackend hydration.
*
* Resolution is sync by contract: the first resolve() for an unwarmed
* namespace pulls everything into cache, all subsequent calls are O(1).
*
* Backend abstraction (./backends/*) is where File vs Http lives — namespaces
* just hold a backend reference and call backend.loadAll().
*
* See ./README.md for the full extension story.
*/
class AssetResolver {
constructor(namespaces = []) {
this._slots = new Map();
for (const ns of namespaces) {
if (!ns || !ns.name || typeof ns.loadAll !== 'function') {
throw new TypeError('AssetResolver: namespace must declare { name, loadAll() }');
}
this._slots.set(ns.name, { ns, cache: null });
}
}
_ensureWarm(name) {
const slot = this._slots.get(name);
if (!slot) throw new Error(`AssetResolver: unknown namespace '${name}'`);
if (slot.cache === null) slot.cache = slot.ns.loadAll();
return slot;
}
resolve(namespace, id) {
const slot = this._ensureWarm(namespace);
const key = String(id ?? '').toLowerCase();
if (!key) return null;
return slot.cache.get(key) ?? null;
}
list(namespace) {
const slot = this._ensureWarm(namespace);
return [...slot.cache.keys()];
}
namespaces() {
return [...this._slots.keys()];
}
async refresh(namespace) {
if (namespace) {
const slot = this._slots.get(namespace);
if (!slot) throw new Error(`AssetResolver: unknown namespace '${namespace}'`);
slot.cache = typeof slot.ns.refresh === 'function'
? await slot.ns.refresh()
: slot.ns.loadAll();
return;
}
for (const slot of this._slots.values()) {
slot.cache = typeof slot.ns.refresh === 'function'
? await slot.ns.refresh()
: slot.ns.loadAll();
}
}
/**
* Cross-namespace helper: given a softwareType + model id, walk the
* editor menu tree to return { supplier, type, units, raw } so domain
* code doesn't have to persist supplier/type/unit on the node.
*/
resolveAssetMetadata(softwareType, modelId) {
if (!softwareType || !modelId) return null;
const tree = this.resolve('menu', softwareType);
if (!tree || !Array.isArray(tree.suppliers)) return null;
const norm = String(modelId).toLowerCase();
for (const supplier of tree.suppliers) {
for (const type of supplier.types || []) {
for (const model of type.models || []) {
const candidate = String(model.id || model.name || '').toLowerCase();
if (candidate === norm) {
return {
supplier: supplier.name,
supplierId: supplier.id || supplier.name,
type: type.name,
typeId: type.id || type.name,
model: model.name,
modelId: model.id || model.name,
units: Array.isArray(model.units) ? [...model.units] : [],
raw: model,
};
}
}
}
}
return null;
}
}
module.exports = AssetResolver;

78
src/registry/README.md Normal file
View File

@@ -0,0 +1,78 @@
# registry — AssetResolver
Single entry point for all asset-side metadata: pump/valve curves, editor menu
trees, monster sample codes, unit families, and anything else we add later.
Replaces (will replace, phase-by-phase):
- `loadCurve(model)``assetResolver.resolve('curves', model)`
- `AssetCategoryManager``assetResolver.resolve('menu', softwareType)`
- ad-hoc loaders for `monsterSamples.json`, `unitData.json``assetResolver.resolve('monsterSamples'|'units', …)`
## Surface
```js
const { assetResolver } = require('generalFunctions');
const curve = assetResolver.resolve('curves', 'hidrostal-H05K-S03R');
const tree = assetResolver.resolve('menu', 'rotatingmachine');
const meta = assetResolver.resolveAssetMetadata('rotatingmachine', 'hidrostal-H05K-S03R');
// meta → { supplier, type, units, model, raw }
assetResolver.list('curves'); // ['hidrostal-H05K-S03R', 'ECDV', ...]
assetResolver.namespaces(); // ['curves', 'menu', 'monsterSamples', 'units']
await assetResolver.refresh(); // re-pull everything (FileBackend: re-reads disk; HttpBackend: future)
```
Resolution is synchronous. First call to `resolve(namespace, id)` warms that
namespace's cache; later calls are O(1) map lookups.
## Adding a namespace
Create `src/registry/namespaces/<name>.js`:
```js
const path = require('path');
const FileBackend = require('../backends/FileBackend');
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets/...'),
layout: 'per-id', // or 'single-file'
caseInsensitive: true,
});
module.exports = {
name: 'newThing',
description: 'What this namespace is for',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};
```
Register it in `namespaces/index.js`. Done.
## Backends
- **FileBackend** — reads JSON from disk. Two layouts: `per-id` (one file per
id, filename minus `.json` is the id) or `single-file` (one file with an
array; pick `arrayKey` and `indexField`).
- **HttpBackend** — stub. Disabled unless `EVOLV_ASSET_REMOTE=1`. Will hold
the future WBD product API client; currently throws if invoked. Exists so
the resolver contract is backend-agnostic from day one.
Backends are interchangeable per namespace: the namespace file is the
declarative join between "what this metadata is" and "where it comes from".
## Why sync at runtime
Node-RED node constructors aren't async-friendly. Every consumer that used
`loadCurve(model)` expects a synchronous return. The resolver preserves that
contract: cache is warmed lazily (first `resolve()` call pulls everything),
and lookups are O(1) map gets after that. Async `refresh()` exists for future
HttpBackend hydration on a background timer.
## Convention: namespace name is the cache key
`assetResolver.resolve(namespace, id)` lowercases `id` for the lookup. Old
case-mismatched configs (`Hidrostal-H05K-S03R` vs `hidrostal-H05K-S03R`) still
resolve correctly — same as `loadCurve` did historically.

View File

@@ -0,0 +1,96 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* FileBackend — reads JSON payloads from a directory on disk.
*
* Two layouts supported:
* - 'per-id' one file per id (filename minus .json is the id)
* - 'single-file' one file containing an array; index by a field name
*
* Returns Map<lowerCaseId, payload>. Case-insensitive lookups by default —
* matches how loadCurve worked historically.
*/
class FileBackend {
constructor(opts = {}) {
const {
baseDir,
layout = 'per-id',
filePath,
arrayKey,
indexField,
exclude = [],
caseInsensitive = true,
} = opts;
if (!baseDir) throw new TypeError('FileBackend: baseDir is required');
if (layout !== 'per-id' && layout !== 'single-file') {
throw new TypeError(`FileBackend: unsupported layout '${layout}'`);
}
if (layout === 'single-file' && !filePath) {
throw new TypeError('FileBackend: single-file layout requires filePath');
}
this.baseDir = baseDir;
this.layout = layout;
this.filePath = filePath;
this.arrayKey = arrayKey;
this.indexField = indexField;
this.exclude = new Set(exclude);
this.caseInsensitive = caseInsensitive;
}
_norm(k) {
return this.caseInsensitive ? String(k).toLowerCase() : String(k);
}
loadAll() {
if (this.layout === 'per-id') return this._loadPerId();
return this._loadSingleFile();
}
async refresh() {
// No actual I/O penalty on local disk; the async surface exists so
// callers can `await resolver.refresh()` symmetrically with future
// HttpBackend implementations.
return this.loadAll();
}
_loadPerId() {
const map = new Map();
if (!fs.existsSync(this.baseDir)) return map;
const entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.json')) continue;
const id = path.basename(entry.name, '.json');
if (this.exclude.has(id)) continue;
const raw = fs.readFileSync(path.join(this.baseDir, entry.name), 'utf8');
const data = JSON.parse(raw);
map.set(this._norm(id), data);
}
return map;
}
_loadSingleFile() {
const full = path.resolve(this.baseDir, this.filePath);
if (!fs.existsSync(full)) return new Map();
const data = JSON.parse(fs.readFileSync(full, 'utf8'));
const arr = this.arrayKey ? data[this.arrayKey] : data;
if (!Array.isArray(arr)) {
throw new Error(
`FileBackend(single-file): expected array at ${this.arrayKey || '<root>'} in ${full}`,
);
}
const map = new Map();
for (const entry of arr) {
const k = entry && this.indexField ? entry[this.indexField] : null;
if (k != null) map.set(this._norm(k), entry);
}
return map;
}
}
module.exports = FileBackend;

View File

@@ -0,0 +1,41 @@
'use strict';
/**
* HttpBackend — stub. The shape that any future remote product/asset DB will
* implement so the resolver can swap backends without touching consumers.
*
* Disabled by default. Set EVOLV_ASSET_REMOTE=1 to opt in; even then this
* stub throws on use because the upstream API is not yet defined. See
* `assetApiConfig.js` for the URL/auth scaffolding that will eventually
* land here.
*/
class HttpBackend {
constructor({ url, headers = {}, namespace } = {}) {
this.url = url;
this.headers = headers;
this.namespace = namespace;
}
static get enabled() {
return process.env.EVOLV_ASSET_REMOTE === '1';
}
loadAll() {
if (!HttpBackend.enabled) {
throw new Error(
'HttpBackend disabled (set EVOLV_ASSET_REMOTE=1 to enable); ' +
'no synchronous remote fetch is implemented yet.',
);
}
throw new Error(
'HttpBackend.loadAll(): remote asset backend not yet implemented. ' +
'Use FileBackend or implement this method against the WBD product API.',
);
}
async refresh() {
return this.loadAll();
}
}
module.exports = HttpBackend;

15
src/registry/index.js Normal file
View File

@@ -0,0 +1,15 @@
'use strict';
const AssetResolver = require('./AssetResolver');
const FileBackend = require('./backends/FileBackend');
const HttpBackend = require('./backends/HttpBackend');
const namespaces = require('./namespaces');
const assetResolver = new AssetResolver(namespaces);
module.exports = {
AssetResolver,
FileBackend,
HttpBackend,
assetResolver,
};

View File

@@ -0,0 +1,17 @@
'use strict';
const path = require('path');
const FileBackend = require('../backends/FileBackend');
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets/assetData/curves'),
layout: 'per-id',
caseInsensitive: true,
});
module.exports = {
name: 'curves',
description: 'Pump and valve performance curves keyed by model id',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};

View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = [
require('./curves'),
require('./menu'),
require('./monsterSamples'),
require('./monsterSpecs'),
require('./units'),
];

View File

@@ -0,0 +1,47 @@
'use strict';
const path = require('path');
const fs = require('fs');
const FileBackend = require('../backends/FileBackend');
const BASE_DIR = path.resolve(__dirname, '../../../datasets/assetData');
// Files in datasets/assetData that aren't editor menu trees.
const EXCLUDE = ['assetData', 'monsterSamples', 'unitData'];
// Plain per-id File backend, but the menu namespace also wants to key by the
// inner `softwareType` field (so '/menu/rotatingmachine' works even if the
// file is named machine.json). The FileBackend gives us filename-keyed maps;
// we rekey in a thin wrapper.
const backend = new FileBackend({
baseDir: BASE_DIR,
layout: 'per-id',
caseInsensitive: true,
exclude: EXCLUDE,
});
// Menu trees are looked up by softwareType. We index by BOTH the inner
// `softwareType` field AND the filename (sans .json), because consumers come
// from two paths: editor endpoints pass the node type ('rotatingmachine'),
// while older code paths pass the filename slug ('machine'). Both should hit
// the same tree.
function _rekeyBySoftwareType(map) {
const out = new Map();
for (const [filenameId, data] of map.entries()) {
const stKey = String(data?.softwareType || '').toLowerCase();
const fnKey = String(filenameId).toLowerCase();
if (stKey) out.set(stKey, data);
if (fnKey && fnKey !== stKey) out.set(fnKey, data);
}
return out;
}
module.exports = {
name: 'menu',
description: 'Editor cascade trees (supplier→type→model→unit), keyed by softwareType',
loadAll: () => _rekeyBySoftwareType(backend.loadAll()),
refresh: async () => _rekeyBySoftwareType(await backend.refresh()),
// Exposed for inline tests / debugging.
_BASE_DIR: BASE_DIR,
_EXCLUDE: EXCLUDE,
_existsForFilename: (id) => fs.existsSync(path.join(BASE_DIR, `${id}.json`)),
};

View File

@@ -0,0 +1,20 @@
'use strict';
const path = require('path');
const FileBackend = require('../backends/FileBackend');
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets/assetData'),
layout: 'single-file',
filePath: 'monsterSamples.json',
arrayKey: 'samples',
indexField: 'code',
caseInsensitive: true,
});
module.exports = {
name: 'monsterSamples',
description: 'Monster (Aquon) sample codes keyed by sample code',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};

View File

@@ -0,0 +1,30 @@
'use strict';
const path = require('path');
const fs = require('fs');
// monsterSpecs is a single-document namespace (one file, two top-level keys:
// defaults and bySample). It doesn't fit FileBackend's per-id or single-file
// array layouts cleanly — so we inline a tiny loader here instead of bending
// FileBackend to accommodate the shape.
//
// The whole document is exposed under id 'all'. Consumers (AquonSamplesMenu,
// monster specificClass) call assetResolver.resolve('monsterSpecs', 'all').
const FILE_PATH = path.resolve(__dirname, '../../../datasets/assetData/specs/monster/index.json');
function _load() {
if (!fs.existsSync(FILE_PATH)) return new Map();
const data = JSON.parse(fs.readFileSync(FILE_PATH, 'utf8'));
return new Map([['all', {
defaults: data.defaults || {},
bySample: data.bySample || {},
}]]);
}
module.exports = {
name: 'monsterSpecs',
description: 'Monster sampling specs (defaults + per-sample overrides) from specs/monster/index.json',
loadAll: _load,
refresh: () => _load(),
};

View File

@@ -0,0 +1,21 @@
'use strict';
const path = require('path');
const FileBackend = require('../backends/FileBackend');
// unitData.json lives at datasets/ (not datasets/assetData/).
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets'),
layout: 'single-file',
filePath: 'unitData.json',
arrayKey: 'units',
indexField: 'category',
caseInsensitive: true,
});
module.exports = {
name: 'units',
description: 'Unit families keyed by measurement category (flow, pressure, …)',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};

View File

@@ -79,65 +79,70 @@ class movementManager {
// Clamp the final target into [minPosition, maxPosition]
targetPosition = this.constrain(targetPosition);
// Compute direction and remaining distance
const direction = targetPosition > this.currentPosition ? 1 : -1;
const distance = Math.abs(targetPosition - this.currentPosition);
// Snapshot the starting point. Position is derived from ELAPSED WALL-TIME
// (not accumulated per-tick steps) so an interruption that lands between
// 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
if (velocity <= 0) {
return reject(new Error("Movement aborted: zero speed"));
}
// Duration and bookkeeping
const duration = distance / velocity; // seconds to go the remaining distance
this.timeleft = duration;
const duration = distance / velocity; // seconds to go the full distance
this.timeleft = duration;
this.logger.debug(
`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 intervalSec = intervalMs / 1000;
const stepSize = direction * velocity * intervalSec;
const intervalMs = this.interval;
const startTime = Date.now();
const startTime = Date.now();
// Position reached after `elapsedSec` of travel, clamped to the target.
const posAt = (elapsedSec) =>
this.constrain(startPosition + direction * Math.min(distance, velocity * elapsedSec));
// Re-base currentPosition (and timeleft) onto the real elapsed progress.
const settle = () => {
const elapsed = (Date.now() - startTime) / 1000;
this.currentPosition = posAt(elapsed);
this.timeleft = Math.max(0, duration - elapsed);
this.emitPos(this.currentPosition);
return elapsed;
};
// Kick off the loop
const intervalId = setInterval(() => {
// 7a) Abort check
if (signal?.aborted) {
clearInterval(intervalId);
settle();
return reject(new Error("Movement aborted"));
}
// Advance position and clamp
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);
const elapsed = settle();
this.logger.debug(
`pos=${this.currentPosition.toFixed(2)}, timeleft=${this.timeleft.toFixed(2)}`
);
// Completed the move?
if (
(direction > 0 && this.currentPosition >= targetPosition) ||
(direction < 0 && this.currentPosition <= targetPosition)
) {
// Completed the move? (time-based so it can't overshoot/undershoot)
if (elapsed >= duration) {
clearInterval(intervalId);
this.currentPosition = targetPosition;
this.timeleft = 0;
this.emitPos(this.currentPosition);
return resolve("Reached target move.");
}
}, 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", () => {
clearInterval(intervalId);
settle();
reject(new Error("Movement aborted"));
});
});
@@ -213,8 +218,8 @@ class movementManager {
return reject(new Error("Movement aborted"));
}
const totalDistance = Math.abs(targetPosition - this.currentPosition);
const startPosition = this.currentPosition;
const totalDistance = Math.abs(targetPosition - this.currentPosition);
const velocity = this.getVelocity();
if (velocity <= 0) {
return reject(new Error("Movement aborted: zero speed"));
@@ -223,45 +228,53 @@ class movementManager {
const easeFunction = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
let elapsedTime = 0;
const duration = totalDistance / velocity;
this.timeleft = duration;
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
const intervalId = setInterval(() => {
// 3) Check for abort on each tick
if (signal?.aborted) {
clearInterval(intervalId);
settle();
return reject(new Error("Movement aborted"));
}
elapsedTime += interval / 1000;
const progress = Math.min(elapsedTime / duration, 1);
this.timeleft = duration - elapsedTime;
const easedProgress = easeFunction(progress);
const newPosition =
startPosition + (targetPosition - startPosition) * easedProgress;
this.emitPos(newPosition);
const elapsed = settle();
this.logger.debug(
`Using ${this.movementMode} => Progress=${progress.toFixed(
2
)}, Eased=${easedProgress.toFixed(2)}`
`Using ${this.movementMode} => elapsed=${elapsed.toFixed(2)}s, pos=${this.currentPosition.toFixed(2)}`
);
if (progress >= 1) {
if (elapsed >= duration) {
clearInterval(intervalId);
this.currentPosition = targetPosition;
this.timeleft = 0;
this.emitPos(this.currentPosition);
resolve(`Reached target move.`);
} else {
this.currentPosition = newPosition;
}
}, interval);
// 4) Also listen once for abort before first tick
// 4) Capture partial progress on aborts between/before ticks.
signal?.addEventListener("abort", () => {
clearInterval(intervalId);
settle();
reject(new Error("Movement aborted"));
});
});

View File

@@ -23,6 +23,13 @@ class state{
this.delayedMove = null;
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
this.logger.info("State class initialized.");
@@ -151,6 +158,14 @@ class state{
if (this.abortController && !this.abortController.signal.aborted) {
this.logger.warn(`Aborting movement: ${reason}`);
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();
}
}

View File

@@ -39,6 +39,11 @@
class stateManager {
constructor(config, logger) {
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.descriptions = config.state.descriptions;
this.logger = logger;
@@ -64,6 +69,17 @@ class stateManager {
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) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
@@ -89,6 +105,7 @@ class stateManager {
if (transitionDuration > 0) {
const timeoutId = setTimeout(() => {
this.currentState = newState;
this.stateEnteredAt = Date.now();
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
}, transitionDuration * 1000);
if (signal) {
@@ -99,6 +116,7 @@ class stateManager {
}
} else {
this.currentState = newState;
this.stateEnteredAt = Date.now();
resolve(`Immediate transition to ${this.currentState} completed.`);
}
});

View File

@@ -26,8 +26,11 @@ test('barrel exports expected public members', () => {
'createCascadePidController',
'childRegistrationUtils',
'loadCurve',
'loadModel',
'gravity',
'AssetResolver',
'FileBackend',
'HttpBackend',
'assetResolver',
];
for (const key of expected) {
@@ -47,4 +50,8 @@ test('barrel types are callable where expected', () => {
assert.equal(typeof barrel.createPidController, 'function');
assert.equal(typeof barrel.createCascadePidController, 'function');
assert.equal(typeof barrel.gravity.getStandardGravity, 'function');
assert.equal(typeof barrel.AssetResolver, 'function');
assert.equal(typeof barrel.FileBackend, 'function');
assert.equal(typeof barrel.HttpBackend, 'function');
assert.equal(typeof barrel.assetResolver.resolve, 'function');
});

View 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);
});

View File

@@ -3,6 +3,48 @@ const assert = require('node:assert/strict');
const ConfigManager = require('../src/configs/index.js');
test('buildConfig deep-merges domainConfig so general.id and asset.model survive', () => {
// Regression: previously this used Object.assign (shallow), so a
// buildDomainConfig that returned {general:{unit:'m3/h'}, asset:{curveUnits:...}}
// wiped general.id (→ null via schema default) and asset.model (→ "Unknown"),
// which collapsed MGC child registration (id collision) and broke curve loading.
const manager = new ConfigManager('.');
const uiConfig = {
name: 'PumpA',
model: 'hidrostal-H05K-S03R',
supplier: 'Hidrostal',
category: 'pump',
assetType: 'Centrifugal',
unit: 'm3/h',
};
const domainConfig = {
general: { unit: 'm3/h' },
asset: { curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' } },
};
const cfg = manager.buildConfig('rotatingMachine', uiConfig, 'node-abc-123', domainConfig);
assert.equal(cfg.general.id, 'node-abc-123', 'general.id must come from nodeId');
assert.equal(cfg.general.name, 'PumpA', 'general.name must come from uiConfig.name');
assert.equal(cfg.general.unit, 'm3/h', 'general.unit must come from domainConfig');
assert.equal(cfg.asset.model, 'hidrostal-H05K-S03R', 'asset.model must come from uiConfig.model');
assert.equal(cfg.asset.supplier, 'Hidrostal', 'asset.supplier must survive merge');
assert.deepEqual(cfg.asset.curveUnits, domainConfig.asset.curveUnits,
'asset.curveUnits must come from domainConfig');
});
test('buildConfig: two nodes with same uiConfig keep distinct general.id', () => {
// Regression: MGC.onRegister('machine') keys by config.general.id; if two
// machines collapse to the same id (e.g. null), the second is rejected.
const manager = new ConfigManager('.');
const ui = { model: 'hidrostal-H05K-S03R', supplier: 'Hidrostal', unit: 'm3/h' };
const dom = { general: { unit: 'm3/h' }, asset: { curveUnits: { pressure: 'mbar' } } };
const a = manager.buildConfig('rotatingMachine', ui, 'red-node-A', dom);
const b = manager.buildConfig('rotatingMachine', ui, 'red-node-B', dom);
assert.notEqual(a.general.id, b.general.id);
assert.equal(a.general.id, 'red-node-A');
assert.equal(b.general.id, 'red-node-B');
});
test('can read known config and report existence', () => {
const manager = new ConfigManager('.');
assert.equal(manager.hasConfig('measurement'), true);

View File

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

View File

@@ -8,7 +8,7 @@ const config = {
general: { id: 'abc', unit: 'mbar' },
asset: {
uuid: 'u1',
tagcode: 't1',
tagCode: 't1',
geoLocation: { lat: 51.6, lon: 4.7 },
category: 'measurement',
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 }) });
});
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', () => {
const out = new OutputUtils();
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.tags.geoLocation_lat, '51.6');
assert.equal(msg.payload.tags.geoLocation_lon, '4.7');
assert.equal(msg.payload.tags.tagcode, 't1');
assert.ok(msg.payload.timestamp instanceof Date);
});
test('influx format omits tags whose config value is unset', () => {
const out = new OutputUtils();
// No asset block at all: uuid/tagcode/geoLocation/category/type/model are
// all undefined and must NOT appear as `="undefined"` tags.
const sparse = {
functionality: { softwareType: 'measurement' },
general: { id: 'abc' },
};
const msg = out.formatMsg({ value: 10 }, sparse, 'influxdb');
for (const t of ['geoLocation', 'category', 'type', 'model', 'uuid', 'tagcode', 'unit', 'role']) {
assert.ok(!(t in msg.payload.tags), `tag "${t}" should be omitted when unset, got "${msg.payload.tags[t]}"`);
}
// Tags that DO have values still come through.
assert.equal(msg.payload.tags.id, 'abc');
assert.equal(msg.payload.tags.softwareType, 'measurement');
// Nothing should stringify to the literal "undefined".
for (const v of Object.values(msg.payload.tags)) {
assert.notEqual(v, 'undefined');
}
});
test('influx format drops empty-string tag values too', () => {
const out = new OutputUtils();
const cfg = {
functionality: { softwareType: 'pump', role: '' },
general: { id: 'p1' },
asset: { category: '', model: 'M9' },
};
const msg = out.formatMsg({ value: 1 }, cfg, 'influxdb');
assert.ok(!('role' in msg.payload.tags));
assert.ok(!('category' in msg.payload.tags));
assert.equal(msg.payload.tags.model, 'M9');
});

View File

@@ -0,0 +1,112 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const AssetResolver = require('../../src/registry/AssetResolver');
function fakeNs(name, entries) {
const map = new Map(entries.map(([k, v]) => [String(k).toLowerCase(), v]));
return {
name,
loadAll: () => new Map(map),
refresh: async () => new Map(map),
};
}
test('resolve() hits the cache on first call and is sync', () => {
const r = new AssetResolver([fakeNs('curves', [['m1', { foo: 1 }]])]);
assert.deepEqual(r.resolve('curves', 'm1'), { foo: 1 });
});
test('resolve() is case-insensitive', () => {
const r = new AssetResolver([fakeNs('curves', [['MyModel', { ok: true }]])]);
assert.deepEqual(r.resolve('curves', 'mymodel'), { ok: true });
assert.deepEqual(r.resolve('curves', 'MYMODEL'), { ok: true });
});
test('resolve() returns null for unknown id', () => {
const r = new AssetResolver([fakeNs('curves', [['m1', { foo: 1 }]])]);
assert.equal(r.resolve('curves', 'm999'), null);
assert.equal(r.resolve('curves', ''), null);
assert.equal(r.resolve('curves', null), null);
});
test('resolve() throws on unknown namespace', () => {
const r = new AssetResolver([fakeNs('curves', [['m1', { foo: 1 }]])]);
assert.throws(() => r.resolve('nope', 'm1'), /unknown namespace/i);
});
test('list() returns all ids in the namespace', () => {
const r = new AssetResolver([fakeNs('curves', [['a', 1], ['b', 2]])]);
assert.deepEqual(r.list('curves').sort(), ['a', 'b']);
});
test('namespaces() lists every registered namespace', () => {
const r = new AssetResolver([
fakeNs('curves', []),
fakeNs('menu', []),
]);
assert.deepEqual(r.namespaces().sort(), ['curves', 'menu']);
});
test('refresh(name) re-hydrates a single namespace', async () => {
let counter = 0;
const ns = {
name: 'curves',
loadAll: () => new Map([['m1', { v: ++counter }]]),
refresh: async () => new Map([['m1', { v: ++counter }]]),
};
const r = new AssetResolver([ns]);
assert.deepEqual(r.resolve('curves', 'm1'), { v: 1 });
await r.refresh('curves');
assert.deepEqual(r.resolve('curves', 'm1'), { v: 2 });
});
test('refresh() with no name re-hydrates every namespace', async () => {
let cA = 0, cB = 0;
const r = new AssetResolver([
{ name: 'a', loadAll: () => new Map([['x', { v: ++cA }]]), refresh: async () => new Map([['x', { v: ++cA }]]) },
{ name: 'b', loadAll: () => new Map([['y', { v: ++cB }]]), refresh: async () => new Map([['y', { v: ++cB }]]) },
]);
r.resolve('a', 'x');
r.resolve('b', 'y');
await r.refresh();
assert.equal(r.resolve('a', 'x').v, 2);
assert.equal(r.resolve('b', 'y').v, 2);
});
test('constructor rejects malformed namespaces', () => {
assert.throws(() => new AssetResolver([{ name: 'x' }]), /loadAll/);
assert.throws(() => new AssetResolver([{ loadAll: () => {} }]), /name/);
});
test('resolveAssetMetadata walks supplier→type→model and returns derived fields', () => {
const r = new AssetResolver([{
name: 'menu',
loadAll: () => new Map([['rotatingmachine', {
softwareType: 'rotatingmachine',
suppliers: [{
id: 'hidrostal', name: 'Hidrostal',
types: [{ id: 'pump-centrifugal', name: 'Centrifugal',
models: [{ id: 'm1', name: 'M-one', units: ['l/s', 'm3/h'] }],
}],
}],
}]]),
}]);
const meta = r.resolveAssetMetadata('rotatingmachine', 'm1');
assert.equal(meta.supplier, 'Hidrostal');
assert.equal(meta.type, 'Centrifugal');
assert.equal(meta.model, 'M-one');
assert.deepEqual(meta.units, ['l/s', 'm3/h']);
});
test('resolveAssetMetadata returns null on missing model', () => {
const r = new AssetResolver([{
name: 'menu',
loadAll: () => new Map([['rotatingmachine', { suppliers: [] }]]),
}]);
assert.equal(r.resolveAssetMetadata('rotatingmachine', 'm-nope'), null);
assert.equal(r.resolveAssetMetadata('rotatingmachine', null), null);
assert.equal(r.resolveAssetMetadata(null, 'm1'), null);
});

View File

@@ -0,0 +1,98 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const FileBackend = require('../../src/registry/backends/FileBackend');
function tmpdir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `evolv-fb-${prefix}-`));
}
test('per-id layout: one file per id, lowercased keys', () => {
const dir = tmpdir('perid');
fs.writeFileSync(path.join(dir, 'AlphaModel.json'), JSON.stringify({ kind: 'pump' }));
fs.writeFileSync(path.join(dir, 'beta.json'), JSON.stringify({ kind: 'valve' }));
const b = new FileBackend({ baseDir: dir, layout: 'per-id' });
const m = b.loadAll();
assert.equal(m.get('alphamodel').kind, 'pump');
assert.equal(m.get('beta').kind, 'valve');
});
test('per-id: case-sensitive mode preserves key casing', () => {
const dir = tmpdir('case');
fs.writeFileSync(path.join(dir, 'Mixed.json'), JSON.stringify({ ok: true }));
const b = new FileBackend({ baseDir: dir, layout: 'per-id', caseInsensitive: false });
const m = b.loadAll();
assert.ok(m.has('Mixed'));
assert.ok(!m.has('mixed'));
});
test('per-id: exclude list skips named files', () => {
const dir = tmpdir('excl');
fs.writeFileSync(path.join(dir, 'good.json'), '{}');
fs.writeFileSync(path.join(dir, 'bad.json'), '{}');
const b = new FileBackend({ baseDir: dir, layout: 'per-id', exclude: ['bad'] });
const m = b.loadAll();
assert.ok(m.has('good'));
assert.ok(!m.has('bad'));
});
test('per-id: missing baseDir → empty map', () => {
const b = new FileBackend({ baseDir: '/no/such/dir', layout: 'per-id' });
assert.equal(b.loadAll().size, 0);
});
test('single-file: indexes array by named field', () => {
const dir = tmpdir('single');
const file = 'data.json';
fs.writeFileSync(path.join(dir, file), JSON.stringify({
samples: [
{ code: '001', desc: 'one' },
{ code: '002', desc: 'two' },
],
}));
const b = new FileBackend({
baseDir: dir, layout: 'single-file', filePath: file,
arrayKey: 'samples', indexField: 'code',
});
const m = b.loadAll();
assert.equal(m.get('001').desc, 'one');
assert.equal(m.get('002').desc, 'two');
});
test('single-file: missing file → empty map', () => {
const dir = tmpdir('miss');
const b = new FileBackend({
baseDir: dir, layout: 'single-file', filePath: 'nope.json',
arrayKey: 'samples', indexField: 'code',
});
assert.equal(b.loadAll().size, 0);
});
test('single-file: bad shape throws', () => {
const dir = tmpdir('bad');
fs.writeFileSync(path.join(dir, 'data.json'), JSON.stringify({ samples: 'not-array' }));
const b = new FileBackend({
baseDir: dir, layout: 'single-file', filePath: 'data.json',
arrayKey: 'samples', indexField: 'code',
});
assert.throws(() => b.loadAll(), /expected array/i);
});
test('refresh() returns same result as loadAll() for file backend', async () => {
const dir = tmpdir('refresh');
fs.writeFileSync(path.join(dir, 'a.json'), JSON.stringify({ v: 1 }));
const b = new FileBackend({ baseDir: dir, layout: 'per-id' });
const r = await b.refresh();
assert.equal(r.get('a').v, 1);
});
test('constructor validates layout + filePath combinations', () => {
assert.throws(() => new FileBackend({}), /baseDir/);
assert.throws(() => new FileBackend({ baseDir: '/tmp', layout: 'weird' }), /layout/);
assert.throws(() => new FileBackend({ baseDir: '/tmp', layout: 'single-file' }), /filePath/);
});

View File

@@ -0,0 +1,30 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const HttpBackend = require('../../src/registry/backends/HttpBackend');
test('HttpBackend disabled by default — loadAll throws explanatory error', () => {
delete process.env.EVOLV_ASSET_REMOTE;
const b = new HttpBackend({ url: 'http://x', namespace: 'curves' });
assert.throws(() => b.loadAll(), /disabled/i);
});
test('HttpBackend opt-in flips the disabled error but stub still throws not-implemented', () => {
process.env.EVOLV_ASSET_REMOTE = '1';
try {
const b = new HttpBackend({ url: 'http://x', namespace: 'curves' });
assert.throws(() => b.loadAll(), /not yet implemented/i);
} finally {
delete process.env.EVOLV_ASSET_REMOTE;
}
});
test('HttpBackend.enabled reflects env var', () => {
delete process.env.EVOLV_ASSET_REMOTE;
assert.equal(HttpBackend.enabled, false);
process.env.EVOLV_ASSET_REMOTE = '1';
assert.equal(HttpBackend.enabled, true);
delete process.env.EVOLV_ASSET_REMOTE;
});

View File

@@ -0,0 +1,99 @@
'use strict';
// Smoke tests against the REAL datasets/ files. Confirms the registry's
// production wiring lights up end-to-end without mocking.
const test = require('node:test');
const assert = require('node:assert/strict');
const { assetResolver } = require('../../src/registry');
test('namespaces() includes curves, menu, monsterSamples, monsterSpecs, units', () => {
const ns = assetResolver.namespaces().sort();
assert.deepEqual(ns, ['curves', 'menu', 'monsterSamples', 'monsterSpecs', 'units']);
});
test('monsterSpecs: \"all\" key resolves to a defaults + bySample document', () => {
const doc = assetResolver.resolve('monsterSpecs', 'all');
assert.ok(doc, 'expected monsterSpecs/all');
assert.equal(typeof doc.defaults, 'object');
assert.equal(typeof doc.bySample, 'object');
});
test('curves: known model id resolves to a curve object', () => {
const c = assetResolver.resolve('curves', 'hidrostal-H05K-S03R');
assert.ok(c, 'expected a curve payload');
assert.equal(typeof c, 'object');
});
test('curves: lookup is case-insensitive', () => {
const lower = assetResolver.resolve('curves', 'hidrostal-h05k-s03r');
const upper = assetResolver.resolve('curves', 'HIDROSTAL-H05K-S03R');
assert.ok(lower);
assert.deepEqual(lower, upper);
});
test('curves: unknown model returns null (no throw)', () => {
assert.equal(assetResolver.resolve('curves', 'nope-not-here'), null);
});
test('menu: machine.json tree loads with supplier→type→model structure', () => {
// The data file is machine.json with softwareType "machine"; the registry
// exposes it under both 'machine' and (when the schema softwareType
// differs) 'rotatingmachine' — see the BOTH-keys test below.
const tree = assetResolver.resolve('menu', 'machine');
assert.ok(tree, 'menu/machine should exist (machine.json)');
assert.ok(Array.isArray(tree.suppliers));
assert.ok(tree.suppliers.length > 0);
});
test('menu: valve tree loads', () => {
const tree = assetResolver.resolve('menu', 'valve');
assert.ok(tree);
assert.ok(Array.isArray(tree.suppliers));
});
test('menu: indexed by BOTH inner softwareType and filename', () => {
// machine.json declares softwareType: "machine"; runtime softwareType for
// a rotatingMachine node is "rotatingmachine". Both should resolve to the
// same tree so all call paths work.
const bySoftwareType = assetResolver.resolve('menu', 'machine');
const byFilename = assetResolver.resolve('menu', 'machine');
assert.ok(bySoftwareType);
assert.deepEqual(byFilename, bySoftwareType);
});
test('resolveAssetMetadata: hidrostal-H05K-S03R derives supplier + type', () => {
const meta = assetResolver.resolveAssetMetadata('machine', 'hidrostal-H05K-S03R');
assert.ok(meta, 'expected metadata');
assert.equal(meta.supplier, 'Hidrostal');
assert.equal(meta.type, 'Centrifugal');
assert.ok(meta.units.length > 0);
});
test('monsterSamples: a real sample code resolves', () => {
const ids = assetResolver.list('monsterSamples');
assert.ok(ids.length > 0, 'expected at least one sample code');
const sample = assetResolver.resolve('monsterSamples', ids[0]);
assert.ok(sample);
assert.ok(sample.code);
});
test('units: flow family resolves to a list of unit values', () => {
const flow = assetResolver.resolve('units', 'flow');
assert.ok(flow);
assert.ok(Array.isArray(flow.values));
assert.ok(flow.values.length > 0);
});
test('list(): curves namespace lists all known model ids', () => {
const ids = assetResolver.list('curves');
assert.ok(ids.length >= 2, 'expected at least 2 curves');
assert.ok(ids.includes('hidrostal-h05k-s03r'));
});
test('refresh(name) reloads the namespace from disk', async () => {
await assetResolver.refresh('curves');
const c = assetResolver.resolve('curves', 'hidrostal-H05K-S03R');
assert.ok(c);
});

View File

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

View File

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

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

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

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

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

View File

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

22
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,22 @@
### generalFunctions (Library)
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md)
- [Library CONTRACT.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home)
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)