24 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
znetsixe
c7e561e593 wiki: add Home.md for generalFunctions library
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:07:54 +02:00
znetsixe
f21e2aa8bb P11.3 + P11.4: BaseNodeAdapter query.units + wikiGen Unit column
P11.3 BaseNodeAdapter auto-wires query.units:
  Implicit query.units topic registered if subclass commands don't
  already declare one. Returns {node, units: {topic → {measure,
  default, accepted: [...]}}} via convert.possibilities. Subclass
  query.units overrides. 17/17 tests; BaseNodeAdapter.js 211 lines.

P11.4 wikiGen Unit column:
  Auto-generated topic-contract table grows a Unit column showing
  `<measure> (default <unit>)` for topics with units, '—' otherwise.
  Effect column now uses descriptor.description when present (P11.2
  field), falls back to generic per-prefix sentence. wikiGen.js 303
  → 315 lines. WIKI_TEMPLATE.md §5 sample updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:33:26 +02:00
znetsixe
5ea968eabc B2.3 + P11.1 + P11.2 + monster schema fix
B2.3 LatestWinsGate fireAndWait:
  Added fireAndWait(value, ctx?) returning per-fire settlement promise.
  Supersede resolves with frozen sentinel {superseded: true} (no
  rejection — callers branch on value without try/catch). Dispatch
  errors also resolve (with undefined); error surfaces via gate.lastError.
  LatestWinsGate.js 75 → 116 lines. 12/12 tests pass.

P11.1 convert.possibilities(measure):
  New helper returning sorted+deduped unit names for a measure.
  Cached per measure. Reuses existing convert measures map. Also
  exposed convert.measures() listing all known measures.
  convert/index.js +21 lines. New test file: 90 lines, 12/12 tests.

P11.2 commandRegistry.units field:
  Pre-dispatch normalisation pipeline. descriptor.units = {measure,
  default}; commandRegistry extracts msg.payload + msg.unit (3 shapes),
  validates against measure, converts to default, falls back + warns
  with accepted-list on unknown/wrong-measure. Falls back gracefully
  if convert.possibilities is missing. commandRegistry.js 164 → 237.
  +7 new tests covering all 4 paths.

monster schema fix (P11.2 sibling):
  generalFunctions/src/configs/monster.json was stripping four
  legitimate constraint keys (nominalFlowMin, flowMax, maxRainRef,
  minSampleIntervalSec). Added them with defaults matching the
  legacy nodeClass coercion. Side effect: this also UNBLOCKED the
  monster cooldown-guard test (separate ROOT-CAUSE entry below).

CONTRACTS.md §4 + §8 updated. 144/144 basic tests + 206/206 full
generalFunctions tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:29:14 +02:00
znetsixe
f11754635b B3.1 + B3.2 + B3.3: ChildRouter fan-out + commandRegistry 'none' + UnitPolicy dual-shape
B3.1 ChildRouter per-listener fan-out (drop emit monkey-patch):
  Partial-filter subscriptions enumerate every concrete
  <type>.measured.<position> event name (cartesian product over
  the canonical POSITIONS list + 19 KNOWN_TYPES) and register a
  plain emitter.on() per combo. Multi-parent semantics are trivial:
  each ChildRouter's listeners are independent. Drop the wrap/unwrap
  bookkeeping in tearDown. ChildRouter.js 184→164 lines.

B3.2 commandRegistry 'none' + description:
  Add 'none' to payloadSchema.type — handler still fires; logs warn
  if msg.payload is non-empty (catches accidental passes). Add
  optional `description` field per descriptor; surfaced via .list()
  so wikiGen can render per-topic effect text.
  commandRegistry.js 157→164 lines. 23/23 tests pass.

B3.3 UnitPolicy dual-shape:
  policy.canonical/output/curve are now BOTH callable methods AND
  frozen property bags. policy.canonical('flow') === 'm3/s' and
  policy.canonical.flow === 'm3/s' both work. Property bags are
  frozen (assign/delete/redefine throw in strict). Drops the
  _unitView workaround in MGC + rotatingMachine specificClass.
  UnitPolicy.js 149→163 lines, 15/15 tests pass.

CONTRACTS.md §4 + §6 updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:13:15 +02:00
znetsixe
ff9aec8702 P10.5: fix 4 pre-existing test failures (output + validation)
outputUtils.formatMsg: fall back to \`<softwareType>_<id>\` when
config.general.name is unset — restores the convention the original
test expected; safer for nodes whose name isn't required at registration.

collectionValidators.validateArray + validateSet: replace \`|| 1\` with
\`?? 1\` so explicit minLength: 0 lets through empty arrays. Was
swallowing the 0 as falsy and clamping minimum to 1.

validationUtils: add public validateCurve wrapper around the helper so
callers can validate raw curves without going through validateSchema.

Test suite: 170 total / 170 pass (was 4 fail).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:13 +02:00
znetsixe
30c5dc8508 P9.2: add scripts/wikiGen.js (shared wiki auto-gen for all nodes)
Two subcommands consumed by each node's package.json wiki:* scripts:
  contract <commands.js>    — generates the topic-contract markdown table
  datamodel <specific.js>   — instantiates the domain + walks getOutput()

Both can splice between <!-- BEGIN/END AUTOGEN: topic-contract|data-model -->
markers in a target file via --write <path>, or print to stdout.

First consumer: pumpingStation (wired in its package.json wiki:contract
+ wiki:datamodel + wiki:all). Other 11 nodes will wire when each gets
its first wiki page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:44 +02:00
82 changed files with 5697 additions and 2818 deletions

9
.gitignore vendored
View File

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

28
.npmignore Normal file
View File

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

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'); 'use strict';
const path = require('path');
// 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 { class AssetCategoryManager {
constructor(relPath = '.') { // relPath is retained for signature compatibility with the prior on-disk
this.assetDir = path.resolve(__dirname, relPath); // implementation; it is unused now — the resolver owns file locations.
this.cache = new Map(); constructor(/* relPath = '.' */) {}
}
getCategory(softwareType) { getCategory(softwareType) {
if (!softwareType) { if (!softwareType) {
throw new Error('softwareType is required'); 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;
} }
const data = assetResolver.resolve('menu', softwareType);
const data = this.getCategory(name); if (!data) {
return { throw new Error(`Asset data '${softwareType}' not found in menu namespace`);
softwareType: data.softwareType || name, }
label: data.label || name, return data;
file: `${name}.json`
};
});
}
searchCategories(query) {
const term = (query || '').trim().toLowerCase();
if (!term) {
return [];
} }
return this.listCategories({ withMeta: true }).filter( hasCategory(softwareType) {
({ softwareType, label }) => if (!softwareType) return false;
softwareType.toLowerCase().includes(term) || return assetResolver.resolve('menu', softwareType) != null;
label.toLowerCase().includes(term) }
);
}
clearCache() { listCategories({ withMeta = false } = {}) {
this.cache.clear(); // 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(); const assetCategoryManager = new AssetCategoryManager();
module.exports = { module.exports = {
AssetCategoryManager, AssetCategoryManager,
assetCategoryManager, assetCategoryManager,
getCategory: (softwareType) => assetCategoryManager.getCategory(softwareType), getCategory: (softwareType) => assetCategoryManager.getCategory(softwareType),
listCategories: (options) => assetCategoryManager.listCategories(options), listCategories: (options) => assetCategoryManager.listCategories(options),
searchCategories: (query) => assetCategoryManager.searchCategories(query), searchCategories: (query) => assetCategoryManager.searchCategories(query),
hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType), hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType),
clearCache: () => assetCategoryManager.clearCache() 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 MenuManager = require('./src/menu/index.js');
const { predict, interpolation } = require('./src/predict/index.js'); const { predict, interpolation } = require('./src/predict/index.js');
const { PIDController, CascadePIDController, createPidController, createCascadePidController } = require('./src/pid/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 { AssetResolver, FileBackend, HttpBackend, assetResolver } = require('./src/registry/index.js');
const { loadModel } = require('./datasets/assetData/modelData/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 { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js');
const Fysics = require('./src/convert/fysics.js'); const Fysics = require('./src/convert/fysics.js');
@@ -72,8 +79,7 @@ module.exports = {
createPidController, createPidController,
createCascadePidController, createCascadePidController,
childRegistrationUtils, childRegistrationUtils,
loadCurve, //deprecated replace with loadModel loadCurve,
loadModel,
gravity, gravity,
POSITIONS, POSITIONS,
POSITION_VALUES, POSITION_VALUES,
@@ -90,5 +96,12 @@ module.exports = {
createRegistry, createRegistry,
CommandRegistry, CommandRegistry,
BaseNodeAdapter, 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,
}; };

315
scripts/wikiGen.js Normal file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env node
'use strict';
/**
* wikiGen.js — shared wiki auto-generation helper for every EVOLV node.
*
* Two subcommands:
*
* node wikiGen.js contract <commands-module> [--write <wiki-path>]
* node wikiGen.js datamodel <specificClass-module> [--write <wiki-path>]
*
* `contract` walks the descriptor array exported by `src/commands/index.js`
* and emits a markdown table mapping canonical topic → aliases → payload
* schema → effect description.
*
* `datamodel` instantiates the domain with a minimal stub config, calls
* `getOutput()` once, and emits a markdown table of (key, type, sample value).
* If construction fails (because the domain needs a live runtime that isn't
* trivially stubbable), the script falls back to a hand-curated partial at
* `<repo>/wiki/_partial-datamodel.md.template` instead of crashing.
*
* When `--write <wiki-path>` is given, the output is spliced between the
* matching `<!-- BEGIN AUTOGEN: <marker> -->` / `<!-- END AUTOGEN: ... -->`
* markers in that file. Otherwise it prints to stdout.
*
* See `.claude/refactor/WIKI_TEMPLATE.md` (sections 5 and 8) and CONTRACTS.md
* for the canonical topic naming and registry shape this script consumes.
*/
const fs = require('fs');
const path = require('path');
// ── CLI parsing ────────────────────────────────────────────────────────────
function parseArgs(argv) {
const [, , subcmd, target, ...rest] = argv;
const opts = { subcmd, target, write: null };
for (let i = 0; i < rest.length; i++) {
if (rest[i] === '--write' && rest[i + 1]) {
opts.write = rest[i + 1];
i++;
}
}
return opts;
}
function usage() {
process.stderr.write([
'Usage:',
' node wikiGen.js contract <path-to-commands/index.js> [--write <wiki-path>]',
' node wikiGen.js datamodel <path-to-specificClass.js> [--write <wiki-path>]',
'',
].join('\n'));
}
// ── Shared helpers ─────────────────────────────────────────────────────────
function resolveAbs(p) {
return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
}
function describeSchema(schema) {
if (!schema) return '_unspecified_';
const t = schema.type;
if (!t) return '_unspecified_';
if (t === 'any') return '`any`';
if (t === 'object') {
const props = schema.properties || {};
const keys = Object.keys(props);
if (!keys.length) return '`object`';
const parts = keys.map((k) => {
const subType = props[k]?.type ?? 'any';
return `${k}:${subType}`;
});
return '`{ ' + parts.join(', ') + ' }`';
}
return '`' + t + '`';
}
function topicEffectFallback(topic) {
// Try to derive a short, plain-English effect from the canonical topic
// when the descriptor doesn't carry a description field. Keep it terse —
// a maintainer can override by adding `description` to the descriptor.
const prefixes = {
'set.': 'Replaces the named state value with the supplied payload.',
'cmd.': 'Triggers an action / sequence — not idempotent.',
'data.': 'Pushes a value into the node\'s measurement stream.',
'query.': 'Read-only query; node replies on the same msg.',
'child.': 'Parent/child plumbing — registers or unregisters a child node.',
};
for (const [pfx, line] of Object.entries(prefixes)) {
if (topic.startsWith(pfx)) return line;
}
return '_(see handler)_';
}
function spliceAutogen(filePath, marker, body) {
const begin = `<!-- BEGIN AUTOGEN: ${marker} -->`;
const end = `<!-- END AUTOGEN: ${marker} -->`;
if (!fs.existsSync(filePath)) {
throw new Error(`wikiGen: --write target '${filePath}' does not exist`);
}
const src = fs.readFileSync(filePath, 'utf8');
const bIdx = src.indexOf(begin);
const eIdx = src.indexOf(end);
if (bIdx < 0 || eIdx < 0 || eIdx < bIdx) {
throw new Error(`wikiGen: markers '${marker}' not found in ${filePath}`);
}
const before = src.slice(0, bIdx + begin.length);
const after = src.slice(eIdx);
const out = before + '\n\n' + body.trimEnd() + '\n\n' + after;
fs.writeFileSync(filePath, out, 'utf8');
}
// ── Subcommand: contract ───────────────────────────────────────────────────
function describeUnits(units) {
// Descriptor.units is the validated `{ measure, default }` pair the
// commandRegistry stores; render it as `<measure> (default <unit>)` so
// a reader sees both the dimension and the canonical default that the
// node coerces to. Em-dash for unit-less topics keeps the column tidy.
if (!units || typeof units !== 'object') return '—';
const { measure, default: def } = units;
if (!measure || !def) return '—';
return '`' + measure + '` (default `' + def + '`)';
}
function renderContract(commandsPath) {
const abs = resolveAbs(commandsPath);
// eslint-disable-next-line import/no-dynamic-require, global-require
const registry = require(abs);
if (!Array.isArray(registry)) {
throw new Error(`wikiGen contract: ${abs} does not export an array of descriptors`);
}
const lines = [];
lines.push('| Canonical topic | Aliases | Payload | Unit | Effect |');
lines.push('|---|---|---|---|---|');
for (const d of registry) {
const topic = '`' + d.topic + '`';
const aliases = (d.aliases && d.aliases.length)
? d.aliases.map((a) => '`' + a + '`').join(', ')
: '_(none)_';
const payload = describeSchema(d.payloadSchema);
const unit = describeUnits(d.units);
const effect = d.description ? String(d.description) : topicEffectFallback(d.topic);
lines.push(`| ${topic} | ${aliases} | ${payload} | ${unit} | ${effect} |`);
}
return lines.join('\n');
}
// ── Subcommand: datamodel ──────────────────────────────────────────────────
function inferSampleType(v) {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v;
}
function trySampleValue(v) {
if (v === null || v === undefined) return '`null`';
const t = typeof v;
if (t === 'number' || t === 'boolean') return '`' + String(v) + '`';
if (t === 'string') return '`"' + v + '"`';
if (Array.isArray(v)) return '`[…]`';
if (t === 'object') return '`{…}`';
return '`' + String(v) + '`';
}
// Heuristic unit map for top-level snapshot keys that aren't structured as
// MeasurementContainer keys (e.g. `heightBasin`, `surfaceArea`). Best-effort
// — the canonical place for unit semantics is the node's config schema; the
// table below is just enough to keep the auto-generated data-model readable.
const FLAT_KEY_UNITS = {
heightBasin: 'm',
basinHeight: 'm',
inflowLevel: 'm',
outflowLevel: 'm',
overflowLevel: 'm',
startLevel: 'm',
stopLevel: 'm',
minLevel: 'm',
maxLevel: 'm',
surfaceArea: 'm2',
volEmptyBasin: 'm3',
maxVol: 'm3',
maxVolAtOverflow:'m3',
minVol: 'm3',
minVolAtInflow: 'm3',
minVolAtOutflow: 'm3',
percControl: '%',
timeleft: 's',
};
function inferUnitFromKey(key) {
// MeasurementContainer-shaped keys take precedence: `{type}.{variant}.{position}.{childId}`.
const parts = key.split('.');
if (parts.length >= 3) {
const type = parts[0];
const map = {
flow: 'm3/s',
pressure: 'Pa',
power: 'W',
temperature: 'K',
level: 'm',
volume: 'm3',
volumePercent: '%',
netFlowRate: 'm3/s',
};
if (map[type]) return map[type];
}
return FLAT_KEY_UNITS[key] || '—';
}
function renderDatamodel(specificClassPath) {
const abs = resolveAbs(specificClassPath);
// eslint-disable-next-line import/no-dynamic-require, global-require
const Domain = require(abs);
// Minimum viable stub config — the BaseDomain pipeline pulls per-key
// defaults from the JSON schema, so this only needs to supply the bits
// that BaseDomain reads from `userConfig` directly.
const stubConfig = {
general: {
name: `wikiGen-${Domain.name || 'domain'}`,
id: `wikiGen-${Domain.name || 'domain'}-id`,
logging: { enabled: false, logLevel: 'error' },
},
};
// Look for a hand-curated fallback alongside the wiki. Path is
// `<node>/wiki/_partial-datamodel.md.template` relative to the
// *commands*-or-specificClass file's repo root.
const repoRoot = findRepoRoot(abs);
const fallback = repoRoot
? path.join(repoRoot, 'wiki', '_partial-datamodel.md.template')
: null;
let out;
try {
const instance = new Domain(stubConfig);
out = instance.getOutput ? instance.getOutput() : null;
if (!out || typeof out !== 'object') {
throw new Error('getOutput() returned a non-object');
}
} catch (err) {
process.stderr.write(`wikiGen datamodel: live instantiation failed: ${err.message}\n`);
if (fallback && fs.existsSync(fallback)) {
process.stderr.write(`wikiGen datamodel: using hand-curated fallback ${fallback}\n`);
return fs.readFileSync(fallback, 'utf8').trimEnd();
}
process.stderr.write('wikiGen datamodel: no hand-curated fallback found — emitting placeholder\n');
return [
'| Key | Type | Unit | Sample |',
'|---|---|---|---|',
`| _live instantiation failed; provide ${fallback ? `\`wiki/_partial-datamodel.md.template\`` : 'a hand-curated template'}_ | — | — | — |`,
].join('\n');
}
const lines = [];
lines.push('| Key | Type | Unit | Sample |');
lines.push('|---|---|---|---|');
for (const k of Object.keys(out).sort()) {
const v = out[k];
lines.push(`| \`${k}\` | ${inferSampleType(v)} | ${inferUnitFromKey(k)} | ${trySampleValue(v)} |`);
}
return lines.join('\n');
}
function findRepoRoot(startPath) {
let dir = path.dirname(startPath);
for (let i = 0; i < 8; i++) {
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
return null;
}
// ── Entry point ────────────────────────────────────────────────────────────
function main() {
const opts = parseArgs(process.argv);
if (!opts.subcmd || !opts.target) {
usage();
process.exit(1);
}
let body;
let marker;
if (opts.subcmd === 'contract') {
body = renderContract(opts.target);
marker = 'topic-contract';
} else if (opts.subcmd === 'datamodel') {
body = renderDatamodel(opts.target);
marker = 'data-model';
} else {
usage();
process.exit(1);
}
if (opts.write) {
spliceAutogen(resolveAbs(opts.write), marker, body);
process.stderr.write(`wikiGen: wrote ${marker} block into ${opts.write}\n`);
} else {
process.stdout.write(body + '\n');
}
}
if (require.main === module) {
main();
}
module.exports = { renderContract, renderDatamodel, spliceAutogen, describeSchema, describeUnits };

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": { "functionality": {
"softwareType": { "softwareType": {
"default": "diffuser", "default": "diffuser",
@@ -86,11 +102,19 @@
"description": "Number of diffuser elements in the zone." "description": "Number of diffuser elements in the zone."
} }
}, },
"density": { "membraneAreaPerElement": {
"default": 2.4, "default": null,
"rules": { "rules": {
"type": "number", "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": { "waterHeight": {

View File

@@ -117,25 +117,60 @@ class ConfigManager {
} }
}; };
// Add asset section if UI provides asset fields // Asset section is emitted per-key: only fields the editor actually
if (uiConfig.supplier || uiConfig.category || uiConfig.assetType || uiConfig.model) { // set propagate to the domain config. Schemas that omit a key (e.g.
config.asset = { // rotatingMachine deliberately drops asset.supplier/category/type
uuid: uiConfig.uuid || uiConfig.assetUuid || null, // because those come from the asset registry at runtime) no longer
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null, // get those keys injected and then stripped by ValidationUtils with
supplier: uiConfig.supplier || 'Unknown', // a warning. Empty strings from HTML defaults stay falsy → omitted →
category: uiConfig.category || 'sensor', // schema default applies.
type: uiConfig.assetType || 'Unknown', const asset = {};
model: uiConfig.model || 'Unknown', const uuid = uiConfig.uuid || uiConfig.assetUuid;
unit: uiConfig.unit || 'unitless' const tagCode = uiConfig.tagCode || uiConfig.assetTagCode;
}; if (uuid) asset.uuid = uuid;
} if (tagCode) asset.tagCode = tagCode;
if (uiConfig.supplier) asset.supplier = uiConfig.supplier;
if (uiConfig.category) asset.category = uiConfig.category;
if (uiConfig.assetType) asset.type = uiConfig.assetType;
if (uiConfig.model) asset.model = uiConfig.model;
if (uiConfig.unit) asset.unit = uiConfig.unit;
if (Object.keys(asset).length > 0) config.asset = asset;
// Merge domain-specific sections // Merge domain-specific sections. Must be a DEEP merge: domainConfig
Object.assign(config, 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; 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 * Migrate a config object from one version to another by applying
* registered migration functions in sequence. * 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." "description": "Defines the position of the measurement relative to its parent equipment or system."
} }
},
"distance": {
"default": null,
"rules": {
"type": "number",
"nullable": true,
"description": "Optional spatial offset from the parent equipment reference. Populated from the editor when hasDistance is enabled; null otherwise."
} }
},
"distanceUnit": {
"default": "m",
"rules": {
"type": "string",
"description": "Unit for the functionality.distance offset (e.g. 'm', 'cm')."
}
},
"distanceDescription": {
"default": "",
"rules": {
"type": "string",
"description": "Free-text description of what the distance offset represents."
}
}
},
"output": {
"process": {
"default": "process",
"rules": {
"type": "enum",
"values": [
{ "value": "process", "description": "Delta-compressed process message (default)." },
{ "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." }
],
"description": "Format of the process payload emitted on output port 0."
}
},
"dbase": {
"default": "influxdb",
"rules": {
"type": "enum",
"values": [
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
{ "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." }
],
"description": "Format of the telemetry payload emitted on output port 1."
}
}
},
"planner": {
"useRendezvous": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)."
}
},
"emergencyPressurePa": {
"default": null,
"rules": {
"type": "number",
"description": "Safety threshold (canonical Pa) for the rendezvous emergency bypass. While a rendezvous is in flight new setpoints are locked out and queued sequentially; if the resolved header pressure reaches this value the lock is pre-empted and the group re-plans immediately. Null/unset (the default) leaves the bypass mechanism wired but INERT — it never fires until a real threshold is configured."
}
}
}, },
"mode": { "mode": {
"current": { "current": {
@@ -107,10 +172,6 @@
"value": "priorityControl", "value": "priorityControl",
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added." "description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added."
}, },
{
"value": "prioritypercentagecontrol",
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added based on a percentage of the total demand."
},
{ {
"value": "maintenance", "value": "maintenance",
"description": "The group is in maintenance mode with limited actions (monitoring only)." "description": "The group is in maintenance mode with limited actions (monitoring only)."
@@ -140,14 +201,6 @@
"description": "Actions allowed in priorityControl mode." "description": "Actions allowed in priorityControl mode."
} }
}, },
"prioritypercentagecontrol": {
"default": ["statusCheck", "execSequentialControl", "balanceLoad", "emergencyStop"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in manualOverride mode."
}
},
"maintenance": { "maintenance": {
"default": ["statusCheck"], "default": ["statusCheck"],
"rules": { "rules": {
@@ -165,7 +218,7 @@
"rules": { "rules": {
"type": "object", "type": "object",
"schema": { "schema": {
"optimalcontrol": { "optimalControl": {
"default": ["parent", "GUI", "physical", "API"], "default": ["parent", "GUI", "physical", "API"],
"rules": { "rules": {
"type": "set", "type": "set",
@@ -173,7 +226,7 @@
"description": "Command sources allowed in optimalControl mode." "description": "Command sources allowed in optimalControl mode."
} }
}, },
"prioritycontrol": { "priorityControl": {
"default": ["parent", "GUI", "physical", "API"], "default": ["parent", "GUI", "physical", "API"],
"rules": { "rules": {
"type": "set", "type": "set",
@@ -181,36 +234,17 @@
"description": "Command sources allowed in priorityControl mode." "description": "Command sources allowed in priorityControl mode."
} }
}, },
"prioritypercentagecontrol": { "maintenance": {
"default": ["parent", "GUI", "physical", "API"], "default": ["parent", "GUI"],
"rules": { "rules": {
"type": "set", "type": "set",
"itemType": "string", "itemType": "string",
"description": "Command sources allowed " "description": "Command sources allowed in maintenance mode. Status/inspection only — physical/HMI and API writes are dropped."
} }
} }
}, },
"description": "Specifies the valid command sources recognized by the machine group controller for each mode." "description": "Specifies the valid command sources recognized by the machine group controller for each mode."
} }
} }
},
"scaling": {
"current": {
"default": "normalized",
"rules": {
"type": "enum",
"values": [
{
"value": "normalized",
"description": "Scales the demand between 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, "default": null,
"rules": { "rules": {
"type": "number", "type": "number",
"nullable": true,
"description": "Defines the position of the measurement relative to its parent equipment or system." "description": "Defines the position of the measurement relative to its parent equipment or system."
} }
} }
}, },
"output": {
"process": {
"default": "process",
"rules": {
"type": "enum",
"values": [
{ "value": "process", "description": "Delta-compressed process message (default)." },
{ "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." }
],
"description": "Format of the process payload emitted on output port 0."
}
},
"dbase": {
"default": "influxdb",
"rules": {
"type": "enum",
"values": [
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
{ "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." }
],
"description": "Format of the telemetry payload emitted on output port 1."
}
}
},
"asset": { "asset": {
"uuid": { "uuid": {
"default": null, "default": null,
@@ -439,6 +467,16 @@
"description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode." "description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode."
} }
}, },
"calibration": {
"stabilityThreshold": {
"default": 0.01,
"rules": {
"type": "number",
"min": 0,
"description": "Absolute standard-deviation ceiling (in scaling-units, i.e. the same range as absMin..absMax) below which the rolling window is considered stable enough to trust for calibration / repeatability. A buffer with stdDev <= threshold is treated as stable; anything above aborts calibrate() and evaluateRepeatability() with a warning. Default 0.01 fits the [50,100] absMin/absMax default range; tighten or relax to match your sensor's expected noise floor."
}
}
},
"outlierDetection": { "outlierDetection": {
"enabled": { "enabled": {
"default": false, "default": false,
@@ -475,4 +513,4 @@
} }
} }
} }
} }

View File

@@ -251,6 +251,34 @@
"type": "number", "type": "number",
"description": "Minimum inner diameter of the intake tubing in millimeters." "description": "Minimum inner diameter of the intake tubing in millimeters."
} }
},
"nominalFlowMin": {
"default": 0,
"rules": {
"type": "number",
"description": "Lower bound of expected inflow rate (m3/h). Used together with flowMax to scale the rain-driven flow prediction."
}
},
"flowMax": {
"default": 0,
"rules": {
"type": "number",
"description": "Upper bound of expected inflow rate (m3/h). Used together with nominalFlowMin to scale the rain-driven flow prediction."
}
},
"maxRainRef": {
"default": 10,
"rules": {
"type": "number",
"description": "Reference rain index that maps to the flowMax end of the prediction band."
}
},
"minSampleIntervalSec": {
"default": 60,
"rules": {
"type": "number",
"description": "Cooldown between consecutive sample pulses (seconds). Pulses raised faster than this are recorded as missedSamples."
}
} }
} }
} }

View File

@@ -166,6 +166,10 @@
"value": "influxdb", "value": "influxdb",
"description": "InfluxDB telemetry payload." "description": "InfluxDB telemetry payload."
}, },
{
"value": "frost",
"description": "FROST/SensorThings CoreSync payload."
},
{ {
"value": "json", "value": "json",
"description": "JSON payload." "description": "JSON payload."
@@ -267,14 +271,14 @@
}, },
"basin": { "basin": {
"volume": { "volume": {
"default": "1", "default": 50,
"rules": { "rules": {
"type": "number", "type": "number",
"description": "Total volume of empty basin in m3" "description": "Total volume of empty basin in m3"
} }
}, },
"height": { "height": {
"default": "1", "default": 4,
"rules": { "rules": {
"type": "number", "type": "number",
"description": "Total height of basin in m" "description": "Total height of basin in m"
@@ -288,11 +292,11 @@
} }
}, },
"inflowLevel": { "inflowLevel": {
"default": 2, "default": 1.5,
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "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": { "outflowLevel": {
@@ -304,7 +308,7 @@
} }
}, },
"overflowLevel": { "overflowLevel": {
"default": 2.5, "default": 3.8,
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "min": 0,
@@ -486,7 +490,7 @@
}, },
"levelbased": { "levelbased": {
"minLevel": { "minLevel": {
"default": 1, "default": 0.3,
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "min": 0,
@@ -498,7 +502,7 @@
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "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": { "stopLevel": {
@@ -507,11 +511,29 @@
"type": "number", "type": "number",
"nullable": true, "nullable": true,
"min": 0, "min": 0,
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel." "description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Does NOT shape the ramp. Pair with a startLevel above stopLevel to get hysteresis (engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel. NOTE: schema default stays null so omitting stopLevel keeps the hysteresis inactive; the editor HTML provides a realistic 0.5 m default for drag-in UX."
}
},
"holdLevel": {
"default": null,
"rules": {
"type": "number",
"nullable": true,
"min": 0,
"description": "Optional `0 %` ramp foot. When set, pumps engage at startLevel but hold at 0 % (= flow.min via MGC) across [startLevel, holdLevel], then ramp 0 → 100 % across [holdLevel, maxLevel]. Default null → equals startLevel, i.e. no hold band and the ramp starts immediately at startLevel. Must satisfy startLevel ≤ holdLevel ≤ maxLevel."
}
},
"deadZoneKeepAlivePercent": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"max": 100,
"description": "Percent emitted to MGC across the falling-edge keep-alive band [stopLevel, startLevel] (i.e. once engaged, while draining back below startLevel but still above stopLevel). 0 maps to flow.min; the 1 % default sits just above min so MGC keeps at least one pump rotating instead of resting at the absolute minimum."
} }
}, },
"maxLevel": { "maxLevel": {
"default": 4, "default": 3.8,
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "min": 0,

View File

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

View File

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

View File

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

View File

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

View File

@@ -301,4 +301,26 @@ convert = function (value) {
return new Converter(value); return new Converter(value);
}; };
/**
* Top-level helper: list accepted unit names for a measure.
* Cached per measure. Unknown measures return [].
*/
var _possibilitiesCache = Object.create(null);
convert.possibilities = function (measure) {
if (!measure || typeof measure !== 'string') return [];
if (_possibilitiesCache[measure]) return _possibilitiesCache[measure].slice();
if (!measures[measure]) {
_possibilitiesCache[measure] = [];
return [];
}
var units = Converter.prototype.possibilities.call({ origin: { measure: measure } }, measure);
var deduped = Array.from(new Set(units)).sort();
_possibilitiesCache[measure] = deduped;
return deduped.slice();
};
convert.measures = function () {
return keys(measures).slice();
};
module.exports = convert; module.exports = convert;

View File

@@ -7,35 +7,65 @@
* *
* See CONTRACTS.md §5. Built on top of `childRegistrationUtils`, which * See CONTRACTS.md §5. Built on top of `childRegistrationUtils`, which
* already canonicalises softwareType (e.g. rotatingmachine → machine). * already canonicalises softwareType (e.g. rotatingmachine → machine).
*
* Wildcard / partial-filter subscriptions enumerate every concrete
* `<type>.<variant>.<position>` event name the filter matches and attach a
* plain `emitter.on(...)` per combination. No emit patching — multi-parent
* stacks compose cleanly because each parent owns its own listeners.
*/ */
const { POSITION_VALUES } = require('../constants/positions');
// Same alias map as childRegistrationUtils. Duplicated rather than imported
// because we need to canonicalise inputs to onRegister/onMeasurement/onPrediction
// at *declaration* time (before any child has registered), so that a domain
// can write `onRegister('rotatingmachine', ...)` or `onRegister('machine', ...)`
// interchangeably and have the dispatch match.
const SOFTWARE_TYPE_ALIASES = { const SOFTWARE_TYPE_ALIASES = {
rotatingmachine: 'machine', rotatingmachine: 'machine',
machinegroupcontrol: 'machinegroup', machinegroupcontrol: 'machinegroup',
}; };
// Canonical measurement-type set used to enumerate position-only and
// match-everything filters. Sourced from MeasurementContainer.measureMap
// plus the EVOLV-specific synthetic types the nodes routinely emit
// (level / volumePercent / efficiency / Ncog / netFlowRate). Keep in sync
// with MeasurementContainer if new types land there.
const KNOWN_TYPES = Object.freeze([
'flow',
'pressure',
'atmPressure',
'power',
'hydraulicPower',
'reactivePower',
'apparentPower',
'temperature',
'level',
'volume',
'volumePercent',
'length',
'mass',
'energy',
'reactiveEnergy',
'efficiency',
'Ncog',
'netFlowRate',
]);
function canonicalType(rawType) { function canonicalType(rawType) {
const t = String(rawType || '').toLowerCase(); const t = String(rawType || '').toLowerCase();
return SOFTWARE_TYPE_ALIASES[t] || t; return SOFTWARE_TYPE_ALIASES[t] || t;
} }
function lowerPosition(p) {
return String(p).toLowerCase();
}
class ChildRouter { class ChildRouter {
constructor(domain) { constructor(domain) {
this.domain = domain; this.domain = domain;
this.logger = domain?.logger || null; this.logger = domain?.logger || null;
// Subscription tables, keyed by canonical softwareType.
this._registerSubs = new Map(); // softwareType -> Array<fn> this._registerSubs = new Map(); // softwareType -> Array<fn>
this._measurementSubs = new Map(); // softwareType -> Array<{filter, fn}> this._measurementSubs = new Map(); // softwareType -> Array<{filter, fn}>
this._predictionSubs = new Map(); // softwareType -> Array<{filter, fn}> this._predictionSubs = new Map(); // softwareType -> Array<{filter, fn}>
// Track every emitter listener we attach so tearDown can remove them. // Every plain emitter listener we attach, so tearDown can remove them.
this._attached = []; this._listeners = [];
} }
// ── declaration API ──────────────────────────────────────────────── // ── declaration API ────────────────────────────────────────────────
@@ -60,7 +90,6 @@ class ChildRouter {
_addEventSub(table, softwareType, filter, fn, label) { _addEventSub(table, softwareType, filter, fn, label) {
if (typeof filter === 'function' && fn === undefined) { if (typeof filter === 'function' && fn === undefined) {
// Allow `onMeasurement(type, fn)` shorthand — no filter.
fn = filter; fn = filter;
filter = {}; filter = {};
} }
@@ -75,10 +104,6 @@ class ChildRouter {
// ── dispatch ────────────────────────────────────────────────────── // ── dispatch ──────────────────────────────────────────────────────
/**
* Called by the domain's registerChild(). Runs onRegister handlers, then
* attaches measurement/prediction listeners on the child's emitter.
*/
dispatchRegister(child, softwareType) { dispatchRegister(child, softwareType) {
const key = canonicalType(softwareType); const key = canonicalType(softwareType);
@@ -98,51 +123,24 @@ class ChildRouter {
_attachVariantListeners(child, key, emitter, variant, table) { _attachVariantListeners(child, key, emitter, variant, table) {
const subs = table.get(key) || []; const subs = table.get(key) || [];
for (const { filter, fn } of subs) { for (const { filter, fn } of subs) {
// Build the set of (type, position) tuples this sub matches. If a filter const types = filter.type ? [filter.type] : KNOWN_TYPES;
// omits one or both of {type, position}, we can't pre-enumerate the event const positions = filter.position ? [lowerPosition(filter.position)] : POSITION_VALUES.map(lowerPosition);
// names — fall back to a wildcard listener via `emit`-time matching. const handlerLabel = variant === 'measured' ? 'onMeasurement' : 'onPrediction';
if (filter.type && filter.position) {
const eventName = `${filter.type}.${variant}.${String(filter.position).toLowerCase()}`;
this._attach(emitter, eventName, (data) => this._invoke(fn, data, child, variant));
continue;
}
// Wildcard: subscribe to a generic catch-all by patching emitter.emit. for (const type of types) {
// EventEmitter has no built-in wildcard — install a one-off proxy listener for (const pos of positions) {
// that intercepts every emit on this emitter and filters by name. const eventName = `${type}.${variant}.${pos}`;
const proxyKey = `__childRouter_proxy_${variant}__`; const listener = (data) => this._invoke(fn, data, child, handlerLabel);
if (!emitter[proxyKey]) { emitter.on(eventName, listener);
const origEmit = emitter.emit.bind(emitter); this._listeners.push({ emitter, eventName, listener });
const proxies = []; }
emitter[proxyKey] = proxies;
emitter.emit = (eventName, ...args) => {
const parts = String(eventName).split('.');
if (parts.length === 3 && parts[1] === variant) {
for (const p of proxies) p({ type: parts[0], position: parts[2], args });
}
return origEmit(eventName, ...args);
};
// Track the proxy install for tearDown to undo.
this._attached.push({ emitter, kind: 'proxy', variant, original: origEmit, proxyKey });
} }
const proxyFn = ({ type, position, args }) => {
if (filter.type && type !== filter.type) return;
if (filter.position && position !== String(filter.position).toLowerCase()) return;
this._invoke(fn, args[0], child, variant);
};
emitter[proxyKey].push(proxyFn);
this._attached.push({ emitter, kind: 'proxyEntry', proxyKey, proxyFn });
} }
} }
_attach(emitter, eventName, listener) { _invoke(fn, eventData, child, handlerLabel) {
emitter.on(eventName, listener);
this._attached.push({ emitter, kind: 'listener', eventName, listener });
}
_invoke(fn, eventData, child, variant) {
try { fn.call(this.domain, eventData, child); } try { fn.call(this.domain, eventData, child); }
catch (err) { this._logHandlerError(`on${variant === 'measured' ? 'Measurement' : 'Prediction'}`, '', err); } catch (err) { this._logHandlerError(handlerLabel, '', err); }
} }
_logHandlerError(kind, key, err) { _logHandlerError(kind, key, err) {
@@ -154,31 +152,13 @@ class ChildRouter {
// ── teardown ────────────────────────────────────────────────────── // ── teardown ──────────────────────────────────────────────────────
tearDown() { tearDown() {
// Two passes: drop concrete listeners + proxy entries first, then unwrap for (const { emitter, eventName, listener } of this._listeners) {
// any proxies whose entry list is now empty. Order matters — restoring if (typeof emitter.off === 'function') emitter.off(eventName, listener);
// emit before clearing entries would leave dangling proxy state. else if (typeof emitter.removeListener === 'function') emitter.removeListener(eventName, listener);
for (const rec of this._attached) {
if (rec.kind === 'listener') {
if (typeof rec.emitter.off === 'function') rec.emitter.off(rec.eventName, rec.listener);
else if (typeof rec.emitter.removeListener === 'function') rec.emitter.removeListener(rec.eventName, rec.listener);
} else if (rec.kind === 'proxyEntry') {
const proxies = rec.emitter[rec.proxyKey];
if (Array.isArray(proxies)) {
const idx = proxies.indexOf(rec.proxyFn);
if (idx >= 0) proxies.splice(idx, 1);
}
}
} }
for (const rec of this._attached) { this._listeners = [];
if (rec.kind !== 'proxy') continue;
const proxies = rec.emitter[rec.proxyKey];
if (!Array.isArray(proxies) || proxies.length === 0) {
rec.emitter.emit = rec.original;
delete rec.emitter[rec.proxyKey];
}
}
this._attached = [];
} }
} }
module.exports = ChildRouter; module.exports = ChildRouter;
module.exports.KNOWN_TYPES = KNOWN_TYPES;

View File

@@ -2,9 +2,22 @@
// Serialises an async dispatch so that high-frequency callers cannot stack // Serialises an async dispatch so that high-frequency callers cannot stack
// up overlapping invocations. Intermediate values are dropped — only the // up overlapping invocations. Intermediate values are dropped — only the
// most recent fire() during an in-flight dispatch is replayed afterwards. // most recent fire()/fireAndWait() during an in-flight dispatch is replayed
// Extracted from machineGroupControl's _dispatchInFlight + _delayedCall // afterwards. Extracted from machineGroupControl's _dispatchInFlight +
// pattern so MGC, pumpingStation, valveGroupControl etc. can share it. // _delayedCall pattern so MGC, pumpingStation, valveGroupControl etc. can
// share it.
//
// fire(value) — never blocks; returns void.
// fireAndWait(value) — returns a promise that settles when THIS value's
// dispatch runs to completion. If a later fireAndWait
// arrives during the in-flight call and supersedes
// this one in the pending slot, the returned promise
// RESOLVES with { superseded: true } instead of
// rejecting — callers can branch on a sentinel
// without try/catch. The dispatch's own return value
// (when not superseded) is forwarded as the resolution.
const SUPERSEDED = Object.freeze({ superseded: true });
class LatestWinsGate { class LatestWinsGate {
constructor(asyncDispatchFn, options = {}) { constructor(asyncDispatchFn, options = {}) {
@@ -14,7 +27,7 @@ class LatestWinsGate {
this._dispatch = asyncDispatchFn; this._dispatch = asyncDispatchFn;
this._logger = options.logger || null; this._logger = options.logger || null;
this._inFlight = false; this._inFlight = false;
this._pending = null; // { value, ctx } | null this._pending = null; // { value, ctx, settle? } | null
this._drainResolvers = []; // resolved when idle again this._drainResolvers = []; // resolved when idle again
this.lastError = null; this.lastError = null;
} }
@@ -25,14 +38,31 @@ class LatestWinsGate {
return this._pending ? 2 : 1; return this._pending ? 2 : 1;
} }
// Never blocks the caller. If a dispatch is in flight, the latest // Never blocks. If a dispatch is in flight, the latest value is parked;
// value is parked; older parked values are silently overwritten. // older parked values are silently overwritten.
fire(value, ctx) { fire(value, ctx) {
if (this._inFlight) { if (this._inFlight) {
this._pending = { value, ctx }; this._supersedePending();
this._pending = { value, ctx, settle: null };
return; return;
} }
this._run(value, ctx); this._run(value, ctx, null);
}
// Returns a promise that resolves when THIS fire's dispatch settles.
// If this fire gets overwritten while parked, resolves with the
// SUPERSEDED sentinel ({ superseded: true }) — callers branch on
// result.superseded === true without try/catch.
fireAndWait(value, ctx) {
return new Promise((resolve) => {
const settle = resolve;
if (this._inFlight) {
this._supersedePending();
this._pending = { value, ctx, settle };
return;
}
this._run(value, ctx, settle);
});
} }
drain() { drain() {
@@ -40,18 +70,28 @@ class LatestWinsGate {
return new Promise((resolve) => { this._drainResolvers.push(resolve); }); return new Promise((resolve) => { this._drainResolvers.push(resolve); });
} }
_run(value, ctx) { _supersedePending() {
const prev = this._pending;
if (prev && typeof prev.settle === 'function') prev.settle(SUPERSEDED);
this._pending = null;
}
_run(value, ctx, settle) {
this._inFlight = true; this._inFlight = true;
// Kick the dispatch on a microtask so fire() always returns // Kick the dispatch on a microtask so fire()/fireAndWait() always
// synchronously, even if _dispatch resolves immediately. // return synchronously, even if _dispatch resolves immediately.
Promise.resolve() Promise.resolve()
.then(() => this._dispatch(value, ctx)) .then(() => this._dispatch(value, ctx))
.catch((err) => { .then((result) => {
if (typeof settle === 'function') settle(result);
}, (err) => {
this.lastError = err; this.lastError = err;
if (this._logger && typeof this._logger.error === 'function') { if (this._logger && typeof this._logger.error === 'function') {
this._logger.error(err); this._logger.error(err);
} }
// Swallow: an error must not deadlock the gate. // Resolve (not reject) so fireAndWait callers don't need
// try/catch. Dispatch errors stay observable via lastError.
if (typeof settle === 'function') settle(undefined);
}) })
.then(() => this._afterDispatch()); .then(() => this._afterDispatch());
} }
@@ -59,9 +99,9 @@ class LatestWinsGate {
_afterDispatch() { _afterDispatch() {
this._inFlight = false; this._inFlight = false;
if (this._pending) { if (this._pending) {
const { value, ctx } = this._pending; const { value, ctx, settle } = this._pending;
this._pending = null; this._pending = null;
this._run(value, ctx); this._run(value, ctx, settle);
return; return;
} }
// Idle — release any drain() waiters. // Idle — release any drain() waiters.
@@ -71,4 +111,6 @@ class LatestWinsGate {
} }
} }
LatestWinsGate.SUPERSEDED = SUPERSEDED;
module.exports = LatestWinsGate; module.exports = LatestWinsGate;

View File

@@ -34,6 +34,14 @@ class UnitPolicy {
this._logger = logger || null; this._logger = logger || null;
// Warn-once memo: same (label, candidate) pair only logs the first time. // Warn-once memo: same (label, candidate) pair only logs the first time.
this._warned = new Set(); this._warned = new Set();
// Dual-shape accessors: each of canonical/output/curve is BOTH a method
// (legacy `policy.canonical('flow')`) AND a frozen property bag
// (`policy.canonical.flow`). The function carries the frozen map's own
// properties via Object.defineProperty so consumers can pick either form.
this.canonical = makeAccessor(this._canonical);
this.output = makeAccessor(this._output);
this.curve = makeAccessor(this._curve || {});
} }
static declare(spec = {}) { static declare(spec = {}) {
@@ -51,18 +59,6 @@ class UnitPolicy {
return this; return this;
} }
canonical(type) {
return this._canonical[type] || null;
}
output(type) {
return this._output[type] || null;
}
curve(type) {
return this._curve ? (this._curve[type] || null) : null;
}
/** /**
* Validate a user-supplied unit string against `expectedMeasure`. On any * Validate a user-supplied unit string against `expectedMeasure`. On any
* mismatch return `fallback` and warn once for this (label, candidate) * mismatch return `fallback` and warn once for this (label, candidate)
@@ -137,6 +133,24 @@ function freezeShallow(obj) {
return Object.freeze({ ...(obj || {}) }); return Object.freeze({ ...(obj || {}) });
} }
// Build a function that doubles as a frozen property bag. `accessor(type)`
// returns the unit for that type (legacy method shape). `accessor.flow` etc.
// return the unit directly (new property shape). Own-properties are
// non-writable, non-configurable; attempts to assign / delete / redefine
// throw in strict mode — proving the bag is genuinely frozen.
function makeAccessor(map) {
const fn = (type) => map[type] || null;
for (const key of Object.keys(map)) {
Object.defineProperty(fn, key, {
value: map[key],
writable: false,
enumerable: true,
configurable: false,
});
}
return Object.freeze(fn);
}
// Accepts either the convert-module measure family ('volumeFlowRate') or one // Accepts either the convert-module measure family ('volumeFlowRate') or one
// of our type names ('flow') and returns the convert-module measure. // of our type names ('flow') and returns the convert-module measure.
function resolveMeasure(expected) { function resolveMeasure(expected) {

View File

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

View File

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

View File

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

View File

@@ -233,6 +233,13 @@ class ValidationUtils {
return fieldSchema.default; return fieldSchema.default;
} }
} }
// Public wrapper for the curve validator — exposes the helper so
// callers (and tests) can validate a raw curve without going
// through validateSchema.
validateCurve(input, defaultCurve) {
return validateCurve(input, defaultCurve, this.logger);
}
} }
module.exports = ValidationUtils; module.exports = ValidationUtils;

View File

@@ -17,7 +17,7 @@ function validateArray(configValue, rules, fieldSchema, name, key, logger) {
} }
}) })
.slice(0, rules.maxLength || Infinity); .slice(0, rules.maxLength || Infinity);
if (validatedArray.length < (rules.minLength || 1)) { if (validatedArray.length < (rules.minLength ?? 1)) {
logger.warn( logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.` `${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
); );
@@ -41,7 +41,7 @@ function validateSet(configValue, rules, fieldSchema, name, key, logger) {
} }
}) })
.slice(0, rules.maxLength || Infinity); .slice(0, rules.maxLength || Infinity);
if (validatedArray.length < (rules.minLength || 1)) { if (validatedArray.length < (rules.minLength ?? 1)) {
logger.warn( logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.` `${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
); );

View File

@@ -1,41 +1,30 @@
const fs = require('fs'); 'use strict';
const path = require('path');
// 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 { class AquonSamplesMenu {
constructor(relPath = '../../datasets/assetData') { // relPath retained for signature compatibility with the previous on-disk
this.baseDir = path.resolve(__dirname, relPath); // implementation; unused — the registry owns file locations.
this.samplePath = path.resolve(this.baseDir, 'monsterSamples.json'); constructor(/* relPath */) {}
this.specPath = path.resolve(this.baseDir, 'specs/monster/index.json');
this.cache = new Map();
}
_loadJSON(filePath, cacheKey) { getAllMenuData() {
if (this.cache.has(cacheKey)) { const samples = assetResolver
return this.cache.get(cacheKey); .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; module.exports = AquonSamplesMenu;

View File

@@ -19,6 +19,7 @@ class AssetMenu {
return null; return null;
} }
const softwareType = category.softwareType || key;
return { return {
...category, ...category,
label: category.label || category.softwareType || key, label: category.label || category.softwareType || key,
@@ -28,11 +29,18 @@ class AssetMenu {
types: (supplier.types || []).map((type) => ({ types: (supplier.types || []).map((type) => ({
...type, ...type,
id: type.id || type.name, id: type.id || type.name,
models: (type.models || []).map((model) => ({ models: (type.models || []).map((model) => {
...model, const id = model.id || model.name;
id: model.id || model.name, // Enrich each model with a slim preview curve (or null) so the
units: model.units || [] // editor wizard can draw a sparkline without a round-trip.
})) const previewCurve = this.buildPreviewCurve(softwareType, id, model.name);
return {
...model,
id,
units: model.units || [],
previewCurve: previewCurve || null
};
})
})) }))
})) }))
}; };
@@ -55,7 +63,17 @@ class AssetMenu {
} }
} }
return keys[0]; // Previously fell back to keys[0] (alphabetically first category),
// which meant a softwareType mismatch silently showed the wrong asset
// tree — e.g. rotatingMachine (softwareType='rotatingmachine') with
// no matching registry file saw 'diffuser' models in the dropdown.
// Return null so the menu renders empty and the operator sees a clear
// 'No suppliers available' placeholder instead of a wrong category.
console.warn(
`[AssetMenu] No asset category matches softwareType='${this.softwareType}' or nodeName='${nodeName}'. ` +
`Available categories: [${keys.join(', ')}]. Menu will render empty.`
);
return null;
} }
getAllMenuData(nodeName) { getAllMenuData(nodeName) {
@@ -76,12 +94,359 @@ class AssetMenu {
}; };
} }
// Client-side wizard layer: chips, combobox, spec strip, curve mini-chart.
// Listens to change events on the hidden <select>s that wireEvents already
// populates — so cascade/reset logic stays in one place.
getVisualInjectionCode(nodeName) {
return `
// Asset wizard visuals for ${nodeName}
(function injectAssetWizardCss() {
const id = 'evolv-asset-wizard-css';
if (document.getElementById(id)) return;
const css = [
// Asset wizard — tightened layout (smaller radius/padding, no
// uppercase label transform, single-line chip text) so the strip
// reads as a compact form control instead of a row of pill cards.
'.evolv-asset-hidden-natives { position:absolute !important; left:-9999px !important; height:0 !important; overflow:hidden; }',
'.evolv-asset-wizard { display:flex; flex-direction:column; gap:8px; margin:6px 0 4px 0; max-width:460px; }',
'.evolv-asset-chips { display:flex; flex-wrap:wrap; gap:4px; align-items:center; }',
'.evolv-asset-chip {',
' display:inline-flex; align-items:baseline; gap:6px;',
' border:1px solid #d0d0d0; border-radius:4px; background:#fff;',
' padding:3px 8px; cursor:pointer; user-select:none;',
' font:inherit; color:#333; height:26px; box-sizing:border-box;',
' transition:border-color 80ms ease-out, background 80ms ease-out;',
'}',
'.evolv-asset-chip:hover { border-color:#86bbdd; background:#f5fafd; }',
'.evolv-asset-chip[aria-selected="true"] { border-color:#1F4E79; background:#eaf4fb; }',
'.evolv-asset-chip[disabled] { opacity:0.5; cursor:not-allowed; }',
'.evolv-asset-chip-icon { color:#607484; font-size:11px; align-self:center; }',
'.evolv-asset-chip-text { display:inline-flex; align-items:baseline; gap:5px; line-height:1; }',
'.evolv-asset-chip-label { font-size:11px; font-weight:normal; color:#888; letter-spacing:0; text-transform:none; }',
'.evolv-asset-chip-label::after { content:":"; color:#bbb; margin-left:1px; }',
'.evolv-asset-chip-value { font-size:12px; font-weight:600; color:#1F4E79; max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
'.evolv-asset-chip-value[data-empty="true"] { color:#aaa; font-weight:400; font-style:italic; }',
'.evolv-asset-chip-sep { color:#bbb; font-size:13px; line-height:1; user-select:none; padding:0 2px; }',
'.evolv-asset-combobox { display:flex; flex-direction:column; gap:4px; border:1px solid #d0d0d0; border-radius:3px; background:#fff; padding:6px; }',
'.evolv-asset-combobox-search { width:100%; box-sizing:border-box; padding:5px 7px; border:1px solid #ccc; border-radius:3px; font:inherit; font-size:12px; }',
'.evolv-asset-combobox-search:focus { outline:none; border-color:#1F4E79; box-shadow:0 0 0 2px rgba(31,78,121,0.15); }',
'.evolv-asset-combobox-list { max-height:200px; overflow-y:auto; }',
'.evolv-asset-combobox-option {',
' padding:5px 8px; cursor:pointer; border-radius:2px;',
' font-size:12px; color:#333;',
'}',
'.evolv-asset-combobox-option:hover,',
'.evolv-asset-combobox-option.evolv-asset-combobox-option-active { background:#eaf4fb; color:#1F4E79; }',
'.evolv-asset-combobox-empty { padding:5px 8px; color:#888; font-size:11px; font-style:italic; }',
'.evolv-asset-summary { display:grid; grid-template-columns:1fr 220px; gap:10px; border:1px solid #e2e2e2; border-radius:3px; padding:8px 10px; background:#fafafa; align-items:center; }',
'.evolv-asset-specs { font-size:11.5px; color:#333; display:flex; flex-direction:column; gap:2px; }',
'.evolv-asset-spec-row { display:flex; gap:6px; }',
'.evolv-asset-spec-key { color:#888; min-width:74px; }',
'.evolv-asset-spec-val { color:#1F4E79; font-weight:600; }',
'.evolv-asset-curve { width:220px; height:110px; }',
'.evolv-asset-curve svg { width:100%; height:100%; display:block; }',
'.evolv-asset-curve-empty { display:flex; align-items:center; justify-content:center; color:#aaa; font-size:11px; font-style:italic; text-align:center; }',
'.evolv-asset-tag-row { margin-top:2px; align-items:center; }',
'.evolv-asset-tag-row > label { width:110px; white-space:nowrap; }',
'.evolv-asset-tag-row input[type=text] { width:auto !important; max-width:200px; min-width:140px; font-size:12px; padding:3px 6px; }',
'@media (max-width:560px) {',
' .evolv-asset-chips { flex-direction:column; align-items:stretch; }',
' .evolv-asset-chip-sep { display:none; }',
' .evolv-asset-chip { width:100%; justify-content:flex-start; }',
' .evolv-asset-summary { grid-template-columns:1fr; }',
' .evolv-asset-curve { width:100%; }',
'}'
].join('\\n');
const style = document.createElement('style');
style.id = id;
style.textContent = css;
document.head.appendChild(style);
})();
window.EVOLV.nodes.${nodeName}.assetMenu.initVisuals = function(node) {
const wizard = document.getElementById('evolv-asset-wizard');
if (!wizard) return;
const stageMap = { supplier: 'node-input-supplier', type: 'node-input-assetType', model: 'node-input-model', unit: 'node-input-unit' };
const downstreamOf = { supplier: ['type','model','unit'], type: ['model','unit'], model: ['unit'], unit: [] };
const getSelect = (stage) => document.getElementById(stageMap[stage]);
const chips = Array.from(wizard.querySelectorAll('.evolv-asset-chip'));
const combobox = document.getElementById('evolv-asset-combobox');
const search = combobox ? combobox.querySelector('.evolv-asset-combobox-search') : null;
const list = combobox ? combobox.querySelector('.evolv-asset-combobox-list') : null;
const summary = document.getElementById('evolv-asset-summary');
const specsEl = document.getElementById('evolv-asset-specs');
const curveEl = document.getElementById('evolv-asset-curve');
let activeStage = null;
let activeIndex = -1;
// Update the chip value text from the live <select>. Empty selects
// show the placeholder; populated selects show the option label.
function syncChip(stage) {
const chip = chips.find((c) => c.getAttribute('data-stage') === stage);
if (!chip) return;
const select = getSelect(stage);
const valueEl = chip.querySelector('.evolv-asset-chip-value');
const labelDefault = stage === 'supplier' ? 'Select…' : '—';
if (!select || !select.value) {
valueEl.textContent = labelDefault;
valueEl.setAttribute('data-empty', 'true');
chip.disabled = false; // stage is reachable but empty
} else {
const opt = select.options[select.selectedIndex];
valueEl.textContent = (opt && opt.textContent) ? opt.textContent : select.value;
valueEl.removeAttribute('data-empty');
}
}
function syncAllChips() {
['supplier','type','model','unit'].forEach(syncChip);
}
function refreshAriaSelected() {
chips.forEach((c) => c.setAttribute('aria-selected', c.getAttribute('data-stage') === activeStage ? 'true' : 'false'));
}
function closeCombobox() {
activeStage = null;
combobox.hidden = true;
refreshAriaSelected();
}
function openStage(stage) {
const select = getSelect(stage);
if (!select) return;
// Skip if the parent stage hasn't been resolved (e.g. type before supplier).
// The parent select would have an empty value in that case.
const parentOrder = ['supplier','type','model','unit'];
const idx = parentOrder.indexOf(stage);
for (let i = 0; i < idx; i += 1) {
const parentSel = getSelect(parentOrder[i]);
if (!parentSel || !parentSel.value) {
if (window.RED && window.RED.notify) {
window.RED.notify('Pick ' + parentOrder[i] + ' first.', 'info');
}
return;
}
}
activeStage = stage;
combobox.hidden = false;
search.value = '';
search.placeholder = 'Filter ' + stage + '…';
renderList('');
refreshAriaSelected();
// Move focus to the search box so keyboard users get an immediate
// typing context after clicking a chip.
setTimeout(() => search.focus(), 0);
}
function getStageOptions(stage) {
const select = getSelect(stage);
if (!select) return [];
return Array.from(select.options)
.filter((o) => o.value !== '' && !o.disabled)
.map((o) => ({ value: o.value, label: o.textContent || o.value }));
}
function renderList(filter) {
if (!activeStage || !list) return;
const items = getStageOptions(activeStage);
const lc = String(filter || '').toLowerCase();
const matches = items.filter((it) => it.label.toLowerCase().includes(lc) || it.value.toLowerCase().includes(lc));
list.innerHTML = '';
activeIndex = matches.length ? 0 : -1;
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'evolv-asset-combobox-empty';
empty.textContent = items.length ? 'No matches.' : 'Nothing available — pick the previous stage first.';
list.appendChild(empty);
return;
}
matches.forEach((it, i) => {
const opt = document.createElement('div');
opt.className = 'evolv-asset-combobox-option';
if (i === 0) opt.classList.add('evolv-asset-combobox-option-active');
opt.setAttribute('role', 'option');
opt.setAttribute('data-value', it.value);
opt.textContent = it.label;
opt.addEventListener('mousedown', (e) => { e.preventDefault(); pickValue(it.value); });
opt.addEventListener('mouseenter', () => {
activeIndex = i;
list.querySelectorAll('.evolv-asset-combobox-option').forEach((el, j) => el.classList.toggle('evolv-asset-combobox-option-active', j === i));
});
list.appendChild(opt);
});
}
function pickValue(value) {
const select = getSelect(activeStage);
if (!select) return;
// Reset downstream selects so the cascade refreshes cleanly.
(downstreamOf[activeStage] || []).forEach((s) => {
const ds = getSelect(s);
if (ds) { ds.value = ''; ds.dispatchEvent(new Event('change', { bubbles: true })); }
});
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
syncAllChips();
updateSummary();
closeCombobox();
// Auto-advance to the next empty stage so the flow feels guided.
const order = ['supplier','type','model','unit'];
const i = order.indexOf(activeStage);
for (let n = i + 1; n < order.length; n += 1) {
const next = getSelect(order[n]);
if (next && (!next.value || next.options.length > 1)) {
openStage(order[n]);
return;
}
}
}
function updateSummary() {
const modelSel = getSelect('model');
if (!modelSel || !modelSel.value) {
if (summary) summary.hidden = true;
return;
}
if (summary) summary.hidden = false;
// Lookup the chosen model in the menuData tree to pull metadata + previewCurve.
const data = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
const categories = data.categories || {};
let chosenModel = null;
Object.keys(categories).forEach((catKey) => {
const cat = categories[catKey];
(cat.suppliers || []).forEach((sup) => (sup.types || []).forEach((t) => (t.models || []).forEach((m) => {
if (String(m.id || m.name) === String(modelSel.value)) chosenModel = m;
})));
});
renderSpecs(chosenModel);
renderCurve(chosenModel && chosenModel.previewCurve);
}
function renderSpecs(model) {
if (!specsEl) return;
specsEl.innerHTML = '';
if (!model) return;
const rows = [];
if (model.name) rows.push({ key: 'Name', val: model.name });
if (model.id && model.id !== model.name) rows.push({ key: 'ID', val: model.id });
if (Array.isArray(model.units) && model.units.length) rows.push({ key: 'Units', val: model.units.join(', ') });
// Pull any leftover scalar keys (rated_kW, voltage, etc.) — heuristic.
Object.keys(model).forEach((k) => {
if (['name','id','units','previewCurve','product_model_id','product_model_uuid'].indexOf(k) >= 0) return;
const v = model[k];
if (v == null) return;
if (typeof v === 'object') return;
rows.push({ key: k, val: String(v) });
});
rows.slice(0, 5).forEach((r) => {
const row = document.createElement('div');
row.className = 'evolv-asset-spec-row';
row.innerHTML = '<span class="evolv-asset-spec-key">' + r.key + '</span><span class="evolv-asset-spec-val">' + r.val + '</span>';
specsEl.appendChild(row);
});
}
function renderCurve(curve) {
if (!curveEl) return;
curveEl.innerHTML = '';
if (!curve || !Array.isArray(curve.x) || !Array.isArray(curve.y) || curve.x.length < 2) {
const empty = document.createElement('div');
empty.className = 'evolv-asset-curve-empty';
empty.textContent = 'no curve available';
curveEl.appendChild(empty);
return;
}
const W = 200, H = 90, P = 6;
const xs = curve.x, ys = curve.y;
const xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
const yMin = Math.min.apply(null, ys), yMax = Math.max.apply(null, ys);
const xRange = xMax - xMin || 1, yRange = yMax - yMin || 1;
const px = (x) => P + (W - 2*P) * (x - xMin) / xRange;
const py = (y) => (H - P) - (H - 2*P) * (y - yMin) / yRange;
const pts = xs.map((x, i) => px(x).toFixed(1) + ',' + py(ys[i]).toFixed(1)).join(' ');
const svg = [
'<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">',
' <rect x="0" y="0" width="' + W + '" height="' + H + '" fill="#fff" stroke="#e5e5e5"/>',
' <polyline fill="none" stroke="#1F4E79" stroke-width="1.6" points="' + pts + '"/>',
' <g font-size="8" fill="#888" font-family="Arial, sans-serif">',
' <text x="' + P + '" y="9">' + (curve.yLabel || '') + '</text>',
' <text x="' + (W - P) + '" y="' + (H - 2) + '" text-anchor="end">' + (curve.xLabel || '') + '</text>',
(curve.legend ? '<text x="' + (W - P) + '" y="9" text-anchor="end" fill="#1F4E79">' + curve.legend + '</text>' : ''),
' </g>',
'</svg>'
].join('');
curveEl.innerHTML = svg;
}
// --- Wire chip clicks + select-change → chip refresh -------------
chips.forEach((chip) => {
chip.addEventListener('click', () => {
const stage = chip.getAttribute('data-stage');
if (activeStage === stage) {
closeCombobox();
} else {
openStage(stage);
}
});
});
['supplier','type','model','unit'].forEach((stage) => {
const sel = getSelect(stage);
if (sel) sel.addEventListener('change', () => { syncChip(stage); if (stage === 'model' || stage === 'unit') updateSummary(); });
});
// --- Combobox interactions -------------------------------------
if (search) {
search.addEventListener('input', () => renderList(search.value));
search.addEventListener('keydown', (e) => {
const optEls = Array.from(list.querySelectorAll('.evolv-asset-combobox-option'));
if (!optEls.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % optEls.length;
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + optEls.length) % optEls.length;
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && optEls[activeIndex]) {
pickValue(optEls[activeIndex].getAttribute('data-value'));
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeCombobox();
}
});
}
// Initial render — fires after loadData has populated the natives.
syncAllChips();
updateSummary();
};
`;
}
getClientInitCode(nodeName) { getClientInitCode(nodeName) {
const htmlCode = this.getHtmlInjectionCode(nodeName); const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName); const dataCode = this.getDataInjectionCode(nodeName);
const eventsCode = this.getEventInjectionCode(nodeName); const eventsCode = this.getEventInjectionCode(nodeName);
const syncCode = this.getSyncInjectionCode(nodeName); const syncCode = this.getSyncInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName); const saveCode = this.getSaveInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return ` return `
// --- AssetMenu for ${nodeName} --- // --- AssetMenu for ${nodeName} ---
@@ -93,14 +458,19 @@ class AssetMenu {
${eventsCode} ${eventsCode}
${syncCode} ${syncCode}
${saveCode} ${saveCode}
${visualCode}
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) { window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
console.log('Initializing asset properties for ${nodeName}'); console.log('Initializing asset properties for ${nodeName}');
this.injectHtml(); this.injectHtml();
this.wireEvents(node); this.wireEvents(node);
this.loadData(node).catch((error) => const self = this;
console.error('Asset menu load failed:', error) this.loadData(node)
); .then(() => { if (self.initVisuals) self.initVisuals(node); })
.catch((error) => {
console.error('Asset menu load failed:', error);
if (self.initVisuals) self.initVisuals(node);
});
}; };
`; `;
} }
@@ -253,6 +623,26 @@ class AssetMenu {
} }
const suppliers = activeCategory ? activeCategory.suppliers : []; const suppliers = activeCategory ? activeCategory.suppliers : [];
// The save handler intentionally discards node.supplier / node.assetType
// (denormalized copies of registry data — only node.model + node.unit
// are persisted identity). So on reopen we re-derive them from the
// saved model id by walking the registry tree. Without this the
// cascade always boots at "Select..." even when a model is saved.
if (node.model && (!node.supplier || !node.assetType)) {
for (const supplier of suppliers) {
const match = (supplier.types || []).find((type) =>
(type.models || []).some((model) =>
String(model.id || model.name) === String(node.model))
);
if (match) {
node.supplier = supplier.id || supplier.name;
node.assetType = match.id || match.name;
break;
}
}
}
populate( populate(
elems.supplier, elems.supplier,
suppliers, suppliers,
@@ -577,35 +967,165 @@ class AssetMenu {
} }
getHtmlTemplate() { getHtmlTemplate() {
// Wizard layout:
// 1. Section heading + chip strip (Supplier Type Model Unit).
// Chips are clickable buttons; clicking re-opens that stage's combobox
// and resets everything to its right.
// 2. Active-stage combobox: search input + filtered option list.
// 3. Spec strip + curve mini-chart (visible once a Model is picked).
// 4. Asset Tag row (still read-only, auto-resolved by syncAsset).
// 5. Hidden native <select>s (canonical save targets — Node-RED reads
// these on save; chip clicks mirror values into them).
return ` return `
<!-- Asset Properties -->
<hr /> <hr />
<h3>Asset selection</h3> <h3>Asset selection</h3>
<div class="form-row"> <div class="evolv-asset-wizard" id="evolv-asset-wizard">
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label> <div class="evolv-asset-chips" role="tablist" aria-label="Asset selection stages">
<select id="node-input-supplier" style="width:70%;"></select> <button type="button" class="evolv-asset-chip" data-stage="supplier" aria-selected="false">
</div> <span class="evolv-asset-chip-icon"><i class="fa fa-industry"></i></span>
<div class="form-row"> <span class="evolv-asset-chip-text">
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label> <span class="evolv-asset-chip-label">Supplier</span>
<select id="node-input-assetType" style="width:70%;"></select> <span class="evolv-asset-chip-value" data-empty="true">Select…</span>
</div> </span>
<div class="form-row"> </button>
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label> <span class="evolv-asset-chip-sep" aria-hidden="true"></span>
<select id="node-input-model" style="width:70%;"></select> <button type="button" class="evolv-asset-chip" data-stage="type" aria-selected="false">
</div> <span class="evolv-asset-chip-icon"><i class="fa fa-puzzle-piece"></i></span>
<div class="form-row"> <span class="evolv-asset-chip-text">
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label> <span class="evolv-asset-chip-label">Type</span>
<select id="node-input-unit" style="width:70%;"></select> <span class="evolv-asset-chip-value" data-empty="true"></span>
</div> </span>
<div class="form-row"> </button>
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label> <span class="evolv-asset-chip-sep" aria-hidden="true"></span>
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" /> <button type="button" class="evolv-asset-chip" data-stage="model" aria-selected="false">
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div> <span class="evolv-asset-chip-icon"><i class="fa fa-wrench"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Model</span>
<span class="evolv-asset-chip-value" data-empty="true">—</span>
</span>
</button>
<span class="evolv-asset-chip-sep" aria-hidden="true"></span>
<button type="button" class="evolv-asset-chip" data-stage="unit" aria-selected="false">
<span class="evolv-asset-chip-icon"><i class="fa fa-balance-scale"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Unit</span>
<span class="evolv-asset-chip-value" data-empty="true">—</span>
</span>
</button>
</div>
<div class="evolv-asset-combobox" id="evolv-asset-combobox" hidden>
<input type="text" class="evolv-asset-combobox-search" placeholder="Type to filter…" autocomplete="off" />
<div class="evolv-asset-combobox-list" role="listbox"></div>
</div>
<div class="evolv-asset-summary" id="evolv-asset-summary" hidden>
<div class="evolv-asset-specs" id="evolv-asset-specs"></div>
<div class="evolv-asset-curve" id="evolv-asset-curve"></div>
</div>
<div class="form-row evolv-asset-tag-row">
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
<input type="text" id="node-input-assetTagNumber" readonly />
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
</div>
<div class="evolv-asset-hidden-natives" aria-hidden="true">
<select id="node-input-supplier"></select>
<select id="node-input-assetType"></select>
<select id="node-input-model"></select>
<select id="node-input-unit"></select>
</div>
</div> </div>
<hr /> <hr />
`; `;
} }
// Build a slim preview curve `{x[], y[], xLabel, yLabel}` per model so the
// editor wizard can render a sparkline without round-tripping. Picks a
// representative slice for each software type's curve format.
buildPreviewCurve(softwareType, modelId, modelName) {
if (!modelId && !modelName) return null;
let loadCurve;
try {
// Lazy require — keep AssetMenu importable in environments that don't
// ship the curves dataset (e.g. unit tests with mocked managers).
loadCurve = require('../../index.js').loadCurve;
} catch (e) {
return null;
}
if (typeof loadCurve !== 'function') return null;
// Try id first, then name (legacy curve files are named after the
// model name rather than id — e.g. ECDV.json).
let curve = null;
try { curve = loadCurve(modelId) || (modelName ? loadCurve(modelName) : null); } catch (e) { curve = null; }
if (!curve) return null;
const type = String(softwareType || '').toLowerCase();
// Helpers — pick a "middle" key from an object whose keys are numeric strings.
const middleKey = (obj) => {
const keys = Object.keys(obj || {});
if (!keys.length) return null;
const sorted = keys.slice().sort((a, b) => Number(a) - Number(b));
return sorted[Math.floor(sorted.length / 2)];
};
const maxKey = (obj) => {
const keys = Object.keys(obj || {});
if (!keys.length) return null;
return keys.slice().sort((a, b) => Number(b) - Number(a))[0];
};
try {
if (type === 'rotatingmachine') {
// { np: { rpm: { x:[%speed], y:[..] } } } — pick top RPM slice.
const np = curve.np || curve;
const rpm = maxKey(np);
if (!rpm || !np[rpm] || !Array.isArray(np[rpm].x)) return null;
return {
x: np[rpm].x.slice(),
y: np[rpm].y.slice(),
xLabel: 'Speed (%)',
yLabel: 'Power',
legend: rpm + ' rpm'
};
}
if (type === 'valve') {
// { density: { dp: { x:[%opening], y:[m3/h] } } } — pick mid density/dp.
const densityKey = middleKey(curve);
if (!densityKey) return null;
const dpMap = curve[densityKey] || {};
const dpKey = middleKey(dpMap);
if (!dpKey || !dpMap[dpKey] || !Array.isArray(dpMap[dpKey].x)) return null;
return {
x: dpMap[dpKey].x.slice(),
y: dpMap[dpKey].y.slice(),
xLabel: 'Opening (%)',
yLabel: 'Flow (m³/h)',
legend: 'ρ=' + densityKey + ' · Δp=' + dpKey
};
}
if (type === 'diffuser') {
// { sote_curve: { coverage: { x:[flux], y:[%] } }, ... } — pick mid coverage on sote_curve.
const sote = curve.sote_curve || curve.SOTE_curve || curve;
const covKey = middleKey(sote);
if (!covKey || !sote[covKey] || !Array.isArray(sote[covKey].x)) return null;
return {
x: sote[covKey].x.slice(),
y: sote[covKey].y.slice(),
xLabel: 'Flux (Nm³/h·m²)',
yLabel: 'SOTE (%)',
legend: covKey + '% coverage'
};
}
// measurement + unknowns: no representative curve yet.
return null;
} catch (e) {
return null;
}
}
getHtmlInjectionCode(nodeName) { getHtmlInjectionCode(nodeName) {
const htmlTemplate = this.getHtmlTemplate() const htmlTemplate = this.getHtmlTemplate()
.replace(/`/g, '\\`') .replace(/`/g, '\\`')
@@ -624,46 +1144,40 @@ class AssetMenu {
} }
getSaveInjectionCode(nodeName) { 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 ` return `
// Asset save handler for ${nodeName} // Asset save handler for ${nodeName}
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) { window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
console.log('Saving asset properties for ${nodeName}'); 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 = []; const errors = [];
fields.forEach((field) => { const modelEl = document.getElementById('node-input-model');
const el = document.getElementById(\`node-input-\${field}\`); const unitEl = document.getElementById('node-input-unit');
node[field] = el ? el.value : ''; const tagEl = document.getElementById('node-input-assetTagNumber');
});
if (node.assetType && !node.unit) { node.model = modelEl ? modelEl.value : '';
errors.push('Unit must be set when a type is specified.'); node.unit = unitEl ? unitEl.value : '';
} node.assetTagNumber = tagEl ? tagEl.value : '';
if (!node.unit) {
errors.push('Unit is required.'); // 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')); errors.forEach((msg) => RED.notify(msg, 'error'));
const saved = fields.reduce((acc, field) => { const saved = { model: node.model, unit: node.unit, assetTagNumber: node.assetTagNumber };
acc[field] = node[field];
return acc;
}, {});
if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') { if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') {
saved.modelId = node.modelMetadata.id; 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 LoggerMenu = require('./logger.js');
const PhysicalPositionMenu = require('./physicalPosition.js'); const PhysicalPositionMenu = require('./physicalPosition.js');
const AquonSamplesMenu = require('./aquonSamples.js'); const AquonSamplesMenu = require('./aquonSamples.js');
const IconHelpers = require('./iconHelpers.js');
const ConfigManager = require('../configs'); const ConfigManager = require('../configs');
class MenuManager { class MenuManager {
@@ -138,6 +139,9 @@ class MenuManager {
window.EVOLV.nodes = window.EVOLV.nodes || {}; window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {}; window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
// Shared icon-picker helpers (no-op if already loaded by another node)
${IconHelpers.getClientInitCode()}
// Initialize menu namespaces // Initialize menu namespaces
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')} ${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
@@ -163,12 +167,26 @@ class MenuManager {
try { try {
${menuTypes.map(type => ` ${menuTypes.map(type => `
try { try {
// initEditor is responsible for calling initVisuals
// at the right time (after any async data load).
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) { if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node); window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
} }
} catch (${type}Error) { } catch (${type}Error) {
console.error('Error initializing ${type} menu for ${nodeName}:', ${type}Error); console.error('Error initializing ${type} menu for ${nodeName}:', ${type}Error);
}`).join('')} }`).join('')}
// Platform-wide: upgrade output-format <select>s
// (process/dbase) to icon pickers. Idempotent — no-op
// for nodes whose HTML already wires the picker, and
// skips when the selects aren't present.
try {
if (window.EVOLV && window.EVOLV.iconHelpers && window.EVOLV.iconHelpers.upgradeOutputFormatSelects) {
window.EVOLV.iconHelpers.upgradeOutputFormatSelects();
}
} catch (outputUpgradeError) {
console.error('Error upgrading output-format selects for ${nodeName}:', outputUpgradeError);
}
} catch (editorError) { } catch (editorError) {
console.error('Error in main editor initialization for ${nodeName}:', editorError); console.error('Error in main editor initialization for ${nodeName}:', editorError);
} }

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) { getClientInitCode(nodeName) {
const dataCode = this.getDataInjectionCode(nodeName); const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName); const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName); const saveCode = this.getSaveInjectionCode(nodeName);
const htmlCode = this.getHtmlInjectionCode(nodeName); const htmlCode = this.getHtmlInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return ` return `
// --- LoggerMenu for ${nodeName} --- // --- LoggerMenu for ${nodeName} ---
@@ -119,13 +175,16 @@ getHtmlInjectionCode(nodeName) {
${dataCode} ${dataCode}
${eventCode} ${eventCode}
${saveCode} ${saveCode}
${visualCode}
// oneditprepare calls this // oneditprepare calls this. Visual upgrade runs last so the natives
// are already populated + wired.
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) { window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
// ------------------ BELOW sequence is important! ------------------------------- // ------------------ BELOW sequence is important! -------------------------------
this.injectHtml(); this.injectHtml();
this.loadData(node); this.loadData(node);
this.wireEvents(node); this.wireEvents(node);
if (this.initVisuals) this.initVisuals(node);
}; };
`; `;
} }

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) { getClientInitCode(nodeName) {
const htmlCode = this.getHtmlInjectionCode(nodeName); const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName); const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName); const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName); const saveCode = this.getSaveInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return ` return `
// --- PhysicalPositionMenu for ${nodeName} --- // --- PhysicalPositionMenu for ${nodeName} ---
@@ -261,12 +318,15 @@ getSaveInjectionCode(nodeName) {
${dataCode} ${dataCode}
${eventCode} ${eventCode}
${saveCode} ${saveCode}
${visualCode}
// hook into oneditprepare // hook into oneditprepare. Visual upgrade runs last so the natives
// are already populated + wired.
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) { window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
this.injectHtml(); this.injectHtml();
this.loadData(node); this.loadData(node);
this.wireEvents(node); this.wireEvents(node);
if (this.initVisuals) this.initVisuals(node);
}; };
`; `;
} }

View File

@@ -18,9 +18,36 @@ const ConfigManager = require('../configs/index.js');
const OutputUtils = require('../helper/outputUtils.js'); const OutputUtils = require('../helper/outputUtils.js');
const { createRegistry } = require('./commandRegistry.js'); const { createRegistry } = require('./commandRegistry.js');
const { StatusUpdater } = require('./statusUpdater.js'); const { StatusUpdater } = require('./statusUpdater.js');
const convert = require('../convert');
const REGISTRATION_DELAY_MS = 100; const REGISTRATION_DELAY_MS = 100;
function _buildImplicitUnitsCommand(getCommands, getNodeName) {
return {
topic: 'query.units',
payloadSchema: { type: 'any' },
description: 'Returns the unit spec (measure, default, accepted) for every topic that declares units.',
handler: (source, msg, ctx) => {
const units = {};
for (const d of getCommands()) {
if (!d.units) continue;
const accepted = (convert && typeof convert.possibilities === 'function')
? convert.possibilities(d.units.measure) : [];
units[d.topic] = {
measure: d.units.measure,
default: d.units.default,
accepted,
};
}
const reply = Object.assign({}, msg, {
topic: 'query.units',
payload: { node: getNodeName(), units },
});
if (ctx && typeof ctx.send === 'function') ctx.send([reply, null, null]);
},
};
}
class BaseNodeAdapter { class BaseNodeAdapter {
constructor(uiConfig, RED, nodeInstance, nameOfNode) { constructor(uiConfig, RED, nodeInstance, nameOfNode) {
const ctor = this.constructor; const ctor = this.constructor;
@@ -55,8 +82,18 @@ class BaseNodeAdapter {
// pumpingStation/measurement nodeClass _attachInputHandler patterns. // pumpingStation/measurement nodeClass _attachInputHandler patterns.
this.node.source = this.source; this.node.source = this.source;
this._output = new OutputUtils(); // `static alwaysEmitFields = ['ctrl', …]` on a subclass exempts those
this._commands = createRegistry(ctor.commands, { logger: this.source?.logger }); // 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
? ctor.commands
: ctor.commands.concat([_buildImplicitUnitsCommand(
() => this._commands.list(),
() => this.name,
)]);
this._commands = createRegistry(mergedCommands, { logger: this.source?.logger });
this._tickInterval = null; this._tickInterval = null;
this._outputChangedListener = null; this._outputChangedListener = null;

View File

@@ -10,7 +10,31 @@
// JSON-Schema. Anything richer belongs in the handler itself, which has // JSON-Schema. Anything richer belongs in the handler itself, which has
// access to logger via ctx. // access to logger via ctx.
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any']); const convert = require('../convert');
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any', 'none']);
function _acceptedList(measure) {
if (convert && typeof convert.possibilities === 'function') {
const list = convert.possibilities(measure);
if (Array.isArray(list) && list.length) return list.join(', ');
}
return '(see convert docs)';
}
function _describeUnit(unit) {
try { return convert().describe(unit); } catch (_) { return null; }
}
function _extractValueAndUnit(msg) {
if (!msg || typeof msg !== 'object') return null;
const p = msg.payload;
if (typeof p === 'number') return { value: p, unit: msg.unit };
if (p && typeof p === 'object' && typeof p.value === 'number') {
return { value: p.value, unit: p.unit ?? msg.unit };
}
return null;
}
class CommandRegistry { class CommandRegistry {
constructor(commands, options = {}) { constructor(commands, options = {}) {
@@ -45,10 +69,13 @@ class CommandRegistry {
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`); throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
} }
} }
const units = this._validateUnits(cmd);
const descriptor = { const descriptor = {
topic: cmd.topic, topic: cmd.topic,
aliases, aliases,
payloadSchema: cmd.payloadSchema || null, payloadSchema: cmd.payloadSchema || null,
description: typeof cmd.description === 'string' ? cmd.description : null,
units,
handler: cmd.handler, handler: cmd.handler,
}; };
this._byKey.set(cmd.topic, descriptor); this._byKey.set(cmd.topic, descriptor);
@@ -59,6 +86,17 @@ class CommandRegistry {
this._descriptors.push(descriptor); this._descriptors.push(descriptor);
} }
_validateUnits(cmd) {
if (cmd.units === undefined || cmd.units === null) return null;
const { measure, default: def } = cmd.units;
if (typeof measure !== 'string' || measure.length === 0 ||
typeof def !== 'string' || def.length === 0) {
throw new TypeError(
`command '${cmd.topic}' units requires { measure: string, default: string }`);
}
return { measure, default: def };
}
has(topic) { has(topic) {
return typeof topic === 'string' && this._byKey.has(topic); return typeof topic === 'string' && this._byKey.has(topic);
} }
@@ -75,6 +113,8 @@ class CommandRegistry {
topic: d.topic, topic: d.topic,
aliases: d.aliases.slice(), aliases: d.aliases.slice(),
payloadSchema: d.payloadSchema, payloadSchema: d.payloadSchema,
description: d.description,
units: d.units ? { measure: d.units.measure, default: d.units.default } : null,
})); }));
} }
@@ -97,6 +137,7 @@ class CommandRegistry {
return; return;
} }
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log); if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
if (descriptor.units) this._normaliseUnits(descriptor, msg, log);
if (!this._validatePayload(descriptor, msg, log)) return; if (!this._validatePayload(descriptor, msg, log)) return;
return descriptor.handler(source, msg, ctx); return descriptor.handler(source, msg, ctx);
} }
@@ -109,6 +150,40 @@ class CommandRegistry {
log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`); log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`);
} }
_normaliseUnits(descriptor, msg, log) {
const { measure, default: defaultUnit } = descriptor.units;
const extracted = _extractValueAndUnit(msg);
if (!extracted) return; // unknown shape — let payload validator handle it
let { value, unit } = extracted;
if (unit === undefined || unit === null || unit === '') {
// No unit supplied — assume default, silent.
msg.payload = value;
msg.unit = defaultUnit;
return;
}
const desc = _describeUnit(unit);
if (!desc) {
log.warn?.(`${descriptor.topic}: unknown unit '${unit}'. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`);
msg.payload = value;
msg.unit = defaultUnit;
return;
}
if (desc.measure !== measure) {
log.warn?.(`${descriptor.topic}: unit '${unit}' is ${desc.measure}, expected ${measure}. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`);
msg.payload = value;
msg.unit = defaultUnit;
return;
}
try {
msg.payload = convert(value).from(unit).to(defaultUnit);
msg.unit = defaultUnit;
} catch (err) {
log.warn?.(`${descriptor.topic}: failed to convert ${value} ${unit} -> ${defaultUnit} (${err.message}). Treating as ${defaultUnit}.`);
msg.payload = value;
msg.unit = defaultUnit;
}
}
_validatePayload(descriptor, msg, log) { _validatePayload(descriptor, msg, log) {
const schema = descriptor.payloadSchema; const schema = descriptor.payloadSchema;
if (!schema) return true; if (!schema) return true;
@@ -119,6 +194,12 @@ class CommandRegistry {
return true; return true;
} }
if (type === 'any') return true; if (type === 'any') return true;
if (type === 'none') {
if (payload !== undefined && payload !== null) {
log.warn?.(`${descriptor.topic}: payload ignored — this is a trigger-only topic`);
}
return true;
}
// typeof null === 'object' — explicit null fails an object schema. // typeof null === 'object' — explicit null fails an object schema.
if (type === 'object') { if (type === 'object') {
if (payload === null || typeof payload !== 'object') { if (payload === null || typeof payload !== 'object') {

View File

@@ -71,14 +71,22 @@ class Predict {
// Capture share-source BEFORE config validation strips it (ConfigUtils // Capture share-source BEFORE config validation strips it (ConfigUtils
// mutates the input config to drop unknown keys, which would remove // mutates the input config to drop unknown keys, which would remove
// shareInputsFrom because it's not in predictConfig.json's schema). // shareInputsFrom because it's not in predictConfig.json's schema).
// Detach on a shallow clone so validateSchema doesn't see the key at all
// — leaving it on the input would emit a `[interpolation] Unknown key
// 'shareInputsFrom'` warning per group-predictor on every curve refresh.
const _sharedSource = (config && config.shareInputsFrom instanceof Predict) const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
? config.shareInputsFrom ? config.shareInputsFrom
: null; : null;
let _initConfig = config;
if (_initConfig && 'shareInputsFrom' in _initConfig) {
_initConfig = { ..._initConfig };
delete _initConfig.shareInputsFrom;
}
// Initialize dependencies // Initialize dependencies
this.emitter = new EventEmitter(); // Own EventEmitter this.emitter = new EventEmitter(); // Own EventEmitter
this.configUtils = new ConfigUtils(defaultConfig); this.configUtils = new ConfigUtils(defaultConfig);
this.config = this.configUtils.initConfig(config); this.config = this.configUtils.initConfig(_initConfig);
// Init after config is set // Init after config is set
this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name); this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);

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

View File

@@ -23,6 +23,13 @@ class state{
this.delayedMove = null; this.delayedMove = null;
this.mode = this.config.mode.current; this.mode = this.config.mode.current;
// Monotonic counter incremented on every EXTERNAL abort (i.e. one
// initiated outside the in-flight sequence — typically MGC reacting
// to a new demand). executeSequence captures the value at entry and
// breaks its for-loop if the counter advances mid-sequence, so a
// shutdown that was already past its ramp-down step doesn't barge
// through stopping → coolingdown when a re-engage arrives.
this.sequenceAbortToken = 0;
// Log initialization // Log initialization
this.logger.info("State class initialized."); this.logger.info("State class initialized.");
@@ -151,6 +158,14 @@ class state{
if (this.abortController && !this.abortController.signal.aborted) { if (this.abortController && !this.abortController.signal.aborted) {
this.logger.warn(`Aborting movement: ${reason}`); this.logger.warn(`Aborting movement: ${reason}`);
this._returnToOperationalOnAbort = Boolean(options.returnToOperational); this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
// Only external aborts (returnToOperational=false) advance the
// sequence-abort token. Sequence-internal aborts (e.g. shutdown's
// own setpoint(0) being pre-empted by a fresher shutdown/estop)
// come from inside executeSequence and must not terminate their
// own loop.
if (!options.returnToOperational) {
this.sequenceAbortToken += 1;
}
this.abortController.abort(); this.abortController.abort();
} }
} }

View File

@@ -39,6 +39,11 @@
class stateManager { class stateManager {
constructor(config, logger) { constructor(config, logger) {
this.currentState = config.state.current; this.currentState = config.state.current;
// Wall-clock entry timestamp into currentState. Used by
// getRemainingTransitionS() so callers (e.g. MGC movement planner)
// can compute exact remaining time for timed states without
// approximating from the full configured duration.
this.stateEnteredAt = Date.now();
this.availableStates = config.state.available; this.availableStates = config.state.available;
this.descriptions = config.state.descriptions; this.descriptions = config.state.descriptions;
this.logger = logger; this.logger = logger;
@@ -63,7 +68,18 @@ class stateManager {
getCurrentState() { getCurrentState() {
return this.currentState; return this.currentState;
} }
// Seconds remaining in the current timed state (warmingup, coolingdown,
// starting, stopping, …). Returns 0 for untimed states or once the
// configured duration has elapsed. The MGC movement planner uses this to
// compute exact rendezvous time for protected (non-interruptible) states.
getRemainingTransitionS() {
const d = this.transitionTimes?.[this.currentState] || 0;
if (d <= 0) return 0;
const elapsed = (Date.now() - this.stateEnteredAt) / 1000;
return Math.max(0, d - elapsed);
}
transitionTo(newState,signal) { transitionTo(newState,signal) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (signal && signal.aborted) { if (signal && signal.aborted) {
@@ -89,6 +105,7 @@ class stateManager {
if (transitionDuration > 0) { if (transitionDuration > 0) {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
this.currentState = newState; this.currentState = newState;
this.stateEnteredAt = Date.now();
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`); resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
}, transitionDuration * 1000); }, transitionDuration * 1000);
if (signal) { if (signal) {
@@ -99,6 +116,7 @@ class stateManager {
} }
} else { } else {
this.currentState = newState; this.currentState = newState;
this.stateEnteredAt = Date.now();
resolve(`Immediate transition to ${this.currentState} completed.`); resolve(`Immediate transition to ${this.currentState} completed.`);
} }
}); });

View File

@@ -26,8 +26,11 @@ test('barrel exports expected public members', () => {
'createCascadePidController', 'createCascadePidController',
'childRegistrationUtils', 'childRegistrationUtils',
'loadCurve', 'loadCurve',
'loadModel',
'gravity', 'gravity',
'AssetResolver',
'FileBackend',
'HttpBackend',
'assetResolver',
]; ];
for (const key of expected) { 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.createPidController, 'function');
assert.equal(typeof barrel.createCascadePidController, 'function'); assert.equal(typeof barrel.createCascadePidController, 'function');
assert.equal(typeof barrel.gravity.getStandardGravity, '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

@@ -310,6 +310,126 @@ test('close handler clears tick interval, stops status, clears badge, calls sour
// ---- 13. Hook points fire when defined ------------------------------------ // ---- 13. Hook points fire when defined ------------------------------------
// ---- 14-16. Auto-wired query.units ---------------------------------------
test('implicit query.units returns measure+default+accepted for every units-declaring topic', async () => {
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [
{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
payloadSchema: { type: 'number' },
handler: () => {},
},
{
topic: 'cmd.calibrate.volume',
units: { measure: 'volume', default: 'm3' },
payloadSchema: { type: 'number' },
handler: () => {},
},
{
topic: 'set.mode',
payloadSchema: { type: 'string' },
handler: () => {},
},
];
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
const sent = [];
await node.handlers.input(
{ topic: 'query.units' },
(arr) => sent.push(arr),
() => {},
);
assert.equal(sent.length, 1);
const [p0, p1, p2] = sent[0];
assert.equal(p1, null);
assert.equal(p2, null);
assert.equal(p0.topic, 'query.units');
assert.equal(p0.payload.node, 'measurement');
const u = p0.payload.units;
assert.ok(u['set.demand'], 'set.demand entry present');
assert.equal(u['set.demand'].measure, 'volumeFlowRate');
assert.equal(u['set.demand'].default, 'm3/h');
assert.ok(Array.isArray(u['set.demand'].accepted), 'accepted is an array');
assert.ok(u['set.demand'].accepted.length > 0, 'accepted is non-empty');
assert.ok(u['cmd.calibrate.volume'], 'cmd.calibrate.volume entry present');
assert.equal(u['cmd.calibrate.volume'].measure, 'volume');
assert.equal(u['cmd.calibrate.volume'].default, 'm3');
// Topic without units does not show up.
assert.equal(u['set.mode'], undefined);
node.handlers.close(() => {});
});
test('implicit query.units returns empty units object when no command declares units', async () => {
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [
{ topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} },
];
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
const sent = [];
await node.handlers.input(
{ topic: 'query.units' },
(arr) => sent.push(arr),
() => {},
);
assert.equal(sent.length, 1);
const [p0] = sent[0];
assert.equal(p0.topic, 'query.units');
assert.deepEqual(p0.payload.units, {});
assert.equal(p0.payload.node, 'measurement');
node.handlers.close(() => {});
});
test('explicit query.units descriptor wins over the implicit auto-wired handler', async () => {
let customRan = 0;
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [
{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
payloadSchema: { type: 'number' },
handler: () => {},
},
{
topic: 'query.units',
payloadSchema: { type: 'any' },
handler: (source, msg, ctx) => {
customRan += 1;
if (ctx && typeof ctx.send === 'function') {
ctx.send([{ topic: 'query.units', payload: 'CUSTOM' }, null, null]);
}
},
},
];
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
const sent = [];
await node.handlers.input(
{ topic: 'query.units' },
(arr) => sent.push(arr),
() => {},
);
assert.equal(customRan, 1, 'custom handler must have been called once');
assert.equal(sent.length, 1);
assert.equal(sent[0][0].payload, 'CUSTOM',
'reply payload comes from the subclass-declared handler, not the implicit one');
node.handlers.close(() => {});
});
test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => { test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const trace = []; const trace = [];

View File

@@ -195,3 +195,74 @@ test('chainable API returns the router instance', () => {
.onPrediction('machine', { type: 'flow', position: 'downstream' }, () => {}); .onPrediction('machine', { type: 'flow', position: 'downstream' }, () => {});
assert.equal(r, router); assert.equal(r, router);
}); });
test('multi-parent: two routers on the same child both receive every event and tear down independently', () => {
// Regression for the pre-2026-05-11 emit-patching stack: two parents
// subscribing partial-filter wildcards on the same child must compose
// without stacking wrappers, and either teardown order must work.
const routerA = new ChildRouter(makeDomain());
const routerB = new ChildRouter(makeDomain());
const a = []; const b = [];
routerA.onMeasurement('measurement', { type: 'pressure' },
(data) => a.push(data.value));
routerB.onMeasurement('measurement', { type: 'pressure' },
(data) => b.push(data.value));
const ch = makeChild();
routerA.dispatchRegister(ch, 'measurement');
routerB.dispatchRegister(ch, 'measurement');
emitMeasured(ch, 'pressure', 'upstream', 11);
emitMeasured(ch, 'pressure', 'downstream', 22);
assert.deepEqual(a.sort(), [11, 22]);
assert.deepEqual(b.sort(), [11, 22]);
// Tear down B first — A must continue to fire on subsequent events.
routerB.tearDown();
emitMeasured(ch, 'pressure', 'upstream', 33);
assert.deepEqual(a.sort(), [11, 22, 33]);
assert.deepEqual(b.sort(), [11, 22], 'B receives nothing after its teardown');
// Now tear down A in the reverse order; neither should fire.
routerA.tearDown();
emitMeasured(ch, 'pressure', 'upstream', 44);
assert.deepEqual(a.sort(), [11, 22, 33], 'A receives nothing after its teardown');
assert.deepEqual(b.sort(), [11, 22]);
});
test('position-only filter fans out across every known type for that position', () => {
const router = new ChildRouter(makeDomain());
const hits = [];
router.onMeasurement('measurement', { position: 'upstream' },
(data) => hits.push(data.value));
const ch = makeChild();
router.dispatchRegister(ch, 'measurement');
emitMeasured(ch, 'pressure', 'upstream', 1);
emitMeasured(ch, 'flow', 'upstream', 2);
emitMeasured(ch, 'temperature', 'upstream', 3);
emitMeasured(ch, 'pressure', 'downstream', 99); // wrong position
emitPredicted(ch, 'pressure', 'upstream', 99); // wrong variant
assert.deepEqual(hits.sort(), [1, 2, 3]);
});
test('empty filter ({}) fires for every type/position combination', () => {
const router = new ChildRouter(makeDomain());
const hits = [];
router.onMeasurement('measurement', {}, (data) => hits.push(data.value));
const ch = makeChild();
router.dispatchRegister(ch, 'measurement');
emitMeasured(ch, 'pressure', 'upstream', 1);
emitMeasured(ch, 'flow', 'downstream', 2);
emitMeasured(ch, 'level', 'atequipment', 3);
emitPredicted(ch, 'flow', 'upstream', 99); // wrong variant
assert.deepEqual(hits.sort(), [1, 2, 3]);
});

View File

@@ -150,3 +150,91 @@ test('size reports 0 / 1 / 2 across the lifecycle', async () => {
await gate.drain(); await gate.drain();
assert.equal(gate.size, 0); assert.equal(gate.size, 0);
}); });
test('fireAndWait resolves when the dispatch for that value settles', async () => {
const calls = [];
const gate = new LatestWinsGate(async (v) => { calls.push(v); return `done:${v}`; });
const result = await gate.fireAndWait('a');
assert.deepEqual(calls, ['a']);
assert.equal(result, 'done:a');
});
test('fireAndWait while in-flight: caller awaits OWN settlement, not the first call', async () => {
const calls = [];
const d = deferred();
let count = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) await d.promise;
return `r:${v}`;
});
const p1 = gate.fireAndWait('first');
// p1 in flight. Park second; second's promise should resolve only
// after second's OWN dispatch runs, not after first's.
const p2 = gate.fireAndWait('second');
let p2Settled = false;
p2.then(() => { p2Settled = true; });
await Promise.resolve(); await Promise.resolve();
assert.equal(p2Settled, false);
d.resolve();
const r1 = await p1;
assert.equal(r1, 'r:first');
const r2 = await p2;
assert.equal(r2, 'r:second');
assert.deepEqual(calls, ['first', 'second']);
});
test('fireAndWait superseded by a later fireAndWait resolves with { superseded: true }', async () => {
const calls = [];
const d = deferred();
let count = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) await d.promise;
});
const p1 = gate.fireAndWait('first'); // in flight
const pParked = gate.fireAndWait('parked'); // gets superseded
const pLatest = gate.fireAndWait('latest'); // wins
d.resolve();
const supersedeRes = await pParked;
assert.equal(supersedeRes.superseded, true);
await p1;
await pLatest;
assert.deepEqual(calls, ['first', 'latest']); // 'parked' dropped
});
test('fireAndWait + fire intermix: a plain fire supersedes a pending fireAndWait', async () => {
const d = deferred();
let count = 0;
const calls = [];
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) await d.promise;
});
gate.fire('first'); // in flight, no settle
const pParked = gate.fireAndWait('parked');
gate.fire('latest'); // supersedes parked
d.resolve();
const res = await pParked;
assert.equal(res.superseded, true);
await gate.drain();
assert.deepEqual(calls, ['first', 'latest']);
});
test('fireAndWait still resolves (with undefined) when the dispatch throws', async () => {
const errors = [];
const logger = { error: (e) => errors.push(e) };
const gate = new LatestWinsGate(async () => { throw new Error('kaboom'); }, { logger });
const r = await gate.fireAndWait('only');
assert.equal(r, undefined);
assert.equal(errors.length, 1);
assert.ok(gate.lastError instanceof Error);
});

View File

@@ -34,6 +34,53 @@ test('declare returns a policy whose canonical/output match the input', () => {
assert.equal(policy.curve('control'), '%'); assert.equal(policy.curve('control'), '%');
}); });
test('canonical/output/curve are also frozen property bags (dot access)', () => {
const policy = UnitPolicy.declare(baseSpec);
// Property-access form — equivalent to the method-call form above.
assert.equal(policy.canonical.flow, 'm3/s');
assert.equal(policy.canonical.pressure, 'Pa');
assert.equal(policy.output.flow, 'm3/h');
assert.equal(policy.output.temperature, 'C');
assert.equal(policy.curve.flow, 'm3/h');
assert.equal(policy.curve.control, '%');
// Method-call form keeps working alongside it.
assert.equal(policy.canonical('flow'), 'm3/s');
assert.equal(policy.output('power'), 'kW');
});
test('canonical/output/curve property bags are frozen — no assignment / delete / redefine', () => {
'use strict';
const policy = UnitPolicy.declare(baseSpec);
// Existing own-properties are non-writable.
assert.throws(() => { policy.canonical.flow = 'tampered'; }, TypeError);
// Existing own-properties are non-configurable: delete throws.
assert.throws(() => { delete policy.canonical.pressure; }, TypeError);
// Redefining an existing prop throws.
assert.throws(
() => Object.defineProperty(policy.canonical, 'flow', { value: 'tampered' }),
TypeError
);
// Object.isFrozen reports the accessor as frozen.
assert.equal(Object.isFrozen(policy.canonical), true);
assert.equal(Object.isFrozen(policy.output), true);
assert.equal(Object.isFrozen(policy.curve), true);
// Original values survive the failed attempts.
assert.equal(policy.canonical.flow, 'm3/s');
assert.equal(policy.canonical.pressure, 'Pa');
});
test('curve property bag is present (empty) even when no curve was declared', () => {
const policy = UnitPolicy.declare({
canonical: baseSpec.canonical,
output: baseSpec.output,
});
// Method form returns null for unknown types.
assert.equal(policy.curve('flow'), null);
// Property form is an empty frozen function — accessing missing keys is undefined.
assert.equal(policy.curve.flow, undefined);
assert.equal(Object.isFrozen(policy.curve), true);
});
test('declare throws when canonical or output is missing', () => { test('declare throws when canonical or output is missing', () => {
assert.throws(() => UnitPolicy.declare({ output: {} }), /canonical/); assert.throws(() => UnitPolicy.declare({ output: {} }), /canonical/);
assert.throws(() => UnitPolicy.declare({ canonical: {} }), /output/); assert.throws(() => UnitPolicy.declare({ canonical: {} }), /output/);

View File

@@ -151,15 +151,64 @@ test('list() returns descriptors without handler functions', () => {
topic: 'set.mode', topic: 'set.mode',
aliases: ['setMode'], aliases: ['setMode'],
payloadSchema: { type: 'string' }, payloadSchema: { type: 'string' },
description: null,
units: null,
}); });
assert.deepEqual(list[1], { assert.deepEqual(list[1], {
topic: 'cmd.startup', topic: 'cmd.startup',
aliases: [], aliases: [],
payloadSchema: null, payloadSchema: null,
description: null,
units: null,
}); });
for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor'); for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor');
}); });
test("payloadSchema type 'none' invokes handler with no payload and no warning", async () => {
const logger = makeLogger();
let invoked = 0;
const reg = createRegistry([{
topic: 'cmd.calibrate',
payloadSchema: { type: 'none' },
handler: () => { invoked += 1; },
}], { logger });
await reg.dispatch({ topic: 'cmd.calibrate' }, {}, {});
await reg.dispatch({ topic: 'cmd.calibrate', payload: undefined }, {}, {});
await reg.dispatch({ topic: 'cmd.calibrate', payload: null }, {}, {});
assert.equal(invoked, 3);
assert.equal(logger._calls.warn.length, 0);
});
test("payloadSchema type 'none' invokes handler with non-empty payload but logs warn", async () => {
const logger = makeLogger();
let invoked = 0;
const reg = createRegistry([{
topic: 'cmd.calibrate',
payloadSchema: { type: 'none' },
handler: () => { invoked += 1; },
}], { logger });
await reg.dispatch({ topic: 'cmd.calibrate', payload: 'ignored' }, {}, {});
await reg.dispatch({ topic: 'cmd.calibrate', payload: { a: 1 } }, {}, {});
await reg.dispatch({ topic: 'cmd.calibrate', payload: 0 }, {}, {});
assert.equal(invoked, 3);
const warns = logger._calls.warn.filter((m) => m.includes('payload ignored'));
assert.equal(warns.length, 3);
assert.ok(warns[0].includes('cmd.calibrate'));
assert.ok(warns[0].includes('trigger-only'));
});
test('list() includes description field when present', () => {
const reg = createRegistry([
{ topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger calibration.', handler: () => {} },
{ topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} },
]);
const list = reg.list();
assert.equal(list[0].description, 'Trigger calibration.');
assert.equal(list[1].description, null);
});
test('deprecationStats reflects alias hit counts', async () => { test('deprecationStats reflects alias hit counts', async () => {
const logger = makeLogger(); const logger = makeLogger();
const reg = createRegistry([{ const reg = createRegistry([{
@@ -233,3 +282,155 @@ test('constructor throws when input is not an array', () => {
assert.throws(() => createRegistry(null), /array/); assert.throws(() => createRegistry(null), /array/);
assert.throws(() => createRegistry({}), /array/); assert.throws(() => createRegistry({}), /array/);
}); });
// ---------------------------------------------------------------------------
// descriptor.units — Phase 11 pre-dispatch normalisation pipeline
// ---------------------------------------------------------------------------
test('units: valid unit + correct measure converts to default before handler', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
}], { logger });
await reg.dispatch({ topic: 'set.demand', payload: 1, unit: 'm3/s' }, {}, {});
assert.equal(seen.length, 1);
assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6, `expected 3600, got ${seen[0].payload}`);
assert.equal(seen[0].unit, 'm3/h');
assert.equal(logger._calls.warn.length, 0);
});
test('units: wrong measure warns + lists accepted + falls back to default unit', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
}], { logger });
await reg.dispatch({ topic: 'set.demand', payload: 42, unit: 'mbar' }, {}, {});
assert.equal(seen.length, 1);
assert.equal(seen[0].payload, 42);
assert.equal(seen[0].unit, 'm3/h');
const warns = logger._calls.warn;
assert.equal(warns.length, 1);
assert.match(warns[0], /set\.demand/);
assert.match(warns[0], /'mbar'/);
assert.match(warns[0], /pressure/);
assert.match(warns[0], /volumeFlowRate/);
assert.match(warns[0], /m3\/h/); // accepted list contains the default
assert.match(warns[0], /Treating 42 as m3\/h/);
});
test('units: unknown unit warns + lists accepted + falls back to default', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
}], { logger });
await reg.dispatch({ topic: 'set.demand', payload: 7, unit: 'flarbargs' }, {}, {});
assert.equal(seen.length, 1);
assert.equal(seen[0].payload, 7);
assert.equal(seen[0].unit, 'm3/h');
const warns = logger._calls.warn;
assert.equal(warns.length, 1);
assert.match(warns[0], /unknown unit 'flarbargs'/);
assert.match(warns[0], /m3\/h/);
assert.match(warns[0], /Treating 7 as m3\/h/);
});
test('units: no unit at all — handler gets raw value tagged with default unit, silent', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
}], { logger });
await reg.dispatch({ topic: 'set.demand', payload: 12 }, {}, {});
assert.equal(seen.length, 1);
assert.equal(seen[0].payload, 12);
assert.equal(seen[0].unit, 'm3/h');
assert.equal(logger._calls.warn.length, 0);
});
test('units: object payload {value, unit} normalises the same as msg.payload+msg.unit', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'set.pressure',
units: { measure: 'pressure', default: 'Pa' },
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
}], { logger });
await reg.dispatch({ topic: 'set.pressure', payload: { value: 5, unit: 'mbar' } }, {}, {});
assert.equal(seen.length, 1);
assert.ok(Math.abs(seen[0].payload - 500) < 1e-6, `expected 500, got ${seen[0].payload}`);
assert.equal(seen[0].unit, 'Pa');
assert.equal(logger._calls.warn.length, 0);
});
test('units: object payload {value} without unit falls back to default unit silently', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'set.pressure',
units: { measure: 'pressure', default: 'Pa' },
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
}], { logger });
await reg.dispatch({ topic: 'set.pressure', payload: { value: 100 } }, {}, {});
assert.equal(seen.length, 1);
assert.equal(seen[0].payload, 100);
assert.equal(seen[0].unit, 'Pa');
assert.equal(logger._calls.warn.length, 0);
});
test('units: non-numeric payload (no normalisation applied) passes through to handler', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
handler: (_s, msg) => { seen.push(msg.payload); },
}], { logger });
// string payload — not normalisable. Should not crash; handler still fires.
await reg.dispatch({ topic: 'set.demand', payload: 'magic' }, {}, {});
assert.equal(seen.length, 1);
assert.equal(seen[0], 'magic');
});
test('units: missing default field throws at construction', () => {
assert.throws(() => createRegistry([{
topic: 'set.demand',
units: { measure: 'volumeFlowRate' },
handler: () => {},
}]), /units requires/);
});
test('units: missing measure field throws at construction', () => {
assert.throws(() => createRegistry([{
topic: 'set.demand',
units: { default: 'm3/h' },
handler: () => {},
}]), /units requires/);
});
test('units: descriptor.units surfaces in list() output', () => {
const reg = createRegistry([
{ topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: () => {} },
{ topic: 'set.mode', handler: () => {} },
]);
const list = reg.list();
assert.deepEqual(list[0].units, { measure: 'volumeFlowRate', default: 'm3/h' });
assert.equal(list[1].units, null);
});

View File

@@ -0,0 +1,90 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const convert = require('../../src/convert/index.js');
test('convert.possibilities — exported as a top-level function', () => {
assert.equal(typeof convert.possibilities, 'function');
});
test('convert.possibilities(volumeFlowRate) returns common flow units', () => {
const units = convert.possibilities('volumeFlowRate');
assert.ok(Array.isArray(units));
assert.ok(units.length > 0);
for (const u of ['m3/s', 'm3/h', 'l/s', 'l/min', 'l/h']) {
assert.ok(units.includes(u), `expected '${u}' in volumeFlowRate possibilities`);
}
});
test('convert.possibilities(pressure) returns common pressure units', () => {
const units = convert.possibilities('pressure');
for (const u of ['Pa', 'kPa', 'bar', 'mbar', 'psi']) {
assert.ok(units.includes(u), `expected '${u}' in pressure possibilities`);
}
});
test('convert.possibilities(power) returns common power units', () => {
const units = convert.possibilities('power');
for (const u of ['W', 'kW', 'MW']) {
assert.ok(units.includes(u), `expected '${u}' in power possibilities`);
}
});
test('convert.possibilities(temperature) returns K, C, F', () => {
const units = convert.possibilities('temperature');
for (const u of ['K', 'C', 'F']) {
assert.ok(units.includes(u), `expected '${u}' in temperature possibilities`);
}
});
test('convert.possibilities for length / mass / volume return non-empty', () => {
assert.ok(convert.possibilities('length').includes('m'));
assert.ok(convert.possibilities('mass').includes('kg'));
assert.ok(convert.possibilities('volume').includes('l'));
});
test('convert.possibilities(unknown) returns []', () => {
assert.deepEqual(convert.possibilities('foo'), []);
assert.deepEqual(convert.possibilities('bogus-measure'), []);
});
test('convert.possibilities handles invalid input safely', () => {
assert.deepEqual(convert.possibilities(), []);
assert.deepEqual(convert.possibilities(null), []);
assert.deepEqual(convert.possibilities(''), []);
assert.deepEqual(convert.possibilities(42), []);
});
test('convert.possibilities is sorted and deduplicated', () => {
const units = convert.possibilities('pressure');
const sorted = [...units].sort();
assert.deepEqual(units, sorted, 'result should be alphabetically sorted');
const set = new Set(units);
assert.equal(set.size, units.length, 'result should have no duplicates');
});
test('convert.possibilities returns stable / cached results across calls', () => {
const a = convert.possibilities('volumeFlowRate');
const b = convert.possibilities('volumeFlowRate');
assert.deepEqual(a, b, 'two calls must return equal arrays');
// Mutating the returned array must not poison the cache.
a.push('SHOULD_NOT_PERSIST');
const c = convert.possibilities('volumeFlowRate');
assert.ok(!c.includes('SHOULD_NOT_PERSIST'), 'cached array must be defensively copied');
assert.deepEqual(c, b);
});
test('convert.measures lists known measure names', () => {
const m = convert.measures();
assert.ok(Array.isArray(m));
for (const name of ['length', 'mass', 'volume', 'pressure', 'power', 'temperature', 'volumeFlowRate']) {
assert.ok(m.includes(name), `expected measure '${name}'`);
}
});
test('convert factory still works (regression — no breakage of existing API)', () => {
const result = convert(1).from('m').to('cm');
assert.equal(result, 100);
});

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

View File

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

266
wiki/Home.md Normal file
View File

@@ -0,0 +1,266 @@
# generalFunctions
![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.
---
## At a glance
| 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 |
---
## How it fits
```mermaid
flowchart LR
gf["generalFunctions<br/>(shared library)"]:::lib
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
gf --> ps
gf --> meas
gf --> valve
gf --> vgc
gf --> reactor
gf --> settler
gf --> monster
gf --> diffuser
gf --> dashAPI
classDef lib fill:#222,color:#fff,stroke:#444
classDef proc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
classDef util fill:#dddddd,color:#000
```
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.
---
## How to import
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).
---
## Module map &mdash; what lives where
```mermaid
flowchart TB
subgraph domain["src/domain/ — base classes"]
BD["BaseDomain.js"]
CR["ChildRouter.js"]
UP["UnitPolicy.js"]
LWG["LatestWinsGate.js"]
HS["HealthStatus.js"]
end
subgraph nodered["src/nodered/ — Node-RED adapter layer"]
BNA["BaseNodeAdapter.js"]
CMR["commandRegistry.js"]
SB["statusBadge.js"]
SU["statusUpdater.js"]
end
subgraph measurements["src/measurements/ — measurement store"]
MC["MeasurementContainer.js"]
MB["MeasurementBuilder.js"]
Meas["Measurement.js"]
end
subgraph helper["src/helper/ — shared utilities"]
LOG["logger.js"]
OU["outputUtils.js"]
CRU["childRegistrationUtils.js"]
CFG["configUtils.js"]
VAL["validationUtils.js"]
MU["menuUtils.js"]
GR["gravity.js"]
end
subgraph predict_grp["src/predict/ — curve prediction"]
PRED["predict_class.js"]
INTERP["interpolation.js"]
end
subgraph configs["src/configs/ — schema registry"]
CFGM["index.js (ConfigManager)"]
JSON["*.json — per-node schemas"]
end
subgraph math["numeric & domain utilities"]
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/"]
MM["MenuManager"]
end
subgraph constants["src/constants/"]
POS["positions.js"]
end
BD --> CR
BD --> UP
BD --> MC
BD --> CRU
BD --> LOG
BNA --> BD
BNA --> CMR
BNA --> OU
BNA --> SU
```
| 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 + 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/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 |
---
## What you'll send (the platform contract)
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:
| 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`. |
For full signatures and stability tags see [Reference &mdash; Contracts](Reference-Contracts).
---
## What you'll see come out
A node that imports `BaseNodeAdapter` automatically gets the three EVOLV ports:
| 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` |
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).
---
## Capability matrix
| 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 |
---
## Need more?
| 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 |
[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)