172 Commits

Author SHA1 Message Date
znetsixe
6d03d4301f chore: enable experimental agent teams via shared settings.json
Adds .claude/settings.json with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
so every contributor gets the `team` keyword + TeamCreate tool on clone,
without each person having to set the env var locally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:19:24 +02:00
znetsixe
d8f14610cd chore: bump dashboardAPI — Tank Layout fills card top to bottom
Canvas frame stretched vertically to match the card's aspect ratio so
the tank visual fills the entire card height with no letterboxing below.
Redundant in-canvas readouts dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:12:02 +02:00
znetsixe
473cbb6951 chore: bump dashboardAPI — basin labels inline, tank fills card width
Tank Layout visual now fills the Canvas card edge-to-edge. Each
threshold's name + value live INSIDE the tank near its line instead of
in external label columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:59:33 +02:00
znetsixe
3cc8b65b69 chore: bump dashboardAPI — Tank Layout card matches visual width
Canvas card shrunk to w:6 and frame to 400 px so the basin visual fills
the card edge-to-edge. Level/Volume timeseries widen to w:14 to absorb
the freed columns. Right value labels and bottom readouts no longer clip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:53:10 +02:00
znetsixe
b04b3bb628 chore: bump dashboardAPI — double basin row height for pumpingStation
Tank Layout canvas + bar gauge + Level/Volume timeseries grow to h:20
so the basin visual occupies more vertical space and reads at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:45:54 +02:00
znetsixe
ba4e41e640 chore: bump dashboardAPI — basin canvas + bar gauge for pumpingStation
Replaces Heights/Volume-Limits/Fill% stats with an integrated basin visual:
vertical bar gauge bound to live level + threshold markers, plus Canvas
showing tank zones, threshold lines, named labels, and live readouts.
Level + Volume timeseries reflow next to the basin in the renamed Basin row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:33:19 +02:00
znetsixe
2aafc38968 chore: bump dashboardAPI — MGC drop dead Scaling panel, show group Mode/RelDistPeak
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:48:04 +02:00
znetsixe
aaf8dd1498 chore: bump dashboardAPI — clean stat panels (dedup, value-only text, meter units)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:42:57 +02:00
znetsixe
d830a6a991 chore: bump dashboardAPI — string fields render in stat panels (reduceOptions.fields)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:09:34 +02:00
znetsixe
cb42740ee1 chore: bump dashboardAPI — resolve Grafana folder by name (stale folderUid fix)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:02:50 +02:00
znetsixe
c529819696 chore: bump submodules — MGC per-pump OFF sentinel + PS m³/s canonical / m³/h output
- machineGroupControl f18f3cc: fn_chart_pump_a/b/c emit -1 OFF sentinel on the
  per-pump % control chart when state is off/idle/maintenance; ui_chart_pumps_ctrl
  ymin=-5; new per-pump-ctrl-fanout output-coverage test + manifest update.
- pumpingStation e041877: revert canonical flow to m³/s (platform convention),
  keep output m³/h for dashboard parity. No demand smoothing/hysteresis — that
  belongs in a dedicated intermediate node per design review.

Also cleaned stale InfluxDB series (out-of-tree): dropped "MGC Isolated" and
"MGC — Pump Group" measurements and the category="undefined" rows in
"Machine Group" (135,416 stale rows; live untagged data preserved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:10:23 +02:00
znetsixe
62ad5605e8 chore: bump MGC (rendezvous lock + emergency bypass) + gF (emergencyPressurePa config)
machineGroupControl 2af6c90, generalFunctions 5c091cd. Rendezvous lock verified
live on the isolated rig: clean monotonic 1→2 pump staging, no wait/hunt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:48:06 +02:00
znetsixe
2562ed2e9f chore: bump machineGroupControl — just-in-time startup (no staging flow bump) + fn_status_split output-17 coverage
machineGroupControl f41e319 (movementScheduler just-in-time start, dashboard
fan-out output-17 coverage, manifest fan-out table).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:24:45 +02:00
znetsixe
52c091bd92 chore: bump submodules — MGC movement gate + demand telemetry, gF tag/alwaysEmit/movement fixes, RM ctrl emit, PS m³/h, dashboardAPI slice47 panels
Bumps: generalFunctions c0be50d, machineGroupControl b59d8e6,
rotatingMachine 889221f, pumpingStation 8216480, dashboardAPI 5533293.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:10:04 +02:00
00a6fc5306 chore: bump dashboardAPI for slice #43 output-coverage manifest
Refs #43
2026-05-26 18:08:56 +02:00
d1412bc53c chore: bump dashboardAPI for slice #42 example flow round-trip
Refs #42
2026-05-26 18:06:59 +02:00
d036646060 chore: bump dashboardAPI for slice #41 manual regen
Refs #41
2026-05-26 18:05:40 +02:00
8224d15d51 chore: bump dashboardAPI for slice #40 MGC template
Refs #40
2026-05-26 18:03:33 +02:00
b3e24175de chore: bump dashboardAPI for slice #39 no-duplication rule
Refs #39
2026-05-26 18:02:03 +02:00
9a9bfdafc1 chore: bump dashboardAPI for slice #38 dashed-bounds overrides
Refs #38
2026-05-26 18:00:48 +02:00
52bf827e9d chore: bump dashboardAPI for slice #37 emittedFields metadata
Refs #37
2026-05-26 17:59:43 +02:00
f867929634 chore: bump dashboardAPI submodule for slice #36 diff-skip regen
Refs #36
2026-05-26 17:57:39 +02:00
042a5cc4ba chore: bump dashboardAPI submodule for slice #35 perf + uid tests
Refs #35
2026-05-26 17:55:45 +02:00
a65cdc3562 chore: ship slice #34 — dashboardAPI walking skeleton + Grafana pin
- Bumps nodes/dashboardAPI submodule to slice/34-walking-skeleton@7fdab73
  (credentials block for bearer token, folderUid config field, basic tests).
- Pins grafana/grafana to 11.3.0 — legacy /api/dashboards/db is the
  generator target; G12 K8s-style API is out of scope (PRD constraint).

Refs #34
2026-05-26 17:53:58 +02:00
14140725bc chore: workflow artifacts — research brief + dashboardAPI v2 PRD + submodule bumps
Bumps machineGroupControl (e1e1977) and pumpingStation (ef07f2a) — example
dashboard JSON tweaks committed on each submodule's development branch.

Adds docs/research/ and docs/prd/ for the dashboardAPI v2 graph-aware Grafana
generator workflow (Gitea issues #32-#43). Ignores .prototypes/ — throwaway
spike code lives there per the /prototype skill.
2026-05-26 17:32:20 +02:00
znetsixe
3f84b91afb chore(submodules): bump 5 nodes — UnitPolicy rollout + buffered fixes
Bumps:
- rotatingMachine    455f15d  refactor: route unit conversions through UnitPolicy.convert
- pumpingStation     2d68a4f  refactor + fix(level): UnitPolicy adoption, level-rate timestamp fix, integration test rewire
- machineGroupControl ddf2b07  refactor: _canonicalToOutputFlow + setDemand via UnitPolicy.convert, structure test rewire
- generalFunctions   bc79de1  fix(influx): accept tagCode camelCase + emit positionVsParent tag
- measurement        36eaa2f  test(edge): align with object-payload accept behaviour

The UnitPolicy bump finishes the §6 contract migration the refactor
plan named (drop _convertUnitValue / hardcoded m3/h<->m3/s scalars in
favour of policy.convert at every site).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:30:45 +02:00
znetsixe
8c3d3ac69a feat(dashboard): verifiable CoreSync FROST demo + bump coresync submodule
Replaces the old 3-panel coresync-frost-demo.json with a 13-panel dashboard
designed for at-a-glance verification of CoreSync's compression behaviour.

Dashboard rebuild (docker/grafana/provisioning/dashboards/coresync-frost-demo.json):
- Header "How to read" text panel: definitions table + sanity checks so
  every metric is line-of-sight to its Flux source.
- Scoreboard row (4 stats): raw samples / CoreSync knots / reduction % /
  approx. bytes saved over the selected time range.
- Per-stream verification table: one row per CoreSync stream with raw,
  knots, and reductionPct (gradient-coloured). Each line's math is
  mentally checkable: raw × (1 − reductionPct/100) = knots.
- Signal-reconstruction overlays: flow (m³/h) and pressure (mbar)
  rendered as a thin raw line plus fat red knot points so you can see
  knots snap to the raw signal at direction changes. Fixes the previous
  panels which mislabelled both as `flowm3h` regardless of units.
- Diagnostics row: per-stream knot-interarrival timeseries and a
  full-math compression-health table (raw, knots, kept fraction with
  gradient bar, savedPct with colour background).

Bumps coresync submodule to 21d77a8 which lands the FROST demo flow plus
the burst-window reducer fix that was driving cog/efficiency/SEC to ~0%
compression. Verified end-to-end on the live stack: headline reduction
went from 33% to 83%, broken streams from 0.6%-14% to 78%-93%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:27:59 +02:00
znetsixe
fcaad8cd9f chore(skills): add /research and /prototype; rewrite README for 6-skill chain
Front-loads gap discovery before /grill-me by adding two skills:

  research    MOSTLY  fans out Explore + WebSearch agents in parallel,
                      synthesizes findings into a brief, names open
                      unknowns explicitly (which become /prototype targets)

  prototype   MOSTLY  builds a throwaway spike to test ONE falsifiable
                      assumption; code lives in .prototypes/ (gitignored),
                      never promoted; output is evidence — verdict, numbers,
                      observed behavior — that feeds /prd

Full chain now:
  /research → /prototype → /grill-me → /prd → /prd-to-issues → /ship-it

Chain rationale: /research and /prototype surface knowledge gaps and falsify
risky assumptions while the cost of changing direction is still cheap; the
TOGETHER phases (grill-me, prd) lock down the contract; the AFK phase
(ship-it) only executes against contracts already on paper.

The chain is a default, not a mandate — README covers when to skip
upstream skills for small or stack-familiar work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:33:26 +02:00
znetsixe
6ff262e96e chore(skills): add workflow chain — grill-me → prd → prd-to-issues → ship-it
Four workflow skills that take a feature from fuzzy idea to merged code.
Two human-in-the-loop phases (grill-me, prd), one mostly-together (prd-to-issues
files only on explicit 'create'), and one AFK (ship-it).

  grill-me        TOGETHER  pressure-test the idea with hard interview questions
  prd             TOGETHER  synthesize PRD; gaps stay explicit, not papered over
  prd-to-issues   MOSTLY    thin vertical-slice issues with coverage matrix +
                            per-issue Slice check; self-audits before showing
  ship-it         AFK       shell loop ships each slice end-to-end with one
                            commit per issue, status streams to terminal,
                            Ctrl-C-able, survives session close

Vertical-slice principle throughout: every issue cuts end-to-end through every
integration layer (no horizontal "do all the DB work first" issues). The
AFK loop only ships against acceptance criteria already locked in by the PRD
phase — autonomous code never runs against undefined contracts.

ship-it tracker support: gh (GitHub) and tea (Gitea). For this repo, set
SHIP_IT_TRUNK=development to override the main default.

See .claude/skills/README.md for the full how-to and a worked example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:27:15 +02:00
znetsixe
025bdb4c7e release: palette redesign + CoreSync scaffolding + dashboardAPI MODULE_NOT_FOUND fix
PALETTE REDESIGN (2026-05-21)
  Sidebar swatches switched from S88 level (all blue) to domain-hue per node.
  Family hue = function (rotating=orange, valves=teal, biology=green/olive,
  sampling=violet, sensor=amber, aeration=sky-blue, infrastructure=slate);
  within a family, darker = higher S88 / "more controller-ish."
  Editor-group rectangles in flow.json still follow S88 — only the
  registerType colour changed.

  Submodule bumps for palette: rotatingMachine, machineGroupControl,
  pumpingStation, valve, valveGroupControl, reactor, settler, monster,
  measurement, diffuser, dashboardAPI.

  Docs touched:
    - CLAUDE.md: palette swatch vs. editor-group bullets split out.
    - .claude/rules/node-red-flow-layout.md: new §10.0 introduces the two
      color systems, full 12-row palette table, and explicit warning not to
      mix the two hexes.
    - .claude/refactor/MODULE_SPLIT.md: per-node headers annotated with
      both `group #XXX` and `palette #XXX`.
    - .claude/refactor/WIKI_HOME_TEMPLATE.md + WIKI_TEMPLATE.md: clarify
      Mermaid classDefs visualize hierarchy, not palette swatches.
    - .claude/refactor/OPEN_QUESTIONS.md: dated decision entry with
      rationale, file list, and follow-ups.

CORESYNC SUBMODULE (new)
  nodes/coresync added pointing at https://gitea.wbd-rd.nl/RnD/coresync.
  FROST/SensorThings handoff path — first version forwards FROST-ready HTTP
  request messages on the dbase output; a downstream http-request node
  performs the POST and feeds responses back on msg.topic = "frost.response".
  Lazy stream resolver, latest-wins queue (keep first + latest, drop middle),
  knot-emit on slope change, provenance preserved in Observation parameters.

    - .gitmodules: add nodes/coresync entry.
    - package.json: register coresync as a Node-RED node.
    - generalFunctions bump: new frostFormatter + 4 node config schemas
      expose the dbase format option.
    - measurement bump: "frost" option added to dbaseOutputFormat dropdown
      (plus the in-flight data.measurement unit-handling work).
    - machineGroupControl bump: small editor compact-fields tweak alongside
      the palette change.
    - CORESYNC_FROST_INTERVIEW_HANDOFF.md added at root with interview state
      (Q20 open: slope angle vs. relative delta comparison).

DASHBOARDAPI MODULE_NOT_FOUND FIX
  package.json: dashboardapi entry path corrected to
  nodes/dashboardAPI/dashboardAPI.js. Commit e04c4a1 renamed the files to
  camelCase but missed package.json; on case-sensitive filesystems
  (Linux/Docker, where the tarball lands) the require resolved to nothing
  and the node showed MODULE_NOT_FOUND in the Node-RED palette.

MISC CLEANUP
  - examples/README.md + examples/pumpingstation-complete-example/ removal
    (build_flow.py, flow.json, README.md superseded by per-node examples).
  - jest.config.js: in-progress tweak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:09:33 +02:00
znetsixe
1a9d0477bf chore: bump rotatingMachine submodule + gitignore local Claude artifacts
Submodule pointer:
- rotatingMachine @ 8c5822c
    style(editor): drop fixed max-width on rotor SVG — let it fill the panel

Repo hygiene:
- Add .repo-mem/, .codex, CLAUDE.local.md to .gitignore. These are
  per-developer Claude Code state (memory store, IDE marker, per-machine
  conventions) that shouldn't ever land in the repo. .repo-mem alone is
  hundreds of MB on disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:32:13 +02:00
znetsixe
f65ecab850 build: add root .npmignore — 175 MB → 1.6 MB pack
The root EVOLV package was 175.8 MB because npm pack at the parent walks
the full file tree and IGNORES per-submodule .npmignore files. The
.gitignore fallback wasn't enough — it left .repo-mem/ (838 MB on disk),
.claude/, .agents/, .codex, every submodule's wiki + tests + simulation
harness, and the per-submodule CLAUDE.md files in the tarball.

This .npmignore mirrors .gitignore for the dev-artifact baseline and then
adds two more layers:
  1. Repo-level dev tooling that .gitignore doesn't cover (tools/,
     docker/, scripts/, test/, wiki/, .repo-mem/, .claude/, …).
  2. Per-submodule dev-only trees under nodes/*/ — necessary because npm
     pack at the root doesn't honour the submodule's own .npmignore.

After: 1.6 MB tarball, 498 files, runtime content only (entry .js + .html,
src/, package.json, examples/, generalFunctions datasets + coolprop.wasm,
per-node README/LICENSE/CONTRACT). Removes the
"npm warn gitignore-fallback" warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:26:26 +02:00
znetsixe
056f4a8d3e fix: bump 3 submodules — levelBased hold-zone bug, MGC unit math, schema + slim npm packs
Submodule pointer updates:
- generalFunctions @ ae30cef
    feat(pumpingStation schema): add holdLevel + deadZoneKeepAlivePercent;
    slim npm pack
- machineGroupControl @ aeb938c
    feat(setDemand): surface specificClass.setDemand(value, unit='%')
    + slim npm pack
- pumpingStation @ 2e4ad8d
    fix(levelBased): drop hold zone, route through MGC.setDemand, add
    holdLevel + integrator variant pick; slim npm pack

Cross-submodule summary:
- pumpingStation level-based control now sends percent demand to MGC via
  the new MGC.setDemand entry point — was calling handleInput with a raw
  percent, which the dispatcher interpreted as canonical m³/s and pegged
  the group at 100 %.
- Ramp foot is no longer pinned at inflowLevel. Default is startLevel
  (0 % at startLevel = MGC flow.min, matching operator mental model). New
  optional holdLevel raises the 0 %-foot for an explicit hold band.
- Predicted-volume integrator now picks the best-available variant per
  side (measured first, then predicted) so a real upstream sensor +
  predicted pump outflow both feed the basin balance.
- Each submodule grew a .npmignore mirroring its .gitignore plus the
  dev-only trees (test/, wiki/, .claude/, …). Per-submodule pack sizes
  dropped — pumpingStation went 1.5 MB → 57 kB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:22:52 +02:00
znetsixe
cdf517cba3 chore: stage forgotten .mcp.json deletion + bump pumpingStation pin
Two trailing items the prior commits missed:

- .mcp.json was rm'd from the working tree in commit d4e72f2 (repo-mem
  cleanup) but never staged for deletion; git status kept showing
  ' D .mcp.json' as a result. Properly removed now.
- nodes/pumpingStation pin bumps to pull in the wiki-gen regen commit
  that kept Reference-Contracts.md in sync with the tool's canonical
  output.

User WIP intentionally not touched: examples/README.md zeroed-out,
examples/pumpingstation-complete-example/* removed, and
nodes/rotatingMachine working-tree change to rotatingMachine.html.
Those belong to the user; the harness only re-flagged them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:12:40 +02:00
znetsixe
5e2c01ece3 docs + submodules: final backlog clearance — valve/reactor/dashboardAPI
Superproject:
- CLAUDE.md: legacy-drift table loses the dashboardAPI row (migrated);
  drift section notes the type-id-preservation strategy for the
  remaining mgc / vgc renames.
- CONTRACTS.md: canonical-unit rule explicitly carves out reactor as
  an approved ASM-textbook exception with the conversion boundary.

Submodules:
- nodes/valve @ 167b102: CONTRACT documents valve's lack of an FSM
  maintenance state (schema mode enum accepts `maintenance` but no
  enter/exit sequences exist). Limits made explicit instead of being
  hidden as a wiki TODO.
- nodes/reactor @ 75d0413: CONTRACT now declares the approved ASM-unit
  divergence (mg/L, m³/d, °C, 1/h) with the conversion boundary spelled
  out. Closes the canonical-unit drift surfaced by the wiki audit.
- nodes/dashboardAPI @ ......: file rename (preserves type id).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:37:05 +02:00
znetsixe
424ad1e293 fix: bump 6 submodules — dead config purged, set.mode descriptions truthful
- nodes/generalFunctions @ 8252a5f: schemas drop dead `calculationMode`
  (RM/valve/VGC — never read) and dead `allowedActions` (valve/VGC —
  flowController only checks isValidSourceForMode, never the action
  allow-list). Kept allowedActions in RM/MGC where it IS enforced.

- nodes/{rotatingMachine,machineGroupControl,valve,valveGroupControl}:
  set.mode descriptions in commands/index.js were generic "auto /
  manual" but each node's schema declares 3–4 specific modes. Now
  enumerate the actual allowed values and point at the schema.

- nodes/machineGroupControl CONTRACT.md additionally fixed: old
  `prioritycontrol`, `optimalcontrol`, `dynamiccontrol` list was wrong
  (lowercase + nonexistent dynamiccontrol); now matches the schema.

- nodes/monster: reframed from "multi-parameter biological process
  monitor" to "sampling-cabinet pulse counter" — the code only emits
  volumetric pulse + bucket state, no constituent analysis. Old
  framing was misleading. examples/README also cleaned of references
  to 4 flow files that don't exist on disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:06:07 +02:00
znetsixe
cc8e68a023 fix: bump 6 submodules — backlog of safe drifts cleared
- machineGroupControl: CONTRACT.md drops stale set.scaling row
- monster: CONTRACT.md adds child.register row (was in registry only)
- settler: CONTRACT.md adds child.register row; node colour
  #e4a363 → #50a8d9 (S88 Unit blue per flow-layout rule §16)
- pumpingStation: CONTRACT.md adds set.outflow row (was in registry only)
- diffuser: test/README stops claiming "no runtime files" (it has them)
- dashboardAPI: logging.enabled default true (HTML + _buildConfig)
  now matches the dashboardapiConfig.json schema; coercion bug fixed

contract-verify now reports OK across all 11 nodes (was 4 drifts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:37 +02:00
znetsixe
429d4b01ed fix(reactor): bump submodules — timeStep unit + X_A_init operational default
Two cross-submodule fixes for the reactor unit-confusion drift the wiki
uplift surfaced:

- nodes/generalFunctions @ 4f715e8: reactor schema timeStep.unit now "s"
  (was "h"), default 1 (was 0.001). Matches reactor.html label [s] and
  baseEngine.js's seconds→days conversion. Description warns future
  readers off changing the unit without updating the engine.

- nodes/reactor @ 346a3ce: HTML X_A_init default 0.001 → 200 (operational
  nitrifying biomass; 0.001 disabled nitrification per the memory note).
  Factory updated to match. New regression test
  test/basic/timestep-units.basic.test.js locks in the seconds contract
  and schema agreement. 49/49 reactor tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:01:21 +02:00
znetsixe
6e6699c763 tools: add physics-sanity + Docker MCP scaffolding + tools/README
- tools/physics-sanity/ — JS library of cross-node balance helpers
  (mass / hydraulic / hydraulic-power / oxygen-transfer / energy) with
  7 unit tests + a CLI demo. Designed for `require()` from per-node
  integration tests where shape-based unit tests miss physically-
  impossible plant states.
- tools/docker-compose.yml + tools/mcp/{node-red-admin,influxdb,browser}
  scaffolding — placeholder Dockerfiles + a ROADMAP.md for the Node-RED
  admin MCP. Compose file is the target shape for the Q3-2026 migration
  to the central MCP server; the per-service Dockerfile stays in this
  repo as the canonical definition either way. Implementations are TODO.
- tools/README.md — top-level tooling index; documents the CI order for
  running every tool on a PR.
- .gitignore: ignore tools/.env (developer-specific MCP endpoints).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:16:47 +02:00
znetsixe
edef1cecbf tools: add output-manifest-verify; extend flow-lint with fan-out checks
- tools/output-manifest-verify/ — enforces .claude/rules/output-coverage.md
  §3: every node ships test/_output-manifest.md and every declared key
  is referenced by at least one test file. First run shows only
  machineGroupControl has the manifest (16 keys covered); all other nodes
  warn. --strict escalates "missing manifest" to an error for CI gating.
- flow-lint gains two rules from the same output-coverage rule:
  * FN_OUTPUT_WIRES_MISMATCH — function declares outputs=N but wires has
    M arrays (causes silent dropped or duplicate emissions).
  * FN_PAYLOAD_NULL_LITERAL — function source contains `payload: null`
    literal (the η-null ui-chart crash pattern from 2026-05-14).
  First run found 1 instance in mgc/02-Dashboard.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:13:49 +02:00
znetsixe
ecd466f7a3 docs: bump 8 submodules — wiki-gen regenerated topic-contract tables
Replaces the placeholder topic tables in Reference-Contracts.md (left by
the wiki uplift agents) with authoritative content generated from each
node's src/commands/index.js. CI gate: `node tools/wiki-gen/bin/wiki-gen.js --check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:12:00 +02:00
znetsixe
b01b3de741 tools: add wiki-gen — regenerates topic-contract AUTOGEN blocks
Generates the markdown table inside <!-- BEGIN AUTOGEN: topic-contract -->
blocks in nodes/<n>/wiki/Reference-Contracts.md from the canonical registry
at src/commands/index.js. Replaces the agent-written placeholders the wiki
uplift left behind.

- Accepts both labelled and unlabelled END markers; rewrites to canonical
  '<!-- END AUTOGEN: topic-contract -->' on regeneration so future runs are
  consistent.
- --check mode for CI (exit 1 if any block is out of date).
- Out of scope for now: data-model AUTOGEN block (requires instantiating
  the domain; the 9 agent-written placeholders for that block stay until
  a follow-up tool lands).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:45 +02:00
znetsixe
e15f402d47 docs: bump 9 submodules with new 5-page wiki structure
Each node's wiki now matches the rotatingMachine reference: Home +
Reference-{Architecture,Contracts,Examples,Limitations} + _Sidebar.
Total ≈ 9 000 lines of wiki content. Topic-contract and data-model
sections wrapped in <!-- BEGIN AUTOGEN --> markers for the future
wiki-gen tool.

Each agent surfaced real source-vs-spec drift rather than inventing
content:

- measurement: digital-mode notifyOutputChanged path uncertain;
  buildDomainConfig drops several editor fields; position case
  preserved by child.register payload
- valve: flowController gates by source only (action allow-list dead);
  no enter/exit maintenance sequences; sequence-abort token status TBD
- reactor: timeStep unit confusion (HTML s, schema h, engine /86400);
  X_A default disagreement (0.001 vs 200 mg/L); doesn't honour the
  canonical-unit rule (m³/d not m³/s, °C not K)
- valveGroupControl: 4 CONTRACT/source drifts incl. set.mode covers
  4 modes not 2, calculationMode + mode.allowedActions dead config,
  maintenance source allow-list undefined
- settler: icon colour #e4a363 conflicts S88 Unit #50a8d9; Port 2
  emits null in _emitOutputs (init-only registration)
- diffuser: stale test/README claiming "no runtime" — runtime exists
  (284-line specificClass with full OTR/ΔP model); update README
- monster: superproject docs frame as multi-parameter biological
  monitor (NH4/NO3/COD/TSS), but source emits only volumetric pulse +
  bucket state; 4 example flows referenced but missing on disk
- dashboardAPI: dashboardapi.{js,html} lowercase entry vs folder
  convention; logging.enabled default mismatch (false vs true)
- generalFunctions: full library API restructure preserved; module
  map + consumer graph + extension examples retained

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:43:25 +02:00
znetsixe
3ff75fcb09 tools: add contract-verify and flow-lint (JS native, repo-rule-aware)
- tools/contract-verify/ — diffs CONTRACT.md ## Inputs table vs
  src/commands/index.js registry. First run found 3 real drifts:
  MGC has `set.scaling` in CONTRACT (not in registry); monster + settler
  registry has `child.register` (not in CONTRACT); pumpingStation registry
  has `set.outflow` (not in CONTRACT).
- tools/flow-lint/ — lints examples/*.flow.json against the rules in
  .claude/rules/node-red-flow-layout.md. First run flagged the
  monster/basic flow (4 ui-* at 0,0 + ui-chart missing interpolation
  property) and rotatingMachine/edge.flow.json (6 ui-* at 0,0).
- Both tools are read-only, single-binary npm packages with a `--json`
  output mode for CI, exit code 1 on drift. Encode the rules so we
  don't have to re-discover the bugs that motivated them.

Per CLAUDE.md tooling doctrine: prefer these over ad-hoc grep/jq.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:53 +02:00
znetsixe
d4e72f280e docs: retire repo-mem MCP, migrate skills to .claude/skills, audit fixes
- Delete .mcp.json + .claude/rules/repo-mem.md; drop .repo-mem from .gitignore
- Remove repo-mem / substrate_score / repo_search references from all .md
- Move 15 EVOLV skills from .agents/skills/ to .claude/skills/ so they are
  auto-discovered by the Claude Code harness and invokable via the Skill tool
- Retire .agents/skills/evolv-orchestrator (duplicate of the subagent at
  .claude/agents/evolv-orchestrator.md); orchestrator lives as a subagent only
- Drop OpenAI-format agent yaml metadata from each skill (not needed for CC)
- Update CLAUDE.md, CONTRACTS.md, AGENTS.md to point at the new locations and
  disambiguate skills (.claude/skills/) vs subagents (.claude/agents/)
- Fix CLAUDE.md tick-loop wording (opt-in per-node, not a fixed 1000ms)
- Widen .claude/rules/ paths frontmatter so node-architecture and telemetry
  rules trigger on more relevant files; add frontmatter to flow-layout rule
- Bump CONTRACTS.md review date to 2026-05-19; add step 7 to the contract-
  change workflow (review example flows when topic usage changes)
- Bump nodes/generalFunctions pin (Home.md substrate_score reference removed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:30:49 +02:00
znetsixe
b1e0736e8e docs: propagate folder-naming convention + bump submodules for editor refresh
Convention:
* CLAUDE.md (root): new "Folder & File Layout (READ BEFORE CREATING NEW FILES)"
  section with required-name table and explicit legacy-drift list (mgc, vgc,
  dashboardapi).
* .claude/rules/node-architecture.md: file-naming convention + src/editor/
  module layout sections; serving recipe for /<nodeName>/editor/:file.

Submodule bumps:
* generalFunctions: shared output-format picker, redesigned position SVGs,
  tighter asset wizard, restored curve preview size.
* rotatingMachine: pump banner, circular state diagram, mode icon cards,
  picker integration, CLAUDE.md update.
* 10 others: per-node CLAUDE.md "Folder & File Layout" sections — 3 of
  them (machineGroupControl, valveGroupControl, dashboardAPI) carry inline
  warnings about their entry-filename drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:50 +02:00
Rene De Ren
253ac93896 docs: standards cleanup — single front-door CONTRACTS.md + archive stale plan artifacts
Establish CONTRACTS.md at the EVOLV root as the canonical map of where every
contract, rule, and standard lives. Surface it from CLAUDE.md so every fresh
agent or colleague lands there first.

Reshape .claude/refactor/ to reflect that the platform refactor is done:
live standards stay at the top level; the plan artifacts (CONTINUE_HERE.md,
TASKS.md) move into Archive/ with WARNING banners.

Drop content that drifted out of date or duplicated the new standards stack:
- docs/DEVELOPER_GUIDE.md (pre-refactor walkthrough; superseded by
  wiki/Architecture, wiki/Getting-Started, .claude/rules/node-architecture,
  .claude/refactor/MODULE_SPLIT + per-node CONTRACT.md + src/commands/).
- .agents/decisions/ (15 DECISION files): load-bearing decisions belong in
  commit messages and PR descriptions; live open items in OPEN_QUESTIONS.md.
- .agents/improvements/TOP10_*.md: moved to Archive/.

Bump generalFunctions to 49c77f2 — adds CONTRACT.md inside the library:
different shape from per-node CONTRACT.md files (library API, not msg.topic),
with stability tags and pointers to .claude/refactor/CONTRACTS.md §N.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:48:46 +02:00
znetsixe
560cc2f39a bump submodules: shared icon-picker visuals + asset wizard
* generalFunctions @ 34a4ef0 — new iconHelpers module + initVisuals
  step on logger/position menus; asset selector rebuilt as a chip
  wizard with per-stage type-to-filter combobox and node-aware curve
  mini-chart (rotatingMachine Q-H, valve Cv, diffuser SOTE).
* machineGroupControl @ 6833e9f — consumes the shared visuals;
  strategy/rendezvous cards keep their MGC-local SVGs; maintenance
  switched to FA fa-wrench. Output-format pickers now use the shared
  .evolv-icon-picker classes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:11:07 +02:00
znetsixe
4b6579a820 bump submodules: MGC rendezvous planner (same-time landing across modes)
- machineGroupControl @ 472402c — rendezvous planner extracted into
  src/movement/. Every dispatch (both optimalControl and priorityControl)
  routes through a shared _dispatchFlowDistribution helper so all pumps
  reach their setpoint at t* = max(eta_i) regardless of per-pump speed.
  New "Same-time landing" toggle in the editor (planner.useRendezvous,
  default true) for operators who want the legacy fire-and-forget.
- generalFunctions @ af02d36 — new planner.useRendezvous schema field
  and stateManager.getRemainingTransitionS() that the planner reads to
  compute exact eta for children mid-ladder.
- rotatingMachine @ 5ea0b0b — sequenceController honors the new
  sequenceAbortToken so a pre-empted sequence (e.g. shutdown caught
  mid-ramp by a fresh demand) cleanly breaks out instead of barging
  through to its terminal state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:45:07 +02:00
znetsixe
1ab913b699 bump submodules: generalFunctions + pumpingStation + rotatingMachine
- generalFunctions f8f71a4: schema additions (output.process/dbase,
  functionality.distance, drop prioritypercentagecontrol), measurement
  position.x nullable, asset-data file renamed machine.json ->
  rotatingmachine.json so AssetMenu lookup matches, menu re-derives
  supplier/assetType from saved model id on reopen.
- pumpingStation 2c7fe17: setDemand reads unit-normalised payload from
  commandRegistry (mirrors today's MGC change to unit-self-describing
  demand commands). Pre-existing test failure (stale path to
  basic-dashboard.flow.json, renamed to 02-Dashboard.json in fe5fa35) is
  unrelated to this commit.
- rotatingMachine 394a972: η = (Q·ΔP)/P_shaft replaces the legacy Q/P
  formula — gives a real BEP peak so NCog stops collapsing to 0 and the
  MGC dashboard's BEP-position metric actually moves. Asset-registry
  lookup renamed machine -> rotatingmachine (matches generalFunctions
  rename). Constructor stateConfig pass-through fixed (default-param was
  clobbering BaseNodeAdapter's pre-set extras). + 2 new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:52:53 +02:00
znetsixe
18f68aa5da bump machineGroupControl: governance + unit-self-describing demand (26e92b5)
MGC submodule lands the 2026-05-14 governance review fixes plus rolled-up
session work: _output-manifest.md per the new output-coverage rule,
computeEqualFlowDistribution extracted as a pure function (testable without
MGC), groupEfficiency degenerate-case fix, unit-self-describing set.demand,
eta = (Q*dP)/P formula correction, and dashboard fan-out hardening
(auto-init, NCog normalization, Q-H trim, null-trap closure). Suite 108/108.

Superproject adds:
- .claude/rules/output-coverage.md: every-output-every-state testing rule
  prompted by the eta-null crash earlier in the session.
- CLAUDE.md: pointer to the new rule under Conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:32:39 +02:00
znetsixe
9924e66249 bump diffuser + generalFunctions: canonical Nm³/(h·m² membrane) axis
Follow-up to the AssetResolver landing. All five diffuser supplier
curves now share one X-axis convention; diffuser specificClass
computes specific flux from total flow + membrane area and queries the
curves at that flux. Each curve file carries its own
_meta.membraneArea_m2_per_element so the node defaults are correct
without any per-node overrides.

Supplier naming fixed: Sulzer (PIK300/PRK300), Aquaconsult-Entec
(Aerostrip Phoenix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:17:08 +02:00
znetsixe
edc91dd988 bump submodules: AssetResolver landing + diffuser supplier curves
Coordinated cutover across five submodules to the generalFunctions
asset registry. Highlights:

- generalFunctions: AssetResolver namespace + FileBackend, with new
  diffuser supplier curves (GVA migrated, Jäger JetFlex EPDM-1000,
  Aerostrip Phoenix multi-coverage, PIK300/PRK300 multi-coverage).
  Diffuser config schema corrected: density was always meant to be
  bottom-coverage %, not elements/m².
- diffuser: _loadSpecs reads from the registry; editor wired with the
  shared asset cascade (supplier → type → model → unit).
- rotatingMachine + valve: derive supplier/type/units from the model
  id via resolveAssetMetadata; reject saved legacy fields with a clear
  re-save prompt.
- machineGroupControl: integration fixtures use the trimmed asset
  shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:13:37 +02:00
znetsixe
3b192bec63 bump machineGroupControl: editor defaults + mode-case fix + new example flows (4cb9c50)
Surfaces mode/scaling in the editor, fixes the camelCase-vs-lowercase
mismatch that silently disabled dispatch on default config, compacts the
status badge, extends getOutput with capacity / machine-count fields, and
replaces the pre-refactor example stubs with 01-Basic.json (MGC + 3
pumps + setup) and 02-Dashboard.json (FlowFuse dashboard with charts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:24:16 +02:00
znetsixe
5eafd83443 bump generalFunctions: deep-merge in buildConfig (84a4430)
Fixes MGC child-registration id collision and rotatingMachine
curve-lookup failure caused by the shallow Object.assign in
ConfigManager.buildConfig wiping general.id and asset.model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:06:50 +02:00
znetsixe
29c0cdc37c bump pumpingStation submodule to 6e89e49
Includes 285fd01 (drop 52 MB 01-basic-demo.gif) and 6e89e49 (restore GIF
"needed" placeholders in Home and Reference-Examples so the dropped media
is tracked instead of leaving a broken image link).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:02:08 +02:00
znetsixe
123ef6fca3 wiki + submodules: Functional Overview page + bump pumpingStation / generalFunctions / monster
Submodule pointers
- pumpingStation: realistic basin defaults, ramp-foot visual fix, manual-mode
  observability, new 02-Dashboard.json (charts + raw-output table), wiki
  Home/Reference-Examples with screenshots + demo GIF.
- generalFunctions: pumpingStation config schema defaults aligned with the
  new editor drag-in values; startLevel description corrected (ramp foot is
  inflowLevel, not startLevel).
- monster: examples cleanup — drop pre-refactor flows, ship single
  02-integrated-e2e.json.

Wiki
- New wiki/Functional-Overview.md: companion to Architecture covering the
  process side — what each node physically represents and which control
  objective it serves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:52:36 +02:00
znetsixe
f7ada0fd9d P11.8: bump pumpingStation submodule — Zone A / Reference-* wiki split
Pilot pass for the per-node Home redesign. pumpingStation's wiki/
now has a short, intuitive Home.md plus four Reference-* sibling
pages (Contracts / Architecture / Examples / Limitations). Asset
placeholders created under wiki/_partial-{screenshots,gifs,flows}/
with explicit "screenshot needed" / "GIF needed" callouts where
the user will record assets.

Abandoned content: wiki/functional-description.md and wiki/modes/*
were removed from source per user direction (example-driven over
prose).

Once this pattern is validated on the live pumpingStation wiki,
the same split will be applied to the other 10 nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:20:44 +02:00
znetsixe
5ae8788fd7 wiki: crisp overhaul — no decoration emoji, all 9 master pages refactored
Source-tree mirror of EVOLV.wiki.git refactor (27a42ee on wiki.git):

- 7 master pages rewritten with clean design (Home, Architecture,
  Topology-Patterns, Topic-Conventions, Telemetry, Getting-Started,
  Glossary). Tables and Mermaid for visuals, gitea alert callouts for
  warnings, shields badges for metadata only. No emoji as decoration.
- Archive.md becomes a removal-changelog pointing readers to git
  history and to the successor pages.
- _Sidebar.md updated to navigate the new flat-name layout.
- Concept / finding / manual pages: uniform mini-header (badges +
  "reference page" callout) added without rewriting domain content.
- Every internal link now uses the flat naming that resolves on the
  live gitea wiki (Concept-ASM-Models, Finding-BEP-..., etc.).

On wiki.git: 29 Archive-* pages hard-deleted (the git history
preserves them; Archive.md documents the removal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:24:51 +02:00
znetsixe
2ccc8aea9e wiki: master EVOLV wiki refactor — 7 new pages + corrected Home
Complete redesign of the platform-level wiki. Previous Home.md had a
broken Mermaid diagram (showed pumpingStation → valveGroupControl as a
parent/child edge, which isn't in any configure() declaration). Audit
of all 12 specificClass.js configure() calls drives the new ground-truth
hierarchy.

New pages:
- Home.md (rewritten — accurate mermaid, full node + concept index)
- Architecture.md (3-tier code structure, generalFunctions API surface,
  child-registration sequence)
- Topology-Patterns.md (5 verified plant configurations + worked example)
- Topic-Conventions.md (set./cmd./evt./data./child. + unit policy + S88
  palette + measurement key shape + status badge + HealthStatus)
- Telemetry.md (Port 0/1/2 contracts + InfluxDB line-protocol layout +
  FlowFuse charts + Grafana provisioning)
- Getting-Started.md (clone, install, Docker vs local, first example)
- Glossary.md (S88, EVOLV runtime, WWTP, pumps, control, project terms)
- _Sidebar.md (gitea wiki navigation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:47:57 +02:00
znetsixe
9ab9f6b3e3 P11.7: bump submodule pointers for 2026-05-11 wiki wave
13-agent parallel rewrite of per-node wiki/Home.md per the visual-first
14-section template (.claude/refactor/WIKI_TEMPLATE.md). Three pointer
bumps (diffuser, valve, dashboardAPI) were committed early by their
agents (ff804af, e2aa6e6, 14f9104); this commit covers the remaining
nine submodules:

- generalFunctions  → c7e561e  (new wiki/Home.md; library API surface)
- machineGroupControl → 05de4ee
- measurement       → ffc0358
- monster           → 53c25f2
- pumpingStation    → b825ac1
- reactor           → d735f94
- rotatingMachine   → b373727
- settler           → 98052a1
- valveGroupControl → 618ad27

Parent wiki audit (b8cb889) is already committed: 18 stale pages moved
to wiki/Archive/, evergreen domain/manual content kept, Home.md
refactor-status corrections (Tier 6 + Tier 9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:08:51 +02:00
znetsixe
b8cb889d87 wiki: audit + archive stale pages; refresh Home for 2026-05-11 wave
- Archived 20 pre-refactor pages to wiki/Archive/ with standard banners:
  - All 6 architecture/ pages (old _loadConfig/_setupSpecificClass internals,
    pre-refactor S88 hierarchy, deployment blueprint)
  - All 3 sessions/ logs (Apr-07 + Apr-13 session summaries)
  - findings/open-issues-2026-03.md (issues 1-5 all resolved by refactor)
  - concepts/generalfunctions-api.md (missing BaseDomain/BaseNodeAdapter)
  - concepts/sources-readme.md (empty PDF placeholder, never populated)
  - manuals/nodes/rotatingMachine.md + measurement.md (superseded by per-repo wikis)
  - Top-level SCHEMA.md, index.md, log.md, metrics.md, overview.md,
    knowledge-graph.yaml (all Apr-07 snapshot, pre-refactor)
- Kept wiki/concepts/ domain pages (ASM, PID, pump-affinity, settling, etc.)
- Kept wiki/findings/ proven results (BEP, NCog, curve-non-convexity, stability)
- Kept wiki/manuals/node-red/* (FlowFuse + Node-RED runtime docs, still current)
- Kept wiki/tools/* (utility scripts)
- Updated wiki/Archive.md index with 20 rows
- Fixed wiki/Home.md: Tier 6 was wrongly marked done; corrected to pending;
  Tier 9 updated to reflect 2026-05-11 in-progress wave

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:07:48 +02:00
znetsixe
14f9104722 P11.7: bump dashboardAPI submodule pointer (wiki Home.md rewrite)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:06:56 +02:00
znetsixe
e2aa6e6937 docs: bump valve submodule pointer — wiki Home.md FSM + config rewrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:06:37 +02:00
znetsixe
ff804af11c bump nodes/diffuser submodule pointer: P11.6 wiki regen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:05:10 +02:00
znetsixe
f9f1cceb82 docs: finalise CONTRACTS.md §4 + WIKI_TEMPLATE.md tweaks
CONTRACTS.md §4: full payloadSchema.type table including 'none', plus
the optional description field example. Matches the B3.2 implementation.

WIKI_TEMPLATE.md §5: Unit column appears with explanatory paragraph.
Matches the P11.4 wikiGen output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:22:05 +02:00
znetsixe
0ff50a0291 Add CONTINUE_HERE.md: fresh-context entry point + deferred-work list
Single doc capturing the 'what's not done' at the end of the
2026-05-11 sprint, in priority order: B5 reactor boundary-conditions
merge, Phase 8 PR cycle, a handful of small open-questions follow-ups,
plus the wiki cosmetics list. README.md links to it from the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:18:46 +02:00
znetsixe
44ffae12f7 P11.6 wiki regen + Phase 10 private-test rewrites — bump pointers
All 11 nodes' wiki/Home.md regenerated with the Unit column +
per-topic descriptions. rotatingMachine + reactor private-method
test files rewritten to the public BaseNodeAdapter surface.

OPEN_QUESTIONS: rotatingMachine + reactor private-test entries
marked RESOLVED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:19 +02:00
znetsixe
4f970eaa0d Wave B3 + C: bump submodule pointers for P11.5 + B2.1 + B2.2
pumpingStation   ef81013 → 5f1c9ae  P11.5 units + B2.1 declareChildGetter
  machineGroupControl 31324ae → 3ee1939  P11.5 units (set.demand)
  rotatingMachine  84126e9 → 1d5e040  P11.5 units (set.flow-setpoint)
  valve            8aa5b5e → 63b5f94  P11.5 units + descriptions
  monster          0038a8c → 133d442  P11.5 descriptions
  diffuser         9122b14 → e18b6a0  P11.5 units (data.flow)
  reactor          297c671 → 1aa2d92  P11.5 descriptions
  settler          2af30c0 → 43a5bf5  P11.5 descriptions
  valveGroupControl c44d595 → 778b2e0  P11.5 + B2.2 ChildRouter adoption
  measurement      497f05d → 15b7414  P11.5 descriptions

Platform: 810 / 0 across all 12 submodules. Phase 11 ready for the
last step (P11.6 regenerate docs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:31:52 +02:00
znetsixe
6bf94f4c8a Wave B1: bump submodule pointers + 5 OPEN_QUESTIONS resolved
generalFunctions f117546 → <new>  B2.3 fireAndWait + P11.1 possibilities
                                    + P11.2 commandRegistry.units + monster schema
  measurement      e6e212a → <new>  B1.3 isStable threshold
  monster          2aa7f88 → <new>  B1.4 cooldown-guard root-cause fix
  machineGroupControl 0e8cab5 → <new>  B2.3 fireAndWait migration

OPEN_QUESTIONS marked RESOLVED in the decisions table:
  isStable tautology, monster cooldown-guard, LatestWinsGate fireAndWait

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:29:23 +02:00
znetsixe
30928ce378 Wave A: bump submodule pointers + mark 4 OPEN_QUESTIONS resolved
generalFunctions ff9aec8 → f117546  B3.1+B3.2+B3.3 infra
  measurement      2aa8021 → e6e212a  B2.4 drop 'mAbs' event
  machineGroupControl 045a941 → 0e8cab5  B3.3 drop _unitView
  rotatingMachine  9e8463b → 84126e9  B3.3 drop _unitView
  pumpingStation   e991ea6 → ef81013  B1.2 drop 'overfillLevel'

OPEN_QUESTIONS.md: 4 entries marked RESOLVED (ChildRouter monkey-patch,
commandRegistry 'none' type, measurement 'mAbs' event, MGC unitPolicy
shape).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:14:00 +02:00
znetsixe
e691551ddd docs: lock in 12 decisions from the 2026-05-11 interview + add Phase 11
OPEN_QUESTIONS.md: summary table of every decision (ramp foot, naming,
isStable fix, monster guard, plain-dicts→declareChildGetter, VGC→
ChildRouter, LatestWinsGate fireAndWait, drop mAbs, per-listener fan-out,
commandRegistry 'none' + description, UnitPolicy dual-shape, Phase 10
test rewrites).

TASKS.md §Phase 11: unit-aware commands. Every numeric setter declares
units: { measure, default }. commandRegistry normalises msg.payload +
msg.unit; warns + lists accepted units for bad input; falls back to
default. New query.units topic returns the spec per node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:03:28 +02:00
znetsixe
351e889918 Bump submodule pointers: A1/A2/A3 fixes + basin-docs-update merge
monster          2a82b7d → 2aa7f88  A1: restore child.register input handler
  settler          6953d64 → 2af30c0  A2: restore child.register input handler
  reactor          d931bea → 297c671  A3: expose tick(dt) on BaseDomain wrapper
  pumpingStation   ed22f01 → e991ea6  A4+B4: merge basin-docs-update (per-mode SVG,
                                       stopLevel hysteresis, shifted ramp, manual
                                       q_out, log/linear curve, overflow clamp + spill,
                                       7-file editor module set)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:20:17 +02:00
znetsixe
c25866e7bc fix(docker): chown /data/evolv to node-red before WORKDIR
The original Dockerfile switched to USER node-red before WORKDIR
/data/evolv, so the auto-created parent directory ended up root-owned
and `RUN npm install` failed with EACCES trying to mkdir node_modules.

Fix: mkdir + chown explicitly as root, then WORKDIR + USER node-red.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:41:00 +02:00
znetsixe
23dc23328d Bump generalFunctions for P10.5 test fixes 2026-05-11 15:21:16 +02:00
znetsixe
3bfb9833c0 P9.3: parent EVOLV wiki Home + Archive + bump submodule pointers
wiki/Home.md (new) — platform landing page per WIKI_HOME_TEMPLATE.md.
Mermaid block of 11 active EVOLV nodes coloured by S88 level, navigation
grouped by level, standards-pointer table, live refactor-status table.

wiki/Archive.md (new) — empty archive table for retired wiki pages.

Submodule pointer bumps (all wiki/Home.md + wiki:* npm scripts):
  measurement          42a0333 → 2aa8021
  machineGroupControl  bb2f3be → 045a941
  rotatingMachine      e058fe9 → 9e8463b
  valve                e27135b → 8aa5b5e
  valveGroupControl    e02cd1a → c44d595
  diffuser             15cfb22 → 9122b14
  monster              2a6a0bc → 2a82b7d
  settler              b8247fc → 6953d64
  reactor              7bf464b → d931bea
  dashboardAPI         92d7eba → 67a374f

Every node now has a visual-first wiki Home page with auto-generated
topic contract + data model. Per-node `npm run wiki:all` re-generates
the AUTOGEN blocks from src/commands/ + src/specificClass.js.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:18:05 +02:00
znetsixe
afc304b424 Bump pumpingStation + generalFunctions for P9.2 + P9.3 + examples
generalFunctions  95c5e68 → 30c5dc8  P9.2 wikiGen.js shared script
  pumpingStation    d2384b1 → ed22f01  P9.3 wiki Home.md pilot +
                                       3-tier example flows + tools/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:57 +02:00
znetsixe
3b7acdaa88 P10.7: top-level test:platform runner + bump submodule pointers
scripts/test-platform.js iterates each submodule, runs npm test, shows
a per-node pass/fail summary, exits non-zero if any node fails.

Wired as `npm run test:platform` in the parent package.json.

Submodule pointer bumps:
  dashboardAPI     2874608 → 92d7eba  (Mocha → node:test conversion for edge+integration)
  diffuser         0ec9dd1 → 15cfb22  (P10.7a test script fix)
  generalFunctions 8ebf31d → 95c5e68  (P10.7a test script fix + remove 5 broken Mocha dupes)
  pumpingStation   52d3889 → d2384b1  (P10.7a test script fix)

Current platform-wide gate: 729 pass / 5 fail across 12 submodules
(5 failures are all pre-existing AssertionErrors logged in
OPEN_QUESTIONS.md for Phase 10.5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:45:06 +02:00
Rene De Ren
e03a7a51b7 P9 follow-up: expand wiki template + add Home/Archive template
WIKI_TEMPLATE.md — extend the canonical per-node page from 9 to 14 sections:
  + Header band (commit hash + regen date)
  + Code map (flowchart TB w/ subgraphs over concern modules)
  + Child registration (mirrors ChildRouter declarations)
  + Data model — getOutput() (abstract schema + optional concrete sample)
  + Debug recipes (symptom → first thing to check)
  + AUTOGEN markers around topic-contract + data-model schema so the
    Phase 9 regen script can rewrite in place.
  + 'Picking a visual' table: Mermaid is default, plots/SVG/screenshots
    allowed where they serve the data.
  + Archive banner snippet.

WIKI_HOME_TEMPLATE.md (new) — Home.md + Archive.md templates:
  - Platform-wide Mermaid graph of 11 active nodes, S88-coloured.
  - Navigation table grouped by S88 level.
  - Standards-pointer table to .claude/rules + .claude/refactor docs.
  - Live refactor-status table for returning visitors.
  - Archive index template with archival-date column.

No wiki pages written yet — next step is one worked example
(pumpingStation) before any change to the Gitea wiki repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:25:34 +02:00
znetsixe
0a890fd0d7 Bump generalFunctions to 7c..-ish (P8.5 cleanup + P6.4 schema fix)
92eb8d2  P8.5: remove src/menu/asset_DEPRECATED.js (243 lines, 0 consumers)
  HEAD     P6.4 follow-up: add diffuser config schema fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:47:39 +02:00
znetsixe
ce07cc564f P9 setup: canonical wiki template (visual-first, mermaid-first)
Defines the 9-section template every node's wiki page follows:
1. Position in the platform (mermaid flowchart, S88-coloured)
2. Capability matrix (≤ 10 rows)
3. Topic contract (auto-generated from src/commands/index.js)
4. Lifecycle (mermaid sequenceDiagram)
5. Configuration (mermaid flowchart + form-to-config table)
6. Examples (basic/integration/dashboard tiers)
7. State chart (stateDiagram-v2, only for stateful nodes)
8. When you would NOT use this node
9. Known limitations / current issues

Hard rules: diagrams before prose, ≤ 60 words of unbroken prose
anywhere, topic contract auto-generated (no hand-written drift).

Per-node application is the next step (P9.3-P9.6 in TASKS.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:27:08 +02:00
znetsixe
1d0dd45d9a P8 prep: bump submodule pointers to development tips after Phase 1-6
All 12 submodules + parent EVOLV are now on the `development` branch
with the platform refactor complete:

  generalFunctions     7372d12  Phase 1 platform infra (additive)
                                BaseNodeAdapter / BaseDomain / UnitPolicy
                                ChildRouter / LatestWinsGate / HealthStatus
                                commandRegistry / statusBadge / statusUpdater
                                stats — 113 unit tests
  pumpingStation       52d3889  Phase 2 — concern split + integration
                                basin/measurement/control/safety/io/commands
                                specificClass 1039→245 lines, 102 tests
  measurement          42a0333  Phase 3 — Channel-based analog + BaseDomain
                                simulator/calibration/commands extracted
                                specificClass 716→244 lines, 96 tests
  machineGroupControl  bb2f3be  Phase 4 — concern split + integration
                                groupOps/totals/combinatorics/optimizer/
                                efficiency/dispatch/commands
                                specificClass 1808→336 lines, 77 tests
  rotatingMachine      e058fe9  Phase 5 — concern split + integration
                                curves/prediction/drift/pressure/state/
                                measurement/flow/display/commands
                                specificClass 1760→400 lines, 196 tests
  valve                e27135b  Phase 6 platform refactor + concern split
  valveGroupControl    e02cd1a  Phase 6 platform refactor + concern split
  diffuser             0ec9dd1  Phase 6 platform refactor (port 4→3)
  monster              2a6a0bc  Phase 6 platform refactor + concern split
  settler              b8247fc  Phase 6 platform refactor (reactor link kept)
  reactor              7bf464b  Phase 6 platform refactor + kinetics/ split
  dashboardAPI         2874608  Phase 6 — commandRegistry only (no BaseDomain;
                                passive HTTP server — see OPEN_QUESTIONS.md)

493 basic tests pass platform-wide (12/12 nodes green).

All canonical input topics (set.* / cmd.* / data.* / child.* / query.* /
evt.*) live alongside legacy aliases with one-time deprecation warnings.
Topic-rename cycle (P7) elapses across one release before alias removal.

Decisions taken during the refactor are recorded in
.claude/refactor/OPEN_QUESTIONS.md (resolved entries + carryovers for
Phase 8.5 cleanup, Phase 9 wiki, and Phase 10 test rewrite).

Ready for review on a per-submodule basis. Promotion to main is gated
on Docker E2E (per-node trial-ready criteria) — not part of this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:25:16 +02:00
znetsixe
13da7388ff refactor docs: lock in topic-prefix glossary, child-getters, opt-in tick
Resolves the 5 open questions answered during Phase 1 setup:

- Topic naming: canonical from Phase 1 (set/cmd/data/child/query/evt),
  with full glossary in CONTRACTS.md §1.
- Parent EVOLV branch lineage: rebased onto origin/main.
- Deprecated paths: tracked as Phase 8.5 in TASKS.md.
- Child storage: registry-as-truth + named getters via
  declareChildGetter.
- Tick: opt-in via static tickInterval; default is event-driven via
  source.emitter 'output-changed'. statusInterval (always-on, 1Hz)
  is separate.

Plus two new pre-existing-issue notes from the sanity gate:
- dashboardAPI uses Mocha-style describe() under node:test (broken).
- reactor tests are mathjs-bound (~13s/file load).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:44:42 +02:00
znetsixe
91e4255ef5 Add refactor planning docs (.claude/refactor/)
Platform-wide refactor plan: README, CONVENTIONS, CONTRACTS,
MODULE_SPLIT, TASKS, OPEN_QUESTIONS. Source of truth for the
phased refactor across all 12 submodules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:22:35 +02:00
Rene De Ren
aec90cc8e7 fix: stopLevel hysteresis works — bump rotatingMachine + MGC
Some checks failed
CI / lint-and-test (push) Has been cancelled
Pump-shutdown deadlock fix split across two submodules:

- rotatingMachine@8f9150e: shutdown sequence clears state.delayedMove
  so the abort-and-return-to-operational path doesn't auto-pickup the
  queued setpoint and re-engage the pump.
- machineGroupControl@ea2857f: turnOffAllMachines clears MGC's
  _delayedCall and serializes per-pump shutdown so PS's 2 s tick loop
  can't interrupt an in-flight shutdown.

Live verification on pumpingstation-complete-example demo: basin now
shuts pumps off at stopLevel cleanly, reverses to fill, completes the
hysteresis cycle.

Also disable the trends page in the demo flow (build_flow.py + regen
flow.json). FlowFuse ui-chart's per-series server-side history buffer
(7 charts × ~20 series × 3600-point retention) was saturating the
Node-RED event loop at 129% CPU, making the dashboard freeze on every
click. Trends remain available — just disabled by default; flip the
ui_page_trends "d" key to false to re-enable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:18:11 +02:00
Rene De Ren
6fef002da1 Bump machineGroupControl@2651aaf — quiet abortActiveMovements normal path
Some checks failed
CI / lint-and-test (push) Has been cancelled
WARN now fires only when force-aborting an actually in-flight pump
movement (gate-bypass safety net), not on every no-op tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:43:17 +02:00
Rene De Ren
c4d75809cd Bump machineGroupControl@df74ea0 — serialize handleInput dispatches
Some checks failed
CI / lint-and-test (push) Has been cancelled
Adds the _dispatchInFlight gate that mirrors rotatingMachine
state.delayedMove. Before this, PS at 1 Hz overran in-flight pump
ramps via concurrent handleInput entries, producing the live thrash:
120 aborts / 2 min, pump_b clamped at minFlow.

Includes regression test:
test/mgc-overactive-demand-serialization.integration.test.js
covering concurrent-burst serialization (30 calls → ≤ 5 aborts) and
latest-wins semantic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:15:23 +02:00
Rene De Ren
a4617d850a Bump MGC@96b84d3 — revert unchanged-demand short-circuit (broke live demo)
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:55:47 +02:00
Rene De Ren
44963cfa43 Bump MGC@a14aa0d — short-circuit handleInput on unchanged demand
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:10:57 +02:00
Rene De Ren
15c39f76bb Bump MGC@69bdf11 + adjust overcapacity test to actually exercise storm
Some checks failed
CI / lint-and-test (push) Has been cancelled
- nodes/machineGroupControl@69bdf11 makes DOWNSTREAM single-writer
  (handlePressureChange = live aggregate; optimizer target moved to
  AT_EQUIPMENT). Closes the ps-mgc-flow-contract failure.

- test/inflow-overcapacity-stability now starts the basin at maxLevel
  so PS percControl is immediately 100 % (the actual storm condition)
  and uses real-time waits between ticks so movementManager intervals
  fire — the previous setImmediate yield was too fast for moves to
  progress, making pumps look perma-parked even when behaviour was OK.
  Park observations dropped from 83 to 3 across the sim window; final
  ctrl converges to ~88 % across all 3 pumps.

All 82 cross-node + node integration tests now pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:33:09 +02:00
Rene De Ren
3c7d54e9c3 Add 4 cross-node tests closing PS↔MGC integration gaps
Some checks failed
CI / lint-and-test (push) Has been cancelled
- ps-mgc-flow-contract: asserts PS's view of MGC outflow equals the live
  per-pump aggregate at every tick. Currently FAILS — exposes that
  MGC's flow.predicted.downstream reverts to optimalControl's bestFlow
  target after handlePressureChange writes the correct flow.act, leaving
  PS with stale outflow values. The mirror added in dc27a56 is necessary
  but not sufficient.

- dead-zone-signal: asserts the Schmitt-trigger transitions
  (engaged 100% → keep-alive 1% → off 0%) across startLevel↓/stopLevel↓
  with proper rising-edge re-arm. Currently PASSES.

- inflow-overcapacity-stability: 45 s sim at 2× station capacity;
  asserts pumps don't thrash or park in accelerating residue. Currently
  FAILS — pumps end up at ctrl=0 in 'accelerating' state, suggesting
  the residue-unpark fix doesn't fully cover steady-state over-capacity.

- realistic-startup-timing: re-runs the varying-demand-during-startup
  scenario with PRODUCTION-default state.time (starting=10s, warm=5s)
  instead of the 1-2 s used elsewhere. Currently PASSES — confirms the
  dispatch-reorder fix holds under realistic transition windows.

Honest summary: 2 pass, 2 fail. The two failures expose genuine
remaining defects in the PS↔MGC measurement contract and the
residue-unpark policy. They're committed FAILING so the bugs are
captured under version control until the underlying fixes land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:07:11 +02:00
Rene De Ren
21e777797a Bump machineGroupControl@dc27a56 — mirror aggregate flow onto DOWNSTREAM
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:20:28 +02:00
Rene De Ren
035f03cdee Bump machineGroupControl@b7c40b0 — mirror dispatch fix in equalFlowControl
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:47:23 +02:00
Rene De Ren
9bc6908d05 Bump machineGroupControl@8e68420 — add cycle/sweep regression tests
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:34:01 +02:00
Rene De Ren
0cab98c196 Pumping-station demo overhaul + cross-node test harness + bumps
Some checks failed
CI / lint-and-test (push) Has been cancelled
Submodule bumps land the deadlock fix (state.js residue unpark + MGC
optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis.

- Renames examples/pumpingstation-3pumps-dashboard →
  pumpingstation-complete-example with regenerated flow.json. New
  dashboard groups, demand-broadcast wiring, S88 placement rule
  applied, ui-chart trend-split and link-channel naming follow
  .claude/rules/node-red-flow-layout.md.
- New cross-node test harness under test/: end-to-end-pumpingstation
  drives PS + MGC + 3 pumps + physics simulator end-to-end and
  verifies the ~5/15 min cycle.
- Adds Grafana provisioning dashboards (pumping-station.json) and a
  helper sync-example.sh script for export/import to live Node-RED.
- Docker entrypoint + settings + compose tweaks for the persistent
  user dir layout used by the demo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:21:21 +02:00
Rene De Ren
ca0644d689 Bump generalFunctions@94bcc90 — gitignore local lockfile stub
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:24:28 +02:00
Rene De Ren
5766ee4d16 Drop tensorflow deps; rule cleanups; repo-mem MCP; bump pumpingStation@6ab585b
Some checks failed
CI / lint-and-test (push) Has been cancelled
- package.json: remove @tensorflow/tfjs and @tensorflow/tfjs-node.
  Monster's TF code was already stripped; the deps were stale and kept
  pulling a heavy native binary back into every install.
- .gitignore: ignore .repo-mem/ regenerable indexes and per-session
  .claude/*.lock runtime files.
- CLAUDE.md: prepend READ-FIRST pointer to .claude/rules/repo-mem.md;
  collapse the 'three outputs' bullet to a pointer at node-architecture.
- .claude/rules/telemetry.md: drop Port 0/1/2 duplication; reference
  node-architecture.md.
- .claude/rules/testing.md: stop requiring a separate test/edge tier and
  the basic/integration/edge example flow trio. Reflects what nodes
  actually do.
- .claude/rules/repo-mem.md (new): when-to-call-which guide for the
  per-repo memory MCP, anti-patterns, refresh model.
- .mcp.json (new): wire repo-mem stdio server.
- docs/DEVELOPER_GUIDE.md (new): step-by-step guide for adding a new
  EVOLV node under the three-layer pattern.
- Bump nodes/pumpingStation to 6ab585b (docs + simulations refresh,
  spill-flow path renames consistent with d8490aa).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:23:47 +02:00
Rene De Ren
0466287514 Bump pumpingStation@d8490aa + generalFunctions@a516c2b
Some checks failed
CI / lint-and-test (push) Has been cancelled
pumpingStation: predicted-volume hard-floor at 0; spill flow refactored
from flow.predicted.out.<child=overflow> to its own position
flow.predicted.overflow. Drops the spillPrev self-subtraction. New
underflowVolume diagnostic for flow-balance errors. 70/70 tests pass.

generalFunctions: MeasurementContainer.get() strict-resolves explicit
.child(name) — missing named child now returns null instead of falling
through to a sibling. Persistent setChildId remains a hint (no
behavioural change for registered children).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:45 +02:00
Rene De Ren
21b0bd34c6 Bump pumpingStation@6b46a8a — predicted-volume overflow clamp
Some checks failed
CI / lint-and-test (push) Has been cancelled
Integrator now clamps predicted volume to [dryRunSafetyVol,
maxVolAtOverflow], records cumulative spill as overflowVolume and
exposes a synthetic flow.predicted.out.overflow rate so net flow
balances to ~0 while pinned. _selectBestNetFlow holds the last
level-rate net flow during overflow so dashboards keep a usable
reading. Top-level predictedOverflowVolume / predictedOverflowRate
fields added to getOutput.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:48:00 +02:00
Rene De Ren
60c6a647e2 Bump pumpingStation@62bc73f — input bounds + full hierarchy validation
Some checks failed
CI / lint-and-test (push) Has been cancelled
- bounds.js sets HTML5 min/max on every level + percent input so the
  spinner can't push values past the basin hierarchy.
- Basin-level violations now surface in a visible ribbon above the
  basin diagram and block Deploy via oneditsave.
- Layout polish: widened side panel, tightened basin viewBox, dropped
  mode-preview axis labels, moved datum below the tank.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:10:29 +02:00
Rene De Ren
48b9335dac Bump pumpingStation@de9a79b + generalFunctions@4b6250c
Some checks failed
CI / lint-and-test (push) Has been cancelled
- pumpingStation: hold-then-ramp shift hysteresis driven by
  shiftArmPercent (% output threshold for arming) instead of by level.
  New e2e integration test exercises the full fill→arm→hold→ramp-down
  cycle. Editor preview gains the arming-% horizontal line.
- generalFunctions: add shiftArmPercent to the pumpingStation schema;
  add prominent doc block on MeasurementContainer documenting the
  `${type}.${variant}.${position}.${childId}` flatten format and the
  implicit 'default' childId convention so dashboards don't drop it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:47:03 +02:00
Rene De Ren
7c6c6183f7 Bump pumpingStation@8a6ca1b + generalFunctions@35f648f
Some checks failed
CI / lint-and-test (push) Has been cancelled
- pumpingStation: level-armed shift hysteresis, derived dryRunLevel,
  side-panel editor with hover-coupling, manual q_out for end-to-end
  testing without rotating-machine wiring.
- generalFunctions: schema additions for flowThreshold, output formats,
  enableShiftedRamp / shiftLevel under control.levelbased.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:29:49 +02:00
Rene De Ren
2593458bdf Update pumpingStation submodule
Some checks failed
CI / lint-and-test (push) Has been cancelled
2026-05-05 11:02:33 +02:00
znetsixe
36147de6d7 Bump pumpingStation@ab0d4ed — outlet pinned, zone labels added, volume in diagram
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:20:06 +02:00
znetsixe
b84c59cbe6 Bump pumpingStation@2dd419d — revert tank size, nudge lines themselves
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:10:32 +02:00
znetsixe
b873a8fb02 Bump pumpingStation@785d036 — taller editor diagram, more breathing room
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:41:10 +02:00
znetsixe
7e51bec8f2 Bump pumpingStation@65fe68b — nudge crowded threshold inputs with leader lines
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:41:26 +02:00
znetsixe
aa546df6e6 Bump pumpingStation@d641d22 — interactive basin diagram in editor
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:28:26 +02:00
znetsixe
c413c0fad5 Bump pumpingStation@12904b4 — inline parameters diagram in editor
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:19:29 +02:00
znetsixe
a6ad85ae38 Bump pumpingStation@1ebbcb6 — editor pipe-edge labels + live derived safety levels
Some checks failed
CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:58:26 +02:00
znetsixe
33ac527274 Bump rotatingMachine + machineGroupControl submodule pointers
Some checks failed
CI / lint-and-test (push) Has been cancelled
- rotatingMachine@399e0a8: editor hygiene (name default, status
  clear on close), remove redundant idle-position clamp in
  flow/power predictions.
- machineGroupControl@9c79dac: bug fix — stale flow/power cache
  now cleared on MGC shutdown so parent pumpingStation sees the
  drop immediately. Also awaits shutdown promises correctly and
  corrects the NCog integration tests to match centrifugal-pump
  physics (Q/P monotonic → NCog=0 → fallback to equal distribution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:25 +02:00
znetsixe
d22d1cabd1 Rename eval/ decision log to simulations/; bump pumpingStation pointer
Some checks failed
CI / lint-and-test (push) Has been cancelled
Follows pumpingStation@3e13512 (rename eval/ → simulations/). The
decision log file is renamed to match the new folder name; an
addendum in the body explains that the rename was a naming
clarification, not a rationale change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:47:00 +02:00
znetsixe
79afe11da8 Log pumpingStation architectural decisions; bump submodule pointers
Some checks failed
CI / lint-and-test (push) Has been cancelled
Four decisions recorded under .agents/decisions/ per project convention
(DECISION-YYYYMMDD-slug.md) to close the loop on today's pumpingStation
refactor + eval + docs work:

- wiki-in-code-repo — why docs+diagrams+code now live in one package
- 5-threshold-naming — old/new field mapping + breaking-change rationale
- mode-tier-template — Tier 1/2/3 classification for mode pages
- eval-harness — why eval/ exists alongside test/

Also bumps nodes/pumpingStation to 66fd3fe (eval harness + Tier 2/3
template pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:50:00 +02:00
znetsixe
b885f291d4 Propagate threshold rename; point platform manual at pumpingStation wiki
Some checks failed
CI / lint-and-test (push) Has been cancelled
Follows pumpingStation@a218945 + generalFunctions@4252292 rename:

- Bump pumpingStation and generalFunctions submodule pointers.
- Update examples/pumpingstation-3pumps-dashboard/ (build_flow.py,
  flow.json, README.md) to use the new threshold names. Collapsed
  minFlowLevel into startLevel; reshuffled order to match the basin
  bottom-to-top: minLevel, startLevel, maxLevel.
- wiki/manuals/README.md: drop the stale pumpingStation.md line and
  point readers at pumpingStation/wiki instead (docs have moved into
  the node's own repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:15:36 +02:00
znetsixe
4a9521154b fix: safety overfill keeps pumps running + minHeightBasedOn=inlet
Some checks failed
CI / lint-and-test (push) Has been cancelled
pumpingStation 5e2ebe4: overfill safety no longer shuts down machine
groups or blocks level control. Pumps keep running during overfill
(sewer can't stop receiving). Only upstream equipment is shut down.

Demo config: minHeightBasedOn=inlet (not outlet). The minimum height
reference for the basin is the inlet pipe elevation — sewage flows
in by gravity and the basin level can't go below the inlet without
the sewer backing up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:11:08 +02:00
znetsixe
732b5a3380 fix: realistic sinus + continuous pump control + dead zone elimination
Some checks failed
CI / lint-and-test (push) Has been cancelled
Sinus inflow: 54-270 m³/h (base 0.015 + amplitude 0.06 m³/s), 4 min
period. Peak needs 1-2 pumps, never all 3 = realistic headroom.

PS control: continuous proportional demand when level > stopLevel, not
just when > startLevel && filling. Pumps now ramp down smoothly as
basin drains toward stopLevel instead of staying stuck at last setpoint.

pumpingStation e8dd657: dead zone elimination
build_flow.py: sinus tuned for gradual pump scaling visibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:42:55 +02:00
znetsixe
c8f149e204 feat(dashboard): split basin charts by unit + add y-axis labels to all charts
Some checks failed
CI / lint-and-test (push) Has been cancelled
Flow: m³/h, Power: kW, Basin Level: m, Basin Fill: % (0-100 fixed).
Level and fill in separate chart groups with their own gauges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:19:32 +02:00
znetsixe
b693e0b90c fix: graduated pump control + mass balance corrections
Some checks failed
CI / lint-and-test (push) Has been cancelled
Three fixes:

1. PS outflow triple-counted (pumpingStation c62d8bc): MGC registered
   twice + individual pumps registered alongside MGC + dual event
   subscription per child. Now: one registration per aggregation level,
   one event per child. Volume integration tracks correctly.

2. All 3 pumps always on: minFlowLevel was 1.0 m but startLevel was
   2.0 m, so at the moment pumps started the percControl was already
   40% → MGC mapped to 356 m³/h → all 3 pumps. Fixed: minFlowLevel
   = startLevel (2.0 m) so percControl starts at 0% and ramps
   linearly. Now pumps graduate: 1-2 pumps at low level, 3 at high.

3. Generalizable registration rule added as code comments: when a group
   aggregator exists (MGC), subscribe to it, not its children. Pick
   one event name per measurement type per child.

E2E verified: 2/3 pumps active at 56% fill, volume draining correctly,
pump C at 5.2% ctrl delivering 99 m³/h while pump A stays off.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:10:32 +02:00
znetsixe
2b0c4e89b1 fix: abort recovery bounce loop broke MGC → pump control
Some checks failed
CI / lint-and-test (push) Has been cancelled
generalFunctions 086e5fe -> 693517c:
  abortCurrentMovement now takes options.returnToOperational (default
  false). Routine MGC demand-update aborts leave pumps in their current
  state. Only shutdown/emergency-stop paths pass returnToOperational:true.

rotatingMachine 510a423 -> 11d196f:
  executeSequence passes returnToOperational:true for shutdown/estop.

Verified E2E: PS fills to startLevel → MGC distributes demand → all 3
pumps at 1.31% ctrl delivering 121 m³/h each → basin draining at
-234 m³/h net. Full fill/drain cycle operational.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:11:36 +02:00
znetsixe
faaeb2efd3 fix: realistic basin sizing + boosted sinus inflow for visible fill cycle
Some checks failed
CI / lint-and-test (push) Has been cancelled
Basin was 30 m³ with 72 m³/h average sinus inflow → took 10+ minutes
to reach startLevel, looking static on the dashboard. Boosted sinus to
base=0.02 + amplitude=0.10 m³/s (avg ~252 m³/h, peak ~432 m³/h). Basin
fills from outlet to startLevel in ~3 minutes now.

Also removed initBasinProperties trace from previous debug session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:17:17 +02:00
znetsixe
53b55d81c3 fix: fully configure PS basin + add node-completeness rule
Some checks failed
CI / lint-and-test (push) Has been cancelled
Basin undersized (10m³) for sinus peak (126 m³/h) → overflow → 122%.
Now 30 m³ with 4m height, all PS fields set. New rule: always configure
every field of every node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:00:27 +02:00
znetsixe
eb97670179 fix(dashboard): use correct basin capacity for fill % + clamp to 0-100
Some checks failed
CI / lint-and-test (push) Has been cancelled
maxVol was hardcoded to 9.33 (overflow volume at 2.8 m height) instead
of 10.0 (basin capacity = basinVolume config). Volumes above 9.33 m³
produced fill > 100% (e.g. 122% at vol=11.4). Fixed to use 10.0 and
clamp to [0, 100].

Patched via nodes-only deploy — basin not reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:53:41 +02:00
znetsixe
cc4ee670ea fix(dashboard): move basin gauges to trend pages next to basin chart
Some checks failed
CI / lint-and-test (push) Has been cancelled
The tank gauge (basin level) and 270° arc gauge (fill %) now live on
the trend pages alongside the basin metrics chart — not on the control
page. Each trend page (10 min / 1 hour) gets its own pair of gauges.

Layout per trend page Basin group:
  - Chart (width 8): Basin fill % + Level + Net flow series
  - Tank gauge (width 2): 0–3 m with color zones at stop/start levels
  - Arc gauge (width 2): 0–100% fill with red/orange/green zones

Deployed via partial (nodes-only) deploy so the basin wasn't reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:46:38 +02:00
znetsixe
a51bc46e26 feat(dashboard): add tank gauge for basin level + 270° arc for fill %
Some checks failed
CI / lint-and-test (push) Has been cancelled
Basin Status group on the Control page now has two visual gauges:

1. gauge-tank (vertical tank with fill gradient) for basin level 0–3 m.
   Color zones: red < 0.6 m (below stopLevel) → orange → blue 1.2–2.5 m
   (normal operating range) → orange → red > 2.8 m (overflow zone).

2. gauge-34 (270° arc) for fill percentage 0–100%.
   Color zones: red < 10% → orange → green 30–80% → orange → red > 95%.

Both gauges are fed from the PS dispatcher's numeric outputs (fillPctNum
and levelNum) which also feed the basin trend charts — same data, two
visual forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:40:43 +02:00
znetsixe
b18c47c07e feat(dashboard): add basin fill gauge, countdown, and basin trend charts
Some checks failed
CI / lint-and-test (push) Has been cancelled
PS control page now shows 7 fields instead of 5:
  - Direction (filling/draining/steady)
  - Basin level (m)
  - Basin volume (m³)
  - Fill level (%)
  - Net flow (m³/h, signed)
  - Time to full/empty (countdown in min or s)
  - Inflow (m³/h)

Two new trend pages per time window (short 10 min / long 1 hour):
  - Basin chart: 3 series (Basin fill %, Basin level m, Net flow m³/h)
    on both Trends 10 min and Trends 1 hour pages.

PS formatter now extracts direction, netFlow, seconds from the delta-
compressed port 0 cache and computes fillPct from vol/maxVol. Dispatcher
sends 10 outputs (7 text + 3 trend numerics to both short+long basin
charts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:35:44 +02:00
znetsixe
60c8d0ff66 fix: root-cause bogus machineCurve default poisoning spline predictions
Some checks failed
CI / lint-and-test (push) Has been cancelled
generalFunctions 29b78a3 -> 086e5fe:
  Schema default machineCurve.nq had a dummy pressure slice at key "1"
  with fake data. Deep merge injected it alongside real curve data,
  pulling the pressure-dimension spline negative at low pressures.
  Fix: default to empty {nq: {}, np: {}}.

rotatingMachine 26e253d -> 510a423:
  Tests updated for corrected fValues.min (70000 vs old 1).
  Trace instrumentation removed. 91/91 green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:28:24 +02:00
znetsixe
658915c53e chore: bump rotatingMachine — clamp negative flow/power at ctrl≤0
Some checks failed
CI / lint-and-test (push) Has been cancelled
rotatingMachine: c464b66 -> 26e253d

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:07:10 +02:00
znetsixe
0cbd6a4077 wip: sinus-driven pumping station demo + PS levelbased control to MGC
Some checks failed
CI / lint-and-test (push) Has been cancelled
Architecture change: demo is now driven by a sinusoidal inflow into the
pumping station basin, rather than a random demand generator. The basin
fills from the sinus, and PS's levelbased control should start/stop
pumps via MGC when level crosses start/stop thresholds.

Changes:
- Demo Drivers tab: sinus generator (period 120s, base 0.005 + amp 0.03
  m³/s) replaces the random demand. Sends q_in to PS via link channel.
- PS config: levelbased mode, 10 m³ basin, startLevel 1.2 m / stopLevel
  0.6 m. Volume-based safeties on, time-based off.
- MGC scaling = normalized (was absolute) so PS's percent-based level
  control maps correctly.
- Dashboard mode toggle now drives PS mode (levelbased ↔ manual) instead
  of per-pump setMode. Slider sends Qd to PS (only effective in manual).
- PS code (committed separately): _controlLevelBased now calls
  _applyMachineGroupLevelControl + new Qd topic + forwardDemandToChildren.

KNOWN ISSUE: Basin fills correctly (visible on dashboard), but pumps
don't start when level exceeds startLevel. Likely cause: _pickVariant
for 'level' in _controlLevelBased may not be resolving the predicted
level correctly, or the safetyController is interfering despite
time-threshold being 0. Needs source-level tracing of the PS tick →
_safetyController → _controlLogic → _controlLevelBased path with
logging enabled. To be debugged in the next session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:42:22 +02:00
znetsixe
bc8138c3dc fix(charts): add all required FlowFuse ui-chart properties + document in rule set
Some checks failed
CI / lint-and-test (push) Has been cancelled
Charts rendered blank because the helper was missing 15+ required
FlowFuse properties. The critical three:
  - interpolation: "linear" (no line drawn without it)
  - yAxisProperty: "payload" + yAxisPropertyType: "msg" (chart didn't
    know which msg field to plot)
  - xAxisPropertyType: "timestamp" (chart didn't know the x source)

Also: width/height must be numbers not strings, colors/textColor/
gridColor arrays must be present, and stackSeries/bins/xAxisFormat/
xAxisFormatType all need explicit values.

Fixed the ui_chart helper to include every property from the working
rotatingMachine/examples/03-Dashboard.json charts. Added the full
required-property template + gotcha list to the flow-layout rule set
(Section 4) so this class of bug is caught by reference on the next
chart build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:04:43 +02:00
znetsixe
06d81169e8 fix(trends): add msg.timestamp to chart data points
Some checks failed
CI / lint-and-test (push) Has been cancelled
FlowFuse ui-chart with xAxisType=time may need an explicit timestamp
on each msg for the time axis to render. Added Date.now() as
msg.timestamp on the per-pump dispatcher flow/power outputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:59:04 +02:00
znetsixe
82db2953e9 fix(dashboard): resolve [object Object] in ui-text widgets + use dispatcher pattern
Some checks failed
CI / lint-and-test (push) Has been cancelled
FlowFuse ui-text only supports {{msg.payload}} — not nested paths
like {{msg.payload.state}}. Every ui-text was showing [object Object]
because the formatter sent a fat object as msg.payload and the format
template tried to access sub-fields.

Fix: per-pump (and per-MGC, per-PS) "dispatcher" function on the
Dashboard UI tab. The dispatcher receives the fat object via one
link-in, then returns 7-9 plain-string outputs — one per ui-text
widget — each with msg.payload set to the formatted string value.
Outputs 8+9 carry numeric values (flowNum/powerNum) tagged with
msg.topic for the trend charts, wired directly to both short-term
and long-term chart nodes.

Pattern documented as the recommended approach in the rule set:
"FlowFuse ui-text receives plain strings only — use a dispatcher
function to split a fat object into per-widget outputs."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:54:02 +02:00
znetsixe
d439b048f2 docs: add CLAUDE.md to all 11 node submodules — S88 classification + rule reference
Some checks failed
CI / lint-and-test (push) Has been cancelled
Each node repo now has a CLAUDE.md that declares its S88 hierarchy
level (Control Module / Equipment Module / Unit / Process Cell), the
associated S88 colour, and the placement lane per the superproject's
flow-layout rule set (.claude/rules/node-red-flow-layout.md).

The rule set lives in the superproject only (single source of truth).
Per-node repos reference it. When Claude Code opens a node repo, it
reads the local CLAUDE.md and knows which lane / colour / group to
use when building a multi-node demo or production flow.

Submodule pointer bumps for all 11 nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:48:37 +02:00
znetsixe
e280d87e6a fix(dashboard): split trends into 3 pages + fix chart dimensions
Some checks failed
CI / lint-and-test (push) Has been cancelled
Dashboard was a single page — 30+ widgets + tiny charts competing for
space. Trends were invisible or very small (width/height both "0"
meant "inherit from group" which gave near-zero chart area).

Split into 3 dashboard pages:
  1. Control — Process Demand, Station Controls, MGC/Basin status,
     per-pump panels (unchanged, just moved off trend groups)
  2. Trends — 10 min — rolling 10-minute flow + power charts with
     width=12 (full group), height=8 (tall charts), 300 max points
  3. Trends — 1 hour — same layout with 60-minute window, 1800 points

All 3 pages auto-nav via the FlowFuse sidebar. Same data feed: the
per-pump trend_split function now wires to 4 charts (2 outputs × 2
pages) instead of 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:46:51 +02:00
znetsixe
64944aa9d8 docs(rules): add S88-hierarchical placement rules for Node-RED flows
Some checks failed
CI / lint-and-test (push) Has been cancelled
Sections 10-16 extend the existing flow-layout rule with a deterministic
lane-and-group convention anchored in the S88 hierarchy:

- 8 logical lanes: L0 inputs -> L1 adapters -> L2 CM -> L3 EM -> L4 UN
  -> L5 PC -> L6 formatters -> L7 outputs. 240 px between lanes.
- Lane assignment is by S88 level, not by node name. New nodes inherit
  a lane via a NODE_LEVEL registry, no rule change needed.
- Every parent + its direct children is wrapped in a Node-RED group box
  coloured by the parent's S88 level (Pump A = EM blue, MGC = Unit blue,
  PS = Process Cell blue, ...). Search the parent's name -> group
  highlights.
- Utility clusters (mode broadcast, station-wide commands, demand
  fan-out) use neutral-grey group boxes.
- Dashboard / setup / demo-driver tabs each get a variant of the rule.
- Spacing constants, place() and wrap_in_group() helpers, an 8-step
  verification checklist.

Off-spec colours (settler orange, monster teal, diffuser and
dashboardAPI missing) are flagged in Section 16 as a follow-up cleanup.
The NODE_LEVEL registry already maps those nodes to their semantic S88
level regardless of what the node's own colour currently says.

Rule lives in the superproject only; per-node repos will reference it
from their own CLAUDE.md files (separate commits per submodule).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:31:57 +02:00
znetsixe
0d7af6bfff refactor(examples): split pumpingstation demo across 4 concern-based tabs + add layout rule set
Some checks failed
CI / lint-and-test (push) Has been cancelled
The demo was a single 96-node tab with everything wired directly. Now
4 tabs wired only through named link-out / link-in pairs, and a
permanent rule set for future Claude sessions to follow.

Tabs (by concern, not by data flow):

  🏭 Process Plant   only EVOLV nodes (3 pumps + MGC + PS + 6 measurements)
                     + per-node output formatters
  📊 Dashboard UI   only ui-* widgets, button/setpoint wrappers, trend
                     splitters
  🎛️ Demo Drivers   random demand generator + state holder. Removable
                     in production
  ⚙️ Setup & Init   one-shot deploy-time injects (mode, scaling,
                     auto-startup, random-on)

Cross-tab wiring uses a fixed named-channel contract (cmd:demand,
cmd:mode, cmd:setpoint-A, evt:pump-A, etc.) — multiple emitters can
target a single link-in for fan-in, e.g. both the slider and the random
generator feed cmd:demand.

Bug fixes folded in:

1. Trend chart was empty / scrambled. Root cause: the trend-feeder
   function had ONE output that wired to BOTH flow and power charts,
   so each chart received both flow and power msgs and the legend
   garbled. Now: 2 outputs (flow → flow chart, power → power chart),
   one msg per output.

2. Every ui-text and ui-chart fell on the (0, 0) corner of the editor
   canvas. Root cause: the helper functions accepted x/y parameters
   but never assigned them on the returned node dict — Node-RED
   defaulted every widget to (0, 0) and they piled on top of each
   other. The dashboard render was unaffected (it lays out by group/
   order), but the editor was unreadable. Fixed both helpers and added
   a verification step ("no node should be at (0, 0)") to the rule set.

Spacing convention (now codified):
- 6 lanes per tab at x = [120, 380, 640, 900, 1160, 1420]
- 80 px standard row pitch, 30-40 px for tight ui-text stacks
- 200 px gap between sections, with a comment header per section

New rule set: .claude/rules/node-red-flow-layout.md
- Tab boundaries by concern
- Link-channel naming convention (cmd:/evt:/setup: prefixes)
- Spacing constants
- Trend-split chart pattern
- Inject node payload typing pitfall (per-prop v/vt)
- Dashboard widget rules (every ui-* needs x/y!)
- Do/don't checklist
- Link-out/link-in JSON cheat sheet
- 5-step layout verification before declaring a flow done

CLAUDE.md updated to point at the new rule set.

Verified end-to-end on Dockerized Node-RED 2026-04-13: 168 nodes across
4 tabs, all wired via 22 link-out / 19 link-in pairs, no nodes at
(0, 0), pumps reach operational ~5 s after deploy, MGC distributes
random demand, trends populate per pump.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:13:27 +02:00
znetsixe
7aacee6482 feat(examples): pumpingstation-3pumps-dashboard end-to-end demo + bump generalFunctions
Some checks failed
CI / lint-and-test (push) Has been cancelled
New top-level examples/ folder for end-to-end demos that show how multiple
EVOLV nodes work together (complementing the per-node example flows under
nodes/<name>/examples/). Future end-to-end demos will live as siblings.

First demo: pumpingstation-3pumps-dashboard
- 1 pumpingStation (basin model, manual mode for the demo so it observes
  rather than auto-shutting pumps; safety guards disabled — see README)
- 1 machineGroupControl (optimalcontrol mode, absolute scaling)
- 3 rotatingMachine pumps (hidrostal-H05K-S03R curve)
- 6 measurement nodes (per pump: upstream + downstream pressure mbar,
  simulator mode for continuous activity)
- Process demand input via dashboard slider (0-300 m3/h) AND auto random
  generator (3s tick, [40, 240] m3/h) — both feed PS q_in + MGC Qd
- Auto/Manual mode toggle (broadcasts setMode to all 3 pumps)
- Station-wide Start / Stop / Emergency-Stop buttons
- Per-pump setpoint slider, individual buttons, full status text
- Two trend charts (flow per pump, power per pump)
- FlowFuse dashboard at /dashboard/pumping-station-demo

build_flow.py is the source of truth — it generates flow.json
deterministically and is the right place to extend the demo.

Bumps:
  nodes/generalFunctions  43f6906 -> 29b78a3
    Fix: childRegistrationUtils now aliases the production
    softwareType values (rotatingmachine, machinegroupcontrol) to the
    dispatch keys parent nodes check for (machine, machinegroup). Without
    this, MGC <-> rotatingMachine and pumpingStation <-> MGC wiring
    silently never matched in production even though tests passed.
    Demo confirms: MGC reports '3 machine(s) connected'.

Verified end-to-end on Dockerized Node-RED 2026-04-13: pumps reach
operational ~5s after deploy, MGC distributes random demand across them,
basin tracks net flow direction, all dashboard widgets update each second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:47 +02:00
znetsixe
d7d106773e chore: bump generalFunctions submodule — fix asset menu supplier->type->model cascade
Some checks failed
CI / lint-and-test (push) Has been cancelled
generalFunctions: e50be2e -> 43f6906

Fixes the bug where picking a supplier and then a type left the model
dropdown stuck on "Awaiting Type Selection". Affects every node that
uses the shared assetMenu (measurement, rotatingMachine, pumpingStation,
monster, …). The chained dropdowns now use an explicit downward
cascade with no synthetic change-event dispatch, so the parent handler
can no longer wipe a child after the child was populated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:51:02 +02:00
znetsixe
89f3b5ddc4 chore: bump measurement submodule — fix asset menu render (TDZ ReferenceError)
Some checks failed
CI / lint-and-test (push) Has been cancelled
measurement: d6f8af4 -> <new>

Fixes a regression in the previous measurement editor commit where a
const Temporal Dead Zone error in oneditprepare aborted the function
before the asset / logger / position menu init ran. Menus are now
kicked off first, mode logic is guarded with try/catch and null-checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:15:11 +02:00
znetsixe
d0fe4d0583 chore: bump measurement submodule — editor UX fix (mode as top-level switch)
Some checks failed
CI / lint-and-test (push) Has been cancelled
measurement: 495b4cf -> d6f8af4

Makes Input Mode the top-level hierarchy in the editor: analog-only and
digital-only field blocks toggle visibility live based on the dropdown,
legacy nodes default to 'analog', channels JSON gets live validation,
and runtime logs an actionable warning when the payload shape doesn't
match the selected mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:00:44 +02:00
znetsixe
0300a76ae8 docs: measurement trial-ready — digital mode + dispatcher fix + 71 tests
Some checks failed
CI / lint-and-test (push) Has been cancelled
Bumps:
- nodes/generalFunctions  75d16c6 -> e50be2e  (permissive unit check + measurement schema additions)
- nodes/measurement       f7c3dc2 -> 495b4cf  (digital mode + dispatcher fix + 59 new tests + rewritten README + UI)

Wiki:
- wiki/manuals/nodes/measurement.md — new user manual covering analog and
  digital modes, topic reference, smoothing/outlier methods, unit policy,
  and the pre-fix dispatcher bug advisory.
- wiki/sessions/2026-04-13-measurement-digital-mode.md — session note with
  findings, fix scope, test additions, and dual-mode E2E results.
- wiki/index.md — links both pages and adds the missing 2026-04-13
  rotatingMachine session entry that was omitted from the earlier commit.

Status: measurement is now trial-ready in both analog and digital modes.
71/71 unit tests green (was 12), dual-mode E2E on live Dockerized
Node-RED verifies analog regression and a three-channel MQTT-style
payload (temperature/humidity/pressure) dispatching independently with
per-channel smoothing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:46:00 +02:00
znetsixe
a1aa44f6ca docs: rotatingMachine trial-ready — submodule bumps, wiki manual, session note
Some checks failed
CI / lint-and-test (push) Has been cancelled
Bumps:
- nodes/generalFunctions  024db55 -> 75d16c6  (FSM abort recovery + schema sync)
- nodes/rotatingMachine   07af7ce -> 17b8887  (interruptible sequences, dual-curve tests, rewritten README)

Wiki:
- wiki/manuals/nodes/rotatingMachine.md — new user manual covering inputs,
  outputs, state machine, supported curves, and troubleshooting.
- wiki/sessions/2026-04-13-rotatingMachine-trial-ready.md — session note
  with findings, fixes, test additions, and dual-curve E2E results.
- wiki/index.md — link both and bump updated date.

Status: rotatingMachine is now trial-ready. 91/91 unit tests green, live
Docker E2E verifies shutdown/emergency-stop during ramps and prediction
behaviour across both shipped pump curves (hidrostal-H05K-S03R,
hidrostal-C5-D03R-SHN1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:22:10 +02:00
znetsixe
6cf1821161 chore: remove redundant Makefile and .npmignore, fix .dockerignore
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Makefile: all useful targets duplicate package.json scripts, and
  referenced deleted e2e files. Use npm run instead.
- .npmignore: contained only node_modules/ which npm ignores by default.
- .dockerignore: remove stale paths (manuals/, third_party/, AGENTS.md,
  FUNCTIONAL_ISSUES_BACKLOG.md), add wiki/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:53:26 +02:00
znetsixe
48f790d123 chore: clean up superproject structure
Some checks failed
CI / lint-and-test (push) Has been cancelled
Move content to correct locations:
- AGENTS.md → .agents/AGENTS.md (with orchestrator reference update)
- third_party/docs/ (8 reference docs) → wiki/concepts/
- manuals/ (12 Node-RED docs) → wiki/manuals/

Delete 23 unreferenced one-off scripts from scripts/ (keeping 5 active).
Delete stale Dockerfile.e2e, docker-compose.e2e.yml, test/e2e/.
Remove empty third_party/ directory.

Root is now: README, CLAUDE.md, LICENSE, package.json, Makefile,
Dockerfile, docker-compose.yml, docker/, scripts/ (5), nodes/, wiki/,
plus dotfiles (.agents, .claude, .gitea).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:01:04 +02:00
znetsixe
bac6c620b1 docs: rewrite README with actual project content
Some checks failed
CI / lint-and-test (push) Has been cancelled
Replace generic Dutch template (with placeholder text) with a proper
README showing: node inventory table, architecture summary, install
instructions, test commands, documentation links, and license.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:25:03 +02:00
znetsixe
7ded2a4415 docs: consolidate scattered documentation into wiki
Some checks failed
CI / lint-and-test (push) Has been cancelled
Move architecture/, docs/ content into wiki/ for a single source of truth:
- architecture/deployment-blueprint.md → wiki/architecture/
- architecture/stack-architecture-review.md → wiki/architecture/
- architecture/wiki-platform-overview.md → wiki/architecture/
- docs/ARCHITECTURE.md → wiki/architecture/node-architecture.md
- docs/API_REFERENCE.md → wiki/concepts/generalfunctions-api.md
- docs/ISSUES.md → wiki/findings/open-issues-2026-03.md

Remove stale files:
- FUNCTIONAL_ISSUES_BACKLOG.md (was just a redirect pointer)
- temp/ (stale cloud env examples)

Fix README.md gitea URL (centraal.wbd-rd.nl → wbd-rd.nl).
Update wiki index with all consolidated pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:08:35 +02:00
znetsixe
6d19038784 docs: initialize project wiki from production hardening session
12 pages covering architecture, findings, and metrics from the
rotatingMachine + machineGroupControl hardening work:

- Overview: node inventory, what works/doesn't, current scale
- Architecture: 3D pump curves, group optimization algorithm
- Findings: BEP-Gravitation proof (0.1% of optimum), NCog behavior,
  curve non-convexity, pump switching stability
- Metrics: test counts, power comparison table, performance numbers
- Knowledge graph: structured YAML with all data points and provenance
- Session log: 2026-04-07 production hardening
- Tools: query.py, search.sh, lint.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:36:08 +02:00
znetsixe
fd9d1679cb fix: update submodule refs — production hardening for rotatingMachine and machineGroupControl
rotatingMachine:
- Safety fixes: async input handler, emergencyStop case fix, null guards,
  listener cleanup, tick loop race condition, editor timeout
- Prediction: remove efficiency rounding (was breaking NCog/BEP), fix
  variant reads, curve anomaly detection
- 43 new tests (76 total)

machineGroupControl:
- Critical: fix flowmovement unit mismatch (m³/s sent where m³/h expected,
  pumps never moved from minimum)
- Fix absolute scaling comparison bug, empty Qd block, empty-machines guards
- Add marginal-cost refinement loop: reduces gap to brute-force optimum
  from 2.1% to <0.1%
- 2 new test files with NCog distribution and power comparison tests

generalFunctions:
- Fix 3 anomalous power values in hidrostal-H05K-S03R curve data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:22 +02:00
znetsixe
4336002b77 fix: update submodule refs with bug fixes for validateSchema recursion and nodeClass syntax
Some checks failed
CI / lint-and-test (push) Has been cancelled
- generalFunctions: fix infinite recursion in validateSchema when version string is in schema
- rotatingMachine: fix missing closing brace in emergencystop case block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:46:34 +02:00
znetsixe
f57343f5e3 Update submodule refs after merge with main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:29:31 +02:00
znetsixe
65ceb696ab Merge remote-tracking branch 'origin/main' into dev-rene
# Conflicts:
#	.dockerignore
#	.gitmodules
#	Dockerfile
#	docker-compose.yml
#	nodes/generalFunctions
#	nodes/machineGroupControl
#	nodes/measurement
#	nodes/monster
#	nodes/pumpingStation
#	nodes/reactor
#	nodes/rotatingMachine
#	nodes/settler
#	package-lock.json
#	package.json
2026-03-31 18:29:03 +02:00
root
91a298960c Prepare reactor, diffuser, and settler updates for mainline merge 2026-03-31 14:26:33 +02:00
Rene De Ren
35221fc5dd Sync pushed submodule refs
Some checks failed
CI / lint-and-test (push) Has been cancelled
2026-03-12 16:47:08 +01:00
Rene De Ren
93a5b6a90e Expose output format controls across node editors 2026-03-12 16:39:54 +01:00
Rene De Ren
1d98670706 Validate diffuser through the full stack 2026-03-12 16:32:25 +01:00
Rene De Ren
a432eea7fe Track config-driven output formatting support 2026-03-12 16:13:47 +01:00
Rene De Ren
9cb3657bae Track dashboardapi buildConfig adoption 2026-03-12 16:11:10 +01:00
Rene De Ren
bd9432eebb Validate dashboardapi round-trip through Node-RED 2026-03-12 11:40:37 +01:00
Rene De Ren
c9bacb64c8 Add monster coverage and stack validation 2026-03-12 10:32:09 +01:00
Rene De Ren
e580c93c84 docs: add open issues from codebase scan
Tracked issues for diffuser restoration, ML module relocation,
monster architecture modernization, test code cleanup, and dashboardAPI improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:34:51 +01:00
Rene De Ren
b02306c42f fix: codebase scan — bug fixes, security, logging consistency, monster modernization
- generalFunctions: add missing migrateConfig(), config versioning, formatters module
- rotatingMachine: fix eneableLog typo, correct child registration ID
- machineGroupControl: console.log → structured logger
- settler/reactor: console.log → logger, throw on unknown reactor type
- monster: modernize imports to require('generalFunctions'), remove broken
  TensorFlow code, add childRegistrationUtils, consolidate input handlers
- dashboardAPI: remove hardcoded Grafana bearer token, use logger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:33:52 +01:00
Rene De Ren
2c76430394 feat: working E2E container stack with Node-RED + InfluxDB + Grafana
- Fix Dockerfile.e2e to install EVOLV properly in Node-RED /data/
- Add measurement node E2E test flow with scaling (4-20mA to 0-5m)
- Add Grafana health check to run-e2e.sh
- Guard pumpingStation demo IIFE with require.main check
- All 10 EVOLV nodes load successfully in containerized Node-RED

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:38:14 +01:00
Rene De Ren
49ebd833db feat: add node tests, integration tests, API reference, fix pumpingStation bug
- Add 127 unit tests for measurement, pumpingStation, reactor, settler specificClass
- Add 32 integration tests for parent-child registration flows
- Fix pumpingStation tick() calling non-existent _calcTimeRemaining (was _calcRemainingTime)
- Add API reference documentation for all generalFunctions modules

Total tests: 536 (389 Jest + 23 node:test + 124 legacy), all passing

Closes #17, #19, #20

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:32:04 +01:00
Rene De Ren
905a061590 feat: architecture refactor — validators, positions, menuUtils, ESLint, tests, CI
Major improvements across the codebase:

- Extract validationUtils.js (548→217 lines) into strategy pattern validators
- Extract menuUtils.js (543→35 lines) into 6 focused menu modules
- Adopt POSITIONS constants across 23 files (183 replacements)
- Eliminate all 71 ESLint warnings (0 errors, 0 warnings)
- Add 158 unit tests for ConfigManager, MeasurementContainer, ValidationUtils
- Add architecture documentation with Mermaid diagrams
- Add CI pipeline (Docker, ESLint, Jest, Makefile)
- Add E2E infrastructure (docker-compose.e2e.yml)

Test results: 377 total (230 Jest + 23 node:test + 124 legacy), all passing
Lint: 0 errors, 0 warnings

Closes #2, #3, #9, #13, #14, #18

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:37:20 +01:00
p.vanderwilt
80de324b32 Update reactor submodule to latest commit 2025-11-28 11:53:57 +01:00
p.vanderwilt
c8d5ea0fce Update submodule commits for reactor and rotatingMachine 2025-11-21 14:49:50 +01:00
p.vanderwilt
b871b23c24 Remove TensorFlow dependencies from package.json 2025-11-21 11:56:59 +01:00
p.vanderwilt
91b681a74d Add additional sensors 2025-11-12 10:48:38 +01:00
p.vanderwilt
76d2008e52 Update submodule commits and package-lock.json dependencies 2025-11-12 10:30:51 +01:00
p.vanderwilt
3c304f14e5 Update submodules for recirculation implementation 2025-11-06 15:03:43 +01:00
p.vanderwilt
24c443840b Add settler to package.json 2025-10-31 12:14:58 +01:00
p.vanderwilt
c4c8629c01 add settler submodule 2025-10-31 12:11:50 +01:00
609c72cedc Merge pull request 'dev-Rene' (#5) from dev-Rene into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/EVOLV/pulls/5
2025-10-24 19:25:04 +00:00
239 changed files with 20579 additions and 8941 deletions

View File

@@ -15,12 +15,21 @@ EVOLV is a modular Node-RED package bundling multiple custom control/automation
- `node_modules/`: Local install output; do not edit.
## Agent Knowledge Base
- `.agents/`: Root directory for repository-specific agent definitions and knowledge base content (non-runtime/support assets, not Node-RED production code).
- `.agents/skills/`: EVOLV specialist skills (domain instructions, workflows, and orchestrator logic).
- When tasks involve domain reasoning or specialist routing, prefer `.agents/skills/*/SKILL.md` as the primary in-repo source of guidance.
- `.claude/skills/`: **EVOLV domain skills** — Claude-Code-native, auto-discovered, invokable via the `Skill` tool. Use for domain reasoning (rotating equipment, biology, telemetry, security, …).
- `.claude/agents/`: **Claude Code subagents** — auto-discovered, invokable via the `Agent` tool with `subagent_type:`. Use for spawnable independent work.
- `.agents/`: Holds this `AGENTS.md` routing table, function-anchors (per-node behavioural contracts in `.agents/function-anchors/`), and the improvements backlog (`.agents/improvements/`). **No skills live here** — the skill surface was migrated to `.claude/skills/` in 2026-05.
### Skills vs Subagents — when to use which
| Surface | Path | Loaded by | When to use |
|---|---|---|---|
| Claude Code skills | `.claude/skills/*/SKILL.md` | Auto-discovered; invokable via the `Skill` tool | Domain knowledge / workflow recipes the active agent should *read* before deciding |
| Claude Code subagents | `.claude/agents/*.md` | Auto-discovered; invokable via the `Agent` tool | Spawnable independent work (audits, parallel exploration, focused tasks) |
The orchestrator lives only as a subagent (`.claude/agents/evolv-orchestrator.md`); there is no orchestrator skill.
## Agent Invocation Policy
- Default: always invoke orchestrator first via `.agents/skills/evolv-orchestrator/SKILL.md`.
- Default: always invoke the orchestrator subagent first (`Agent(subagent_type: 'evolv-orchestrator')`).
- Orchestrator decides specialist selection, task decomposition, execution order, and integration checks.
- `team` keyword policy:
- When the user says `team`, treat the request as orchestrator-led multi-specialist work.
@@ -33,7 +42,7 @@ EVOLV is a modular Node-RED package bundling multiple custom control/automation
- recommended plan
- risks and tradeoffs
- unresolved disagreements (if any)
- For any change inside `nodes/*` that affects Node-RED runtime/editor behavior, always load `.agents/skills/evolv-frontend-node-red/SKILL.md` before editing.
- For any change inside `nodes/*` that affects Node-RED runtime/editor behavior, always load `.claude/skills/evolv-frontend-node-red/SKILL.md` before editing.
- For dashboard graphics/charts work, also load `manuals/node-red/flowfuse-ui-chart-manual.md` and `manuals/node-red/flowfuse-dashboard-layout-manual.md`.
- FlowFuse `ui-chart` baseline for EVOLV: use series by `msg.topic` (`category: "topic"`, `categoryType: "msg"`). Avoid leaving `category` blank.
- Direct specialist invocation is allowed only when all are true:
@@ -71,17 +80,11 @@ Current owner-approved defaults (February 16, 2026):
- Breaking `msg.topic`/payload changes are allowed only with explicit migration/deprecation notes.
- Safety posture: `availability-first`
- Prefer continuity of operation with bounded safeguards over early protective trips.
- Decision logging: `required for all decision-gate changes`
- Every decision-gate outcome must be recorded in `.agents/decisions/`.
Decision log:
- Record important decisions in `.agents/decisions/DECISION-YYYYMMDD-<slug>.md`.
- Include context, options, decision, consequences, and rollback/migration notes.
- Decision logging: record load-bearing decisions in the commit message and PR description. Live open items belong in `.claude/refactor/OPEN_QUESTIONS.md`. Superseded plan artifacts live in `.agents/improvements/Archive/` and `.claude/refactor/Archive/`.
Functional/architectural improvements backlog:
- Track deferred functional/runtime/architecture improvements in `.agents/improvements/IMPROVEMENTS_BACKLOG.md`.
- If an improvement is discovered during non-functional work, add it to this backlog before closing the task.
- Keep the top priority review list in `.agents/improvements/TOP10_PRODUCTION_PRIORITIES_YYYY-MM-DD.md` when requested.
- When an item is implemented after review, remove it from `.agents/improvements/IMPROVEMENTS_BACKLOG.md` and note the fix in session notes/PR context.
## Agent Routing Table
@@ -89,22 +92,22 @@ Use this table after orchestrator triage, or for approved single-domain direct c
| Task Pattern | Primary Skill | Path |
|---|---|---|
| Multi-domain feature, ambiguous ownership, or cross-node integration planning | Orchestrator | `.agents/skills/evolv-orchestrator/SKILL.md` |
| Node-RED editor HTML, form defaults, menu/config endpoints, UI/runtime config parity | Frontend + Node-RED expert | `.agents/skills/evolv-frontend-node-red/SKILL.md` |
| Rotating machine behavior, pump curves, operating envelopes, mechanical plausibility | Mechanical rotating equipment engineer | `.agents/skills/evolv-mechanical-rotating-equipment/SKILL.md` |
| Sensor/measurement semantics, units, validation, quality flags, measurement assets | Instrumentation engineer | `.agents/skills/evolv-instrumentation-assets/SKILL.md` |
| System-wide control architecture, sequencing, mode transitions, parent-child topic contracts | System/process control engineer | `.agents/skills/evolv-process-systems-control/SKILL.md` |
| Biological process modeling, ASM kinetics, oxygen demand, sludge/retention assumptions | Biological process engineer | `.agents/skills/evolv-biological-process-engineering/SKILL.md` |
| InfluxDB telemetry model, tags/fields, retention, Grafana query compatibility | Database/Influx architect | `.agents/skills/evolv-database-influx-architecture/SKILL.md` |
| Sensor/analyzer product behavior, warmup/drift/fouling, device quality semantics | Measurement product specialist | `.agents/skills/evolv-measurement-product-specialist/SKILL.md` |
| OT edge protocol integration (OPC UA/PLC/fieldbus mapping), reconnect and handshake behavior | OT edge PLC integration specialist | `.agents/skills/evolv-ot-edge-plc-integration/SKILL.md` |
| OT/IT threat review, secure defaults, endpoint hardening, control-message safety | OT/IT security engineer | `.agents/skills/evolv-ot-it-security/SKILL.md` |
| Alarm strategy, interlocks, permissives, trip/reset behavior | Alarms/interlocks engineer | `.agents/skills/evolv-alarms-interlocks-permissives/SKILL.md` |
| Hydraulics and cross-node mass/volume balance plausibility | Process hydraulics engineer | `.agents/skills/evolv-process-hydraulics-mass-balance/SKILL.md` |
| Telemetry KPI contract design, dashboard/query compatibility, operator diagnostics | Telemetry/analytics specialist | `.agents/skills/evolv-telemetry-analytics-dashboards/SKILL.md` |
| Wastewater compliance/reporting impact and auditability | Regulatory compliance specialist | `.agents/skills/evolv-regulatory-compliance-wastewater/SKILL.md` |
| FAT/SAT planning, commissioning evidence, rollout readiness gates | Commissioning and validation specialist | `.agents/skills/evolv-commissioning-validation/SKILL.md` |
| Code quality review, regression risk, test gaps, technical debt prioritization | Quality/debt engineer | `.agents/skills/evolv-quality-technical-debt/SKILL.md` |
| Multi-domain feature, ambiguous ownership, or cross-node integration planning | Orchestrator | `.claude/agents/evolv-orchestrator.md` (subagent — spawn via `Agent` tool) |
| Node-RED editor HTML, form defaults, menu/config endpoints, UI/runtime config parity | Frontend + Node-RED expert | `.claude/skills/evolv-frontend-node-red/SKILL.md` |
| Rotating machine behavior, pump curves, operating envelopes, mechanical plausibility | Mechanical rotating equipment engineer | `.claude/skills/evolv-mechanical-rotating-equipment/SKILL.md` |
| Sensor/measurement semantics, units, validation, quality flags, measurement assets | Instrumentation engineer | `.claude/skills/evolv-instrumentation-assets/SKILL.md` |
| System-wide control architecture, sequencing, mode transitions, parent-child topic contracts | System/process control engineer | `.claude/skills/evolv-process-systems-control/SKILL.md` |
| Biological process modeling, ASM kinetics, oxygen demand, sludge/retention assumptions | Biological process engineer | `.claude/skills/evolv-biological-process-engineering/SKILL.md` |
| InfluxDB telemetry model, tags/fields, retention, Grafana query compatibility | Database/Influx architect | `.claude/skills/evolv-database-influx-architecture/SKILL.md` |
| Sensor/analyzer product behavior, warmup/drift/fouling, device quality semantics | Measurement product specialist | `.claude/skills/evolv-measurement-product-specialist/SKILL.md` |
| OT edge protocol integration (OPC UA/PLC/fieldbus mapping), reconnect and handshake behavior | OT edge PLC integration specialist | `.claude/skills/evolv-ot-edge-plc-integration/SKILL.md` |
| OT/IT threat review, secure defaults, endpoint hardening, control-message safety | OT/IT security engineer | `.claude/skills/evolv-ot-it-security/SKILL.md` |
| Alarm strategy, interlocks, permissives, trip/reset behavior | Alarms/interlocks engineer | `.claude/skills/evolv-alarms-interlocks-permissives/SKILL.md` |
| Hydraulics and cross-node mass/volume balance plausibility | Process hydraulics engineer | `.claude/skills/evolv-process-hydraulics-mass-balance/SKILL.md` |
| Telemetry KPI contract design, dashboard/query compatibility, operator diagnostics | Telemetry/analytics specialist | `.claude/skills/evolv-telemetry-analytics-dashboards/SKILL.md` |
| Wastewater compliance/reporting impact and auditability | Regulatory compliance specialist | `.claude/skills/evolv-regulatory-compliance-wastewater/SKILL.md` |
| FAT/SAT planning, commissioning evidence, rollout readiness gates | Commissioning and validation specialist | `.claude/skills/evolv-commissioning-validation/SKILL.md` |
| Code quality review, regression risk, test gaps, technical debt prioritization | Quality/debt engineer | `.claude/skills/evolv-quality-technical-debt/SKILL.md` |
## Shared Engineering Baseline
- Dependencies: prefer `npm ci` at repo root (uses `package-lock.json`). Avoid changing `package.json` without updating the lockfile.
@@ -153,15 +156,15 @@ Enforcement:
- If legacy nodes are missing these artifacts, treat as technical debt and bring to parity during related work.
## Skill Ownership Of Detailed Standards
- Node-RED structure, file responsibilities, admin endpoints, and new-node checklist: `.agents/skills/evolv-frontend-node-red/SKILL.md`
- Message/port conventions and topic contract behavior: `.agents/skills/evolv-process-systems-control/SKILL.md`
- Biological/kinetic modeling assumptions and plausibility constraints: `.agents/skills/evolv-biological-process-engineering/SKILL.md`
- Sensor/analyzer product behavior and quality-state semantics: `.agents/skills/evolv-measurement-product-specialist/SKILL.md`
- PLC/OPC UA edge protocol mapping and reconnect semantics: `.agents/skills/evolv-ot-edge-plc-integration/SKILL.md`
- Alarm/interlock/permissive design standards: `.agents/skills/evolv-alarms-interlocks-permissives/SKILL.md`
- Hydraulics and mass-balance consistency rules: `.agents/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
- Telemetry KPI and dashboard/query contract standards: `.agents/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
- Wastewater compliance and auditability constraints: `.agents/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
- Commissioning/FAT/SAT validation standards: `.agents/skills/evolv-commissioning-validation/SKILL.md`
- Test policy depth and quality gates: `.agents/skills/evolv-quality-technical-debt/SKILL.md`
- Multi-skill decomposition/integration and interview protocol: `.agents/skills/evolv-orchestrator/SKILL.md`
- Node-RED structure, file responsibilities, admin endpoints, and new-node checklist: `.claude/skills/evolv-frontend-node-red/SKILL.md`
- Message/port conventions and topic contract behavior: `.claude/skills/evolv-process-systems-control/SKILL.md`
- Biological/kinetic modeling assumptions and plausibility constraints: `.claude/skills/evolv-biological-process-engineering/SKILL.md`
- Sensor/analyzer product behavior and quality-state semantics: `.claude/skills/evolv-measurement-product-specialist/SKILL.md`
- PLC/OPC UA edge protocol mapping and reconnect semantics: `.claude/skills/evolv-ot-edge-plc-integration/SKILL.md`
- Alarm/interlock/permissive design standards: `.claude/skills/evolv-alarms-interlocks-permissives/SKILL.md`
- Hydraulics and mass-balance consistency rules: `.claude/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
- Telemetry KPI and dashboard/query contract standards: `.claude/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
- Wastewater compliance and auditability constraints: `.claude/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
- Commissioning/FAT/SAT validation standards: `.claude/skills/evolv-commissioning-validation/SKILL.md`
- Test policy depth and quality gates: `.claude/skills/evolv-quality-technical-debt/SKILL.md`
- Multi-skill decomposition/integration and interview protocol: `.claude/agents/evolv-orchestrator.md` (subagent)

View File

@@ -1,38 +0,0 @@
# DECISION-20260216-agent-harness-defaults
## Context
- Task/request: Adapt EVOLV agents/skills using Harness Engineering patterns and set owner-controlled operating defaults.
- Impacted files/contracts: `AGENTS.md`, `.agents/skills/*/SKILL.md`, `.agents/skills/*/agents/openai.yaml`, decision-log policy.
- Why a decision is required now: New harness workflow needs explicit defaults for compatibility, safety bias, and governance discipline.
## Options
1. Compatibility posture
- Option A: strict backward compatibility
- Option B: controlled compatibility breaks with migration notes
2. Safety posture
- Option A: protection-first
- Option B: availability-first
3. Decision logging scope
- Option A: required only for breaking/risky changes
- Option B: required for all decision-gate outcomes
## Decision
- Selected option: Compatibility `controlled`; Safety `availability-first`; Decision logging `required for all decision-gate changes`.
- Decision owner: User
- Date: February 16, 2026
- Rationale: Maintain delivery and operational continuity while preserving governance through mandatory, durable decision records.
## Consequences
- Compatibility impact: Breaking contract changes are permissible only when migration/deprecation is explicit.
- Safety/security impact: Control changes should bias toward continuity with bounded safeguards; critical protections still require explicit constraints.
- Data/operations impact: Decision traceability improves cross-turn consistency and auditability.
## Implementation Notes
- Required code/doc updates: Set defaults in `AGENTS.md` and orchestrator skill instructions; keep decision-log template active.
- Validation evidence required: Presence of defaults in policy docs and this decision artifact under `.agents/decisions/`.
## Rollback / Migration
- Rollback strategy: Update defaults in `AGENTS.md` and orchestrator SKILL; create a superseding decision log entry.
- Migration/deprecation plan: For any future hard-break preference, require explicit migration plan and effective date in a new decision entry.

View File

@@ -1,43 +0,0 @@
# Decision: Shared Modern PID in generalFunctions + PumpingStation Flow-Based Adoption
- Date: 2026-02-23
- Scope: `nodes/generalFunctions/src/pid/*`, `nodes/pumpingStation/src/*`
## Context
Flow-based control in `pumpingStation` needed a production-grade PID with freeze/unfreeze, runtime retuning, and support for cascade/secondary-loop architecture.
## Options Considered
1. Implement PID only inside `pumpingStation`.
2. Implement shared PID in `generalFunctions` and consume it from `pumpingStation`.
3. Keep current heuristic (non-PID) flow controller.
## Decision
Chose option 2.
## Rationale
- PID behavior is cross-domain control functionality and should be reusable across EVOLV nodes.
- `generalFunctions` already serves as shared utility/runtime infrastructure.
- Reuse reduces drift and duplicated control logic.
- PumpingStation can immediately adopt shared PID while preserving existing topic contracts.
## Consequences
- Positive:
- Single, test-covered PID implementation with modern features.
- PumpingStation flow mode becomes true closed-loop control.
- Runtime support for freeze/unfreeze and tuning updates without redeploy.
- Risks:
- Behavioral differences versus prior heuristic flow control.
- Requires conservative tuning per site.
## Safety / Compatibility
- No existing topic names were removed.
- Added optional control topics for PID runtime management.
- Existing non-flowbased modes remain intact.
## Rollback
- Revert `nodes/pumpingStation/src/specificClass.js` flow-based branch to previous heuristic logic.
- Keep shared PID module in `generalFunctions` for future use, or revert `nodes/generalFunctions/src/pid/*` if required.
## Migration Notes
- For `flowbased`, start with low `kp/ki`, verify stability in commissioning, then tune upward.
- Use `freezeFlowPid` and `setFlowPidMode` during maintenance or manual takeover.

View File

@@ -1,33 +0,0 @@
# Decision: Harden NRMSE and Use Metric Profiles in RotatingMachine
- Date: 2026-02-24
- Scope: `nodes/generalFunctions/src/nrmse/*`, `nodes/rotatingMachine/src/specificClass.js`
## Context
Drift analytics were previously single-path and flow-focused with weak input safeguards in NRMSE.
Requirement: make NRMSE architecturally robust and apply it across multiple measurements in rotatingMachine.
## Decision
Adopt a metric-profile drift architecture:
1. Harden `generalFunctions/nrmse` with:
- strict validation for malformed inputs
- timestamp-aware alignment support
- per-metric state
- configurable rolling window and EWMA long-term trend
- point-based API (`assessPoint`) while retaining legacy calls
2. Rewire rotatingMachine to consume NRMSE per metric:
- `flow` model drift
- `power` model drift
- pressure-quality drift as node-specific plausibility/redundancy assessment
3. Expose drift and confidence outputs per metric in node output payload.
## Consequences
- Drift computations are deterministic and safer under bad inputs.
- RotatingMachine confidence now reflects multiple measurement channels.
- Output schema expands with power/pressure drift fields.
## Rollback Notes
- Revert `errorMetrics.js` and rotatingMachine drift wiring to return to legacy flow-only drift behavior.

View File

@@ -1,34 +0,0 @@
# Decision: RotatingMachine Hydraulic Efficiency Correction and Prediction Confidence
- Date: 2026-02-24
- Scope: `nodes/rotatingMachine/src/specificClass.js`, rotatingMachine integration tests
## Context
Hydraulic efficiency calculation in `rotatingMachine` was dimensionally inconsistent and could over/under-report efficiency KPIs.
At the same time, prediction drift tooling (`nrmse`) existed but was not actively connected to rotatingMachine output confidence.
## Options Considered
1. Keep existing formula and only tune thresholds.
2. Replace formula with standard hydraulic power/efficiency equations and expose prediction confidence from live pressure source + drift.
## Decision
Adopt option 2.
- Hydraulic power now follows standard engineering relation:
- `P_h = Q * Δp` (equivalent to `ρ g Q H`)
- `η_h = P_h / P_in`
- RotatingMachine now computes flow drift via `nrmse` from measured vs predicted flow windows.
- RotatingMachine now exposes prediction confidence fields in output:
- `predictionQuality`
- `predictionConfidence`
- `predictionPressureSource`
- `predictionFlags`
## Consequences
- Efficiency KPIs become physically interpretable and traceable to pressure/flow/power inputs.
- Prediction trust is now observable by downstream control/dashboard layers.
- Output schema is expanded with new prediction confidence fields.
## Rollback / Migration Notes
- Rollback path: revert `specificClass.js` hydraulic block and prediction-health integration.
- No mandatory migration required for existing flows unless they choose to consume new prediction confidence fields.

View File

@@ -1,38 +0,0 @@
# Decision: Canonical Unit Anchoring and Curve Unit Normalization in RotatingMachine
- Date: 2026-02-24
- Scope: `nodes/rotatingMachine/*`, `nodes/generalFunctions/src/measurements/MeasurementContainer.js`, `nodes/generalFunctions/src/configs/rotatingMachine.json`
## Context
RotatingMachine previously relied on node-local defaults for measurement storage units, with implicit assumptions that loaded machine curves used the same units as runtime configuration. This made unit drift likely when model curves, simulated inputs, and runtime settings differed.
Owner decision direction:
- use a single unit anchor strategy
- treat node/UI units as ingress/egress only
- add explicit curve unit metadata
- reject or flag blank/invalid measurement units
## Decision
1. Extend `MeasurementContainer` with optional canonical-anchor mode:
- per-type canonical unit mapping
- strict unit validation and required-unit policy
- compatibility checks by measure family
- requested-unit conversion at flattened output stage
2. Apply canonical policy in `rotatingMachine` runtime:
- internal storage and calculations anchored to SI-like canonical units (`Pa`, `m3/s`, `W`, `K`)
- egress payloads converted back to configured output units
- ingress `simulateMeasurement` path requires explicit valid units
3. Add explicit curve unit metadata (`asset.curveUnits`) and normalize loaded curves into canonical units before predictor initialization.
## Consequences
- Unit handling is centralized and deterministic for RotatingMachine.
- Curve/model-unit mismatch risk is reduced by explicit metadata plus normalization.
- Existing output topic/field names remain stable; values are emitted in configured output units while internals stay canonical.
- This establishes a migration template for remaining EVOLV nodes.
## Rollback Notes
- Revert `MeasurementContainer` canonical/validation extensions.
- Revert RotatingMachine unit-policy and curve-normalization wiring.
- Remove `asset.curveUnits` schema entry and restore previous node-local default-unit behavior.

View File

@@ -1,37 +0,0 @@
# Decision: Unit-Anchor Rollout Phase 1 (MachineGroup, PumpingStation, Valve, ValveGroupControl)
- Date: 2026-02-24
- Scope:
- `nodes/machineGroupControl/src/nodeClass.js`
- `nodes/machineGroupControl/src/specificClass.js`
- `nodes/pumpingStation/src/nodeClass.js`
- `nodes/pumpingStation/src/specificClass.js`
- `nodes/valve/src/nodeClass.js`
- `nodes/valve/src/specificClass.js`
- `nodes/valveGroupControl/src/nodeClass.js`
- `nodes/valveGroupControl/src/specificClass.js`
## Context
After adopting canonical-unit anchoring in `rotatingMachine`, adjacent controller nodes still mixed local units, unitless writes, and implicit conversions. That left cross-node behavior sensitive to registration order and source-unit assumptions.
## Decision
1. Apply the same canonical storage policy per node:
- internal storage in canonical units (`Pa`, `m3/s`, `W`, `K` where relevant),
- preferred/output units for operator-facing status and output payloads.
2. Enable strict measurement ingress discipline on migrated nodes:
- `strictUnitValidation: true`,
- `throwOnInvalidUnit: true`,
- required unit for physically dimensional types (`flow`, `pressure`, `power`, `temperature`, and node-specific equivalents).
3. Replace unitless runtime writes/reads with explicit-unit helpers in each nodes domain class, including child-machine/child-valve interactions.
## Consequences
- Cross-node calculations now run against a deterministic unit anchor in phase-1 nodes.
- Status/output values remain in preferred/output units, while internal math stays canonical.
- Legacy paths that send dimensional values without units now fail fast instead of silently coercing.
## Rollback Notes
- Revert the eight files listed in scope.
- Restore previous `MeasurementContainer` initialization (non-canonical, non-strict behavior) in each node.
- Remove helper-based explicit unit reads/writes and revert to prior direct chain usage.

View File

@@ -1,36 +0,0 @@
# DECISION-20260323-architecture-layering-resilience-and-config-authority
## Context
- Task/request: refine the EVOLV architecture baseline using the current stack drawings and owner guidance.
- Impacted files/contracts: architecture documentation, future wiki structure, telemetry/storage strategy, security boundaries, and configuration authority assumptions.
- Why a decision is required now: the architecture can no longer stay at a generic "Node-RED plus cloud" level; several operating principles were clarified by the owner and need to be treated as architectural defaults.
## Options
1. Keep the architecture intentionally broad and tool-centric
- Benefits: fewer early commitments.
- Risks: blurred boundaries for resilience, data ownership, and security; easier to drift into contradictory implementations.
- Rollout notes: wiki remains descriptive but not decision-shaping.
2. Adopt explicit defaults for resilience, API boundary, telemetry layering, and configuration authority
- Benefits: clearer target operating model; easier to design stack services and wiki pages consistently; aligns diagrams with intended operational behavior.
- Risks: some assumptions may outpace current implementation and therefore create an architecture debt backlog.
- Rollout notes: document gaps clearly and treat incomplete systems as planned workstreams rather than pretending they already exist.
## Decision
- Selected option: Option 2.
- Decision owner: repository owner confirmed during architecture review.
- Date: 2026-03-23.
- Rationale: the owner clarified concrete architecture goals that materially affect security, resilience, and platform structure. The documentation should encode those as defaults instead of leaving them implicit.
## Consequences
- Compatibility impact: low immediate code impact, but future implementations should align to these defaults.
- Safety/security impact: improved boundary clarity by making central the integration entry point and keeping edge protected behind site/central mediation.
- Data/operations impact: multi-level InfluxDB and smart-storage behavior become first-class design concerns; `tagcodering` becomes the intended configuration backbone.
## Implementation Notes
- Required code/doc updates: update the architecture review doc, add visual wiki-ready diagrams, and track follow-up work for incomplete `tagcodering` integration and telemetry policy design.
- Validation evidence required: architecture docs reflect the agreed principles and diagrams; no contradiction with current repo evidence for implemented components.
## Rollback / Migration
- Rollback strategy: return to a generic descriptive architecture document without explicit defaults.
- Migration/deprecation plan: implement these principles incrementally, starting with configuration authority, telemetry policy, and site/central API boundaries.

View File

@@ -1,36 +0,0 @@
# DECISION-20260323-compose-secrets-via-env
## Context
- Task/request: harden the target-state stack example so credentials are not stored directly in `temp/cloud.yml`.
- Impacted files/contracts: `temp/cloud.yml`, deployment/operations practice for target-state infrastructure examples.
- Why a decision is required now: the repository contained inline credentials in a tracked compose file, which conflicts with the intended security posture and creates avoidable secret-leak risk.
## Options
1. Keep credentials inline in the compose file
- Benefits: simplest to run as a standalone example.
- Risks: secrets leak into git history, reviews, copies, and local machines; encourages unsafe operational practice.
- Rollout notes: none, but the risk remains permanent once committed.
2. Move credentials to server-side environment variables and keep only placeholders in compose
- Benefits: aligns the manifest with a safer deployment pattern; keeps tracked config portable across environments; supports secret rotation without editing the compose file.
- Risks: operators must manage `.env` or equivalent secret injection correctly.
- Rollout notes: provide an example env file and document that the real `.env` stays on the server and out of version control.
## Decision
- Selected option: Option 2.
- Decision owner: repository owner confirmed during task discussion.
- Date: 2026-03-23.
- Rationale: the target architecture should model the right operational pattern. Inline secrets in repository-tracked compose files are not acceptable for EVOLV's intended OT/IT deployment posture.
## Consequences
- Compatibility impact: low; operators now need to supply environment variables when deploying `temp/cloud.yml`.
- Safety/security impact: improved secret hygiene and lower credential exposure risk.
- Data/operations impact: deployment requires an accompanying `.env` on the server or explicit `--env-file` usage.
## Implementation Notes
- Required code/doc updates: replace inline secrets in `temp/cloud.yml`; add `temp/cloud.env.example`; keep the real `.env` untracked on the server.
- Validation evidence required: inspect compose file for `${...}` placeholders and verify no real credentials remain in tracked files touched by this change.
## Rollback / Migration
- Rollback strategy: reintroduce inline values, though this is not recommended.
- Migration/deprecation plan: create a server-local `.env` from `temp/cloud.env.example`, fill in real values, and run compose from that environment.

View File

@@ -1,36 +0,0 @@
# DECISION-YYYYMMDD-<slug>
## Context
- Task/request:
- Impacted files/contracts:
- Why a decision is required now:
## Options
1. Option A
- Benefits:
- Risks:
- Rollout notes:
2. Option B
- Benefits:
- Risks:
- Rollout notes:
## Decision
- Selected option:
- Decision owner:
- Date:
- Rationale:
## Consequences
- Compatibility impact:
- Safety/security impact:
- Data/operations impact:
## Implementation Notes
- Required code/doc updates:
- Validation evidence required:
## Rollback / Migration
- Rollback strategy:
- Migration/deprecation plan:

View File

@@ -1,15 +0,0 @@
# EVOLV Decision Log
Use this folder to store high-impact agent/user decisions that affect compatibility, safety, security, schema, or rollout risk.
Naming:
- `DECISION-YYYYMMDD-<slug>.md`
When to log:
- topic/payload/API contract changes
- safety envelope or fail-safe strategy changes
- security posture/default changes
- Influx retention/backfill/schema tradeoffs
- explicit acceptance of deferred high-risk debt
Start from `DECISION_TEMPLATE.md` for new entries.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Alarms Interlocks Permissives"
short_description: "Protective logic and operator alarm specialist"
default_prompt: "Map alarm/interlock/permissive behavior in the impacted EVOLV nodes, define deterministic trip and reset rules, validate sequence edge cases, and return test-backed recommendations with clear operational tradeoffs."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Biological Process Engineering"
short_description: "Wastewater biology and kinetics specialist"
default_prompt: "Map biological state variables and kinetics in the impacted EVOLV nodes, define non-negotiable biological invariants, validate oxygen/temperature/time-step behavior, and return test-backed recommendations with calibration assumptions and risks."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Commissioning Validation"
short_description: "FAT/SAT and deployment-readiness specialist"
default_prompt: "Create a commissioning evidence plan from impacted EVOLV contracts, define measurable FAT/SAT acceptance criteria, verify failure and recovery paths, and return go/no-go risks with rollback guidance."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Database + Influx Architect"
short_description: "Design telemetry schema for Influx and Grafana"
default_prompt: "Define EVOLV telemetry schema from current payload/query usage, enforce cardinality and compatibility invariants, validate with representative queries, and escalate decision-gate tradeoffs for retention/backfill or breaking schema changes."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Frontend + Node-RED"
short_description: "Build EVOLV Node-RED editor/runtime UX safely"
default_prompt: "Implement EVOLV Node-RED editor/runtime changes from a file-level impact map, preserve UI/runtime parity and stable endpoint contracts, provide verification evidence, and ask decision-gate questions before compatibility-breaking edits."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Instrumentation Engineer"
short_description: "Define sensor and measurement asset behavior"
default_prompt: "Design EVOLV measurement behavior from current assets and consumers, enforce unit/quality invariants, provide validation evidence for edge conditions, and ask decision-gate questions before semantic or threshold changes."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Measurement Product Specialist"
short_description: "Sensor/analyzer product behavior expert"
default_prompt: "Model real device behavior for the impacted EVOLV measurement paths, including warmup, drift, fouling, quality states, and bounds; preserve payload contracts and provide test-backed fallback behavior."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Rotating Equipment Engineer"
short_description: "Model rotating assets with physical realism"
default_prompt: "Review EVOLV rotating-machine logic from current curves/sensors, enforce physical and fail-safe invariants, verify with boundary evidence, and trigger decision-gate interviews before changing safety envelopes."

View File

@@ -1,83 +0,0 @@
---
name: evolv-orchestrator
description: Orchestrate multi-agent execution for the EVOLV repository. Use when work spans multiple disciplines (Node-RED frontend/editor UI, rotating equipment logic, instrumentation assets, process control, InfluxDB/data architecture, OT/IT security, and quality/technical debt) and requires decomposition, sequencing, handoffs, and integration decisions.
---
# EVOLV Orchestrator
## Mission
Coordinate specialized EVOLV agents, split work into clear tasks, and ensure integrations are coherent across JavaScript/CommonJS Node-RED nodes, process assets, and observability/data concerns.
## Harness-Style Operating Rules
- Start from the live repo state, not generic playbooks.
- Build a file-level impact map before assigning specialist work.
- Define invariants first, then implement changes.
- Require evidence for each claim (tests, smoke checks, endpoint validation, or concrete diffs).
- Convert repeated lessons into updated skill guidance to reduce future ambiguity.
## Execution Flow
1. Frame the objective and constraints in one paragraph.
2. Build an impact map before assigning work. Identify touched contracts and files:
- `nodes/<nodeName>/<nodeName>.html` (editor UI)
- `nodes/<nodeName>/<nodeName>.js` (runtime entry)
- `nodes/<nodeName>/src/nodeClass.js` (Node-RED wrapper)
- `nodes/<nodeName>/src/specificClass.js` (domain logic)
- `nodes/generalFunctions/` (shared helpers/config)
3. Declare invariants and acceptance criteria:
- backward compatibility posture: controlled breaks allowed only with migration
- safety posture: availability-first unless user overrides for a specific task
- security trust boundary/default behavior
- data schema/query compatibility where relevant
4. Route tasks to specialist skills with explicit deliverables and acceptance criteria.
5. Require each specialist to return:
- assumptions
- changed files
- tests added/updated
- unresolved risks
6. Integrate outputs and check cross-skill consistency:
- config fields aligned between `.html` and runtime parsing
- admin endpoints stable (`/<nodeName>/menu.js`, `/<nodeName>/configData.js`)
- topic contracts (`msg.topic`) unchanged unless migration is defined
7. Ask targeted user interview questions only at decision gates (see protocol below).
8. Produce a final integrated implementation with a risk log and decision log updates when needed.
## Delegation Map
- Use `evolv-frontend-node-red` for Node-RED editor/runtime UX and HTML config input design.
- Use `evolv-mechanical-rotating-equipment` for rotating machine behavior, limits, and performance logic.
- Use `evolv-instrumentation-assets` for measurement tags, sensor semantics, and asset metadata.
- Use `evolv-process-systems-control` for system-level interactions, modes, and control architecture.
- Use `evolv-database-influx-architecture` for InfluxDB schema, retention, query shape, and Grafana coupling.
- Use `evolv-ot-it-security` for OT/IT hardening and secure-by-default checks.
- Use `evolv-quality-technical-debt` for regression risk, tests, maintainability, and technical debt.
## Interview Protocol
Ask at most 3 focused questions at a time. Prioritize:
1. Operational objective and KPI (what "better" means).
2. Safety/availability constraints (what must never break).
3. Backward compatibility expectations for flows and topics.
Trigger an interview before finalizing when any of these are true:
- Breaking topic/payload/schema/API change is proposed
- Safety envelope or fail-safe defaults are loosened/tightened
- Security defaults or endpoint exposure changes
- Data retention/backfill/query behavior changes
- Rollout strategy has material operational risk
Default resolution rules when interviewing:
- prefer compatibility option with migration over undocumented hard breaks
- prefer availability-first behavior with explicit bounded safeguards
- always create/update a decision log entry for every decision-gate outcome
Question format:
- decision statement (one sentence)
- options with tradeoff
- recommended option and why
## Output Contract
Return:
- task breakdown by specialist
- execution order and dependencies
- measurable acceptance criteria
- integration risks and mitigation
- evidence summary (what was verified and how)
- decision log entries created/updated (if any)

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Orchestrator"
short_description: "Coordinate EVOLV specialist agent workflows"
default_prompt: "Build a repo-grounded impact map, define invariants, delegate EVOLV work to specialists with measurable acceptance criteria, run decision-gate interviews for ambiguous high-impact choices, and return integrated evidence plus risks."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV OT Edge PLC Integration"
short_description: "OPC UA/PLC edge interoperability specialist"
default_prompt: "Build a protocol-to-topic contract map for the affected EVOLV integration, define deterministic read/write and reconnect semantics, validate failure and recovery behavior, and return evidence-backed implementation guidance."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV OT/IT Security Engineer"
short_description: "Audit EVOLV OT/IT control security posture"
default_prompt: "Perform EVOLV OT/IT security review from explicit trust boundaries, preserve secure defaults, provide reproducible evidence and severity-ranked fixes, and raise decision-gate questions before any risk-accepting control changes."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Process Hydraulics Mass Balance"
short_description: "Flow, volume, and conservation behavior specialist"
default_prompt: "Build a control-volume and flow map for impacted EVOLV nodes, enforce mass/volume balance invariants, validate transient and boundary scenarios, and return test-backed findings with unresolved hydraulic risks."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Systems Control Engineer"
short_description: "Design robust multi-node process control"
default_prompt: "Engineer EVOLV system control from a repo-grounded topic/ownership map, preserve transition and fail-safe invariants, validate sequencing behavior with evidence, and escalate decision-gate questions for contract-breaking control changes."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Quality + Debt Engineer"
short_description: "Drive code quality and technical debt reduction"
default_prompt: "Review EVOLV code with evidence-anchored findings, prioritize correctness and regression risk, require verification for fixes, and frame explicit decision-gate tradeoffs when risk is accepted or testing is reduced."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Regulatory Compliance Wastewater"
short_description: "Compliance and auditability specialist"
default_prompt: "Assess compliance impact of the proposed EVOLV changes, trace KPI lineage and control actions relevant to permits, validate auditability fields and behaviors, and return risk-focused recommendations with evidence requirements."

View File

@@ -1,4 +0,0 @@
interface:
display_name: "EVOLV Telemetry Analytics Dashboards"
short_description: "KPI and dashboard contract specialist"
default_prompt: "Map telemetry producers/consumers for impacted EVOLV outputs, preserve KPI and chart contracts, validate query compatibility and null-data behavior, and return migration notes where needed."

View File

@@ -41,8 +41,8 @@ You are a biological process engineer specializing in wastewater treatment model
- `.agents/function-anchors/monster/`
## Reference Skills
- `.agents/skills/evolv-biological-process-engineering/SKILL.md`
- `.agents/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
- `.claude/skills/evolv-biological-process-engineering/SKILL.md`
- `.claude/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
## Validation Checklist
- [ ] Kinetic rates have correct temperature compensation
@@ -53,4 +53,4 @@ You are a biological process engineer specializing in wastewater treatment model
- [ ] Retention times consistent with reactor geometry and flow
## Reasoning Difficulty: Very High
This agent handles ASM kinetics, mass balance calculations, temperature compensation, and sludge settling models — some of the most complex scientific reasoning in the platform. Incorrect stoichiometric coefficients, missed temperature corrections, or flawed mass balance closures can propagate silently through reactor simulations. When uncertain, consult `third_party/docs/asm-models.md`, `third_party/docs/settling-models.md`, and `.agents/skills/evolv-biological-process-engineering/SKILL.md` before making claims about biological process behavior.
This agent handles ASM kinetics, mass balance calculations, temperature compensation, and sludge settling models — some of the most complex scientific reasoning in the platform. Incorrect stoichiometric coefficients, missed temperature corrections, or flawed mass balance closures can propagate silently through reactor simulations. When uncertain, consult `third_party/docs/asm-models.md`, `third_party/docs/settling-models.md`, and `.claude/skills/evolv-biological-process-engineering/SKILL.md` before making claims about biological process behavior.

View File

@@ -43,9 +43,9 @@ You are a commissioning and compliance specialist for the EVOLV wastewater treat
- Mode changes (auto→manual, simulation→physical) are compliance-relevant events
## Reference Skills
- `.agents/skills/evolv-commissioning-validation/SKILL.md`
- `.agents/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
- `.agents/skills/evolv-alarms-interlocks-permissives/SKILL.md`
- `.claude/skills/evolv-commissioning-validation/SKILL.md`
- `.claude/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
- `.claude/skills/evolv-alarms-interlocks-permissives/SKILL.md`
## Validation Checklist
- [ ] Compliance-relevant output fields unchanged (or migration documented)
@@ -56,4 +56,4 @@ You are a commissioning and compliance specialist for the EVOLV wastewater treat
- [ ] Control-action traceability maintained through the change
## Reasoning Difficulty: High
This agent handles regulatory compliance context, audit trail requirements, and simulation-to-field validation gaps. Dutch wastewater regulations (Waterschapswet, EU UWWTD) have specific monitoring and reporting obligations that code changes can inadvertently violate. When uncertain, consult `third_party/docs/wastewater-compliance-nl.md` and `.agents/skills/evolv-commissioning-validation/SKILL.md` before making claims about compliance requirements.
This agent handles regulatory compliance context, audit trail requirements, and simulation-to-field validation gaps. Dutch wastewater regulations (Waterschapswet, EU UWWTD) have specific monitoring and reporting obligations that code changes can inadvertently violate. When uncertain, consult `third_party/docs/wastewater-compliance-nl.md` and `.claude/skills/evolv-commissioning-validation/SKILL.md` before making claims about compliance requirements.

View File

@@ -29,7 +29,7 @@ You are the EVOLV orchestrator agent. You decompose complex tasks, route to spec
- Canonical internal units: Pa, m³/s, W, K
## Workflow
1. Read `.agents/skills/evolv-orchestrator/SKILL.md` for full orchestration protocol
1. Read `.claude/skills/evolv-orchestrator/SKILL.md` for full orchestration protocol
2. Build an impact map: which nodes, contracts, and shared modules are affected?
3. Identify the minimum set of specialist agents needed
4. Decompose into sequenced subtasks with clear acceptance criteria
@@ -41,15 +41,18 @@ You are the EVOLV orchestrator agent. You decompose complex tasks, route to spec
- InfluxDB retention/backfill semantics or dashboard query contracts
## Reference Files
- `.agents/skills/evolv-orchestrator/SKILL.md` — Full orchestration protocol
- `AGENTS.md` — Agent invocation policy, routing table, decision governance
- `.agents/decisions/` — Decision log directory
- `CONTRACTS.md` (EVOLV root) — front-door map: where every contract, rule, and standard lives
- `.claude/refactor/CONTRACTS.md` — platform API shapes (BaseDomain, BaseNodeAdapter, commands registry, …)
- `.claude/refactor/OPEN_QUESTIONS.md` — live decisions log
- `.claude/skills/evolv-orchestrator/SKILL.md` — Full orchestration protocol
- `.agents/AGENTS.md` — Agent invocation policy and routing table
- `.agents/improvements/IMPROVEMENTS_BACKLOG.md` — Deferred improvements
## Decision Governance
- Record decision-gate outcomes in `.agents/decisions/DECISION-YYYYMMDD-<slug>.md`
- Capture load-bearing decisions in the commit message and PR description
- Live open questions belong in `.claude/refactor/OPEN_QUESTIONS.md`
- Ask at most 3 questions per interview batch
- Owner-approved defaults: compatibility=controlled, safety=availability-first
## Reasoning Difficulty: Medium-High
This agent handles multi-domain task decomposition, cross-cutting impact analysis, and decision governance enforcement. The primary challenge is correctly mapping changes across node boundaries — a single modification can cascade through parent-child relationships, shared contracts, and InfluxDB semantics. When uncertain about cross-domain impact, consult `.agents/skills/evolv-orchestrator/SKILL.md` and `AGENTS.md` before routing to specialist agents.
This agent handles multi-domain task decomposition, cross-cutting impact analysis, and decision governance enforcement. The primary challenge is correctly mapping changes across node boundaries — a single modification can cascade through parent-child relationships, shared contracts, and InfluxDB semantics. When uncertain about cross-domain impact, consult `.claude/skills/evolv-orchestrator/SKILL.md` and `.agents/AGENTS.md` before routing to specialist agents.

View File

@@ -45,7 +45,7 @@ dashboardAPI, diffuser, machineGroupControl, measurement, monster, pumpingStatio
- `nodes/generalFunctions/src/*/` — Individual module directories
## Reference Skills
- All `.agents/skills/` depending on which module is being changed:
- All `.claude/skills/` depending on which module is being changed:
- predict/interpolation/loadCurve → `evolv-mechanical-rotating-equipment`
- MeasurementContainer/nrmse/convert → `evolv-instrumentation-assets`
- outputUtils → `evolv-database-influx-architecture`
@@ -59,4 +59,4 @@ dashboardAPI, diffuser, machineGroupControl, measurement, monster, pumpingStatio
- Prefer additive changes (new exports) over breaking changes (renamed/removed exports)
## Reasoning Difficulty: Medium-High
This agent manages a shared library consumed by all 13 nodes. Individual module changes are often straightforward, but the cross-node impact analysis is challenging — a subtle behavior change in interpolation or predict can cascade through rotatingMachine, pumpingStation, and machineGroupControl simultaneously. When uncertain about impact scope, grep for imports across `nodes/*/src/` and consult the relevant `.agents/skills/` for the module being changed.
This agent manages a shared library consumed by all 13 nodes. Individual module changes are often straightforward, but the cross-node impact analysis is challenging — a subtle behavior change in interpolation or predict can cascade through rotatingMachine, pumpingStation, and machineGroupControl simultaneously. When uncertain about impact scope, grep for imports across `nodes/*/src/` and consult the relevant `.claude/skills/` for the module being changed.

View File

@@ -43,8 +43,8 @@ You are an instrumentation engineer specializing in sensor measurement, signal c
- `.agents/function-anchors/measurement/`
## Reference Skills
- `.agents/skills/evolv-instrumentation-assets/SKILL.md`
- `.agents/skills/evolv-measurement-product-specialist/SKILL.md`
- `.claude/skills/evolv-instrumentation-assets/SKILL.md`
- `.claude/skills/evolv-measurement-product-specialist/SKILL.md`
## Validation Checklist
- [ ] Unit conversions chain correctly (no double-conversion)
@@ -55,4 +55,4 @@ You are an instrumentation engineer specializing in sensor measurement, signal c
- [ ] MeasurementContainer fields populated consistently
## Reasoning Difficulty: High
This agent handles signal processing, NRMSE-based drift detection, sensor behavior modeling, and data quality propagation. Incorrect filter parameters or threshold settings can mask real sensor drift or generate false alarms. When uncertain, consult `third_party/docs/signal-processing-sensors.md` and `.agents/skills/evolv-instrumentation-assets/SKILL.md` before making claims about sensor behavior or signal conditioning parameters.
This agent handles signal processing, NRMSE-based drift detection, sensor behavior modeling, and data quality propagation. Incorrect filter parameters or threshold settings can mask real sensor drift or generate false alarms. When uncertain, consult `third_party/docs/signal-processing-sensors.md` and `.claude/skills/evolv-instrumentation-assets/SKILL.md` before making claims about sensor behavior or signal conditioning parameters.

View File

@@ -50,9 +50,9 @@ You are a mechanical and process engineer specializing in rotating equipment, hy
- `.agents/function-anchors/valve/`
## Reference Skills
- `.agents/skills/evolv-mechanical-rotating-equipment/SKILL.md`
- `.agents/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
- `.agents/skills/evolv-alarms-interlocks-permissives/SKILL.md`
- `.claude/skills/evolv-mechanical-rotating-equipment/SKILL.md`
- `.claude/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
- `.claude/skills/evolv-alarms-interlocks-permissives/SKILL.md`
## Validation Checklist
- [ ] Unit conversions use canonical system (Pa, m³/s, W, K internally)
@@ -63,4 +63,4 @@ You are a mechanical and process engineer specializing in rotating equipment, hy
- [ ] System curve intersection validated for duty point calculations
## Reasoning Difficulty: High
This agent handles physics validation involving affinity laws, pump curve theory, system curve intersections, and unit system rigor. Errors in hydraulic calculations or VFD scaling can produce physically impossible results that look numerically plausible. When uncertain, consult `third_party/docs/pump-affinity-laws.md`, `third_party/docs/pid-control-theory.md`, and `.agents/skills/evolv-mechanical-rotating-equipment/SKILL.md` before making claims about mechanical behavior.
This agent handles physics validation involving affinity laws, pump curve theory, system curve intersections, and unit system rigor. Errors in hydraulic calculations or VFD scaling can produce physically impossible results that look numerically plausible. When uncertain, consult `third_party/docs/pump-affinity-laws.md`, `third_party/docs/pid-control-theory.md`, and `.claude/skills/evolv-mechanical-rotating-equipment/SKILL.md` before making claims about mechanical behavior.

View File

@@ -38,8 +38,8 @@ You are a Node-RED runtime and editor specialist for the EVOLV platform. You und
- `nodes/generalFunctions/` — Shared utilities (MenuManager, configManager, logger, etc.)
## Reference Skills
- `.agents/skills/evolv-frontend-node-red/SKILL.md` — Detailed Node-RED frontend patterns
- `.agents/skills/evolv-process-systems-control/SKILL.md` — Control architecture and topic contracts
- `.claude/skills/evolv-frontend-node-red/SKILL.md` — Detailed Node-RED frontend patterns
- `.claude/skills/evolv-process-systems-control/SKILL.md` — Control architecture and topic contracts
## Rules
- Never put `RED.*` calls in specificClass — that's nodeClass territory
@@ -48,4 +48,4 @@ You are a Node-RED runtime and editor specialist for the EVOLV platform. You und
- Always check `generalFunctions` MenuManager/configManager when modifying config flows
## Reasoning Difficulty: Medium
Node-RED patterns are well-documented with clear conventions. The main risk is editor/runtime synchronization — changes to admin endpoints, HTML forms, or registration patterns can silently break the editor without runtime errors. When uncertain, consult `.agents/skills/evolv-frontend-node-red/SKILL.md` and the Node-RED documentation before making structural changes.
Node-RED patterns are well-documented with clear conventions. The main risk is editor/runtime synchronization — changes to admin endpoints, HTML forms, or registration patterns can silently break the editor without runtime errors. When uncertain, consult `.claude/skills/evolv-frontend-node-red/SKILL.md` and the Node-RED documentation before making structural changes.

View File

@@ -35,8 +35,8 @@ You are an OT/IT security and edge integration specialist for the EVOLV industri
- Watchdog timers for connection health monitoring
## Reference Skills
- `.agents/skills/evolv-ot-it-security/SKILL.md`
- `.agents/skills/evolv-ot-edge-plc-integration/SKILL.md`
- `.claude/skills/evolv-ot-it-security/SKILL.md`
- `.claude/skills/evolv-ot-edge-plc-integration/SKILL.md`
## Scope
- Admin endpoints (`GET /<nodeName>/menu.js`, `GET /<nodeName>/configData.js`)
@@ -55,4 +55,4 @@ You are an OT/IT security and edge integration specialist for the EVOLV industri
- [ ] Control messages validated before actuator commands are issued
## Reasoning Difficulty: High
This agent handles industrial threat modeling, OT protocol security, and fail-safe analysis. Security in industrial systems has physical safety implications — a missed input validation on a control message could lead to unsafe actuator commands. When uncertain, consult `third_party/docs/ot-security-iec62443.md` and `.agents/skills/evolv-ot-it-security/SKILL.md` before making claims about security boundaries or protocol safety.
This agent handles industrial threat modeling, OT protocol security, and fail-safe analysis. Security in industrial systems has physical safety implications — a missed input validation on a control message could lead to unsafe actuator commands. When uncertain, consult `third_party/docs/ot-security-iec62443.md` and `.claude/skills/evolv-ot-it-security/SKILL.md` before making claims about security boundaries or protocol safety.

View File

@@ -49,8 +49,8 @@ node --test nodes/<nodeName>/test/edge/*.test.js
- `nodes/*/examples/` — Example flows
## Reference Skills
- `.agents/skills/evolv-quality-technical-debt/SKILL.md`
- `.agents/skills/evolv-commissioning-validation/SKILL.md`
- `.claude/skills/evolv-quality-technical-debt/SKILL.md`
- `.claude/skills/evolv-commissioning-validation/SKILL.md`
## Validation Checklist
- [ ] All 3 test tiers present (basic/integration/edge)
@@ -62,4 +62,4 @@ node --test nodes/<nodeName>/test/edge/*.test.js
- [ ] Code complexity reasonable (no god functions, clear naming)
## Reasoning Difficulty: Medium
Test patterns are straightforward and the 3-tier structure provides clear guidance. The harder challenge is cross-node regression detection — a change in generalFunctions can silently break downstream nodes whose tests still pass in isolation. When uncertain, consult `.agents/skills/evolv-quality-technical-debt/SKILL.md` and `.agents/function-anchors/` for behavioral contracts before writing or modifying tests.
Test patterns are straightforward and the 3-tier structure provides clear guidance. The harder challenge is cross-node regression detection — a change in generalFunctions can silently break downstream nodes whose tests still pass in isolation. When uncertain, consult `.claude/skills/evolv-quality-technical-debt/SKILL.md` and `.agents/function-anchors/` for behavioral contracts before writing or modifying tests.

View File

@@ -45,8 +45,8 @@ You are a telemetry and database specialist for the EVOLV platform, focusing on
- `.agents/function-anchors/dashboardAPI/`
## Reference Skills
- `.agents/skills/evolv-database-influx-architecture/SKILL.md`
- `.agents/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
- `.claude/skills/evolv-database-influx-architecture/SKILL.md`
- `.claude/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
## Validation Checklist
- [ ] Tags are low-cardinality only (no timestamps, UUIDs, free-text)
@@ -57,4 +57,4 @@ You are a telemetry and database specialist for the EVOLV platform, focusing on
- [ ] Retention policy matches data criticality and storage constraints
## Reasoning Difficulty: Medium
InfluxDB schema design is well-understood, and the Port 1 telemetry contract is consistent across nodes. The main risk area is cardinality management — adding a high-cardinality tag can silently degrade query performance until it becomes critical. When uncertain, consult `third_party/docs/influxdb-schema-design.md` and `.agents/skills/evolv-database-influx-architecture/SKILL.md` before making schema changes.
InfluxDB schema design is well-understood, and the Port 1 telemetry contract is consistent across nodes. The main risk area is cardinality management — adding a high-cardinality tag can silently degrade query performance until it becomes critical. When uncertain, consult `third_party/docs/influxdb-schema-design.md` and `.claude/skills/evolv-database-influx-architecture/SKILL.md` before making schema changes.

View File

@@ -0,0 +1,119 @@
# Continue here (fresh-context entry point) — ARCHIVED
> [!WARNING]
> **ARCHIVED — the refactor described here landed on `development` in May 2026.**
> This file describes the *plan* and the *deferred work as of 2026-05-11*. It is
> retained for history. For current work, start at [`../../../CONTRACTS.md`](../../../CONTRACTS.md)
> (EVOLV root) and the live standards in [`../`](../).
Original intro: read this file first if you're picking up the EVOLV refactor
in a fresh session. It points at the durable plan and lists what's left.
## Read in order
1. `README.md` — refactor overview + the 11-phase plan
2. `CONVENTIONS.md` — code style, file/function size, naming, testing
3. `CONTRACTS.md` — the API shapes (BaseNodeAdapter, BaseDomain, commandRegistry, ChildRouter, UnitPolicy, statusBadge, LatestWinsGate, HealthStatus)
4. `MODULE_SPLIT.md` — per-node module layout
5. `TASKS.md` — phased plan; **Phases 1-11 are done** as of 2026-05-11
6. `OPEN_QUESTIONS.md` — the live decisions log; most entries are RESOLVED
7. `WIKI_TEMPLATE.md` + `WIKI_HOME_TEMPLATE.md` — the wiki shape every node uses
## Current state (verify with `npm run test:platform` from EVOLV root)
- **823 platform tests pass, 0 fail** across 12 submodules.
- All 12 submodules + parent EVOLV are on `development` branches at
`gitea.wbd-rd.nl/RnD/*` — never merged to `main` yet.
- Docker stack runs natively in WSL via `docker compose up -d` (compose v2
plugin installed at `~/.docker/cli-plugins/docker-compose`).
- Every node has `wiki/Home.md` with the 14-section visual-first template;
topic-contract + data-model sections auto-regenerate via
`npm run wiki:all` in each submodule.
## What's NOT done (deferred, in priority order)
### 1. B5 — `reactor` `boundary-conditions` feature branch merge (BIG)
`origin/boundary-conditions` in `nodes/reactor/` is **8 commits ahead** of
main, never merged before the refactor. Carries improved upstream/downstream
boundary-condition handling, multi-parent support, and grid-position refactor.
50 files changed, +2570 / -4151 lines. Conflicts heavily with the post-refactor
`kinetics/{cstr,pfr,baseEngine}.js` structure.
**Approach:** same pattern that worked for pumpingStation `basin-docs-update`:
- `cd nodes/reactor && git merge --no-commit --no-ff origin/boundary-conditions`
- Resolve the ~4-6 conflicting files by keeping the refactor's BaseDomain +
kinetics/ structure, porting the boundary-condition behavior into
`kinetics/baseEngine.js` (BC application) + `kinetics/pfr.js` (grid).
- All 46 reactor tests must stay green; ideally pick up any new tests the
branch added.
Domain owner: needs to be in the loop. mathjs load means each test pass takes
~15s — budget accordingly.
### 2. Phase 8 — `development → main` PRs (human review)
12 submodules + parent. Suggested merge order:
- `generalFunctions` first (everything depends on it)
- All 11 node submodules (any order)
- Parent `EVOLV` last (carries the submodule pointers)
Each PR opened via gitea UI. Confirm Docker E2E per-node before merging
(load `examples/0X-*.json` flows, verify behaviour). Squash or rebase per
your team convention.
### 3. Small follow-ups still in `OPEN_QUESTIONS.md`
These are individually small (≤ half-day each):
| Entry | Action |
|---|---|
| pumpingStation `overfillVol` alias | Drop the parallel `overfillVol → highVolumeSafetyVol` alias in `src/basin/thresholdValidator.js`. Same shape as the already-done `overfillLevel` drop. |
| MGC `calcGroupEfficiency.maxEfficiency` naming | `maxEfficiency` actually returns the mean. Rename to `meanEfficiency` everywhere; update call sites + downstream telemetry consumers (search the platform for the key). |
| `predictionHealth` migration in rotatingMachine | Decide where `confidence` (numeric 0..1) lives now that HealthStatus is standardised. Default chosen: keep as sibling field on the drift container, not inside HealthStatus. Implement the migration if/when rotatingMachine drift output gets a v2 contract. |
| dashboardAPI no BaseDomain | Documented in OPEN_QUESTIONS. dashboardAPI is a passive HTTP server; doesn't fit BaseDomain. Confirm with team whether to revisit. |
| measurement legacy property mirrors | The new specificClass kept some legacy `inputValue`/`outputAbs` getter/setter mirrors for back-compat. Decide if any can be removed in a v2. |
| measurement `handleScaling` mutates `config.scaling` | Channel's `_applyScaling` mutates its own config copy when input range collapses; bridged via a 2-line mirror-back to `config.scaling`. Decide if Channel should not mutate at all, OR if the bridge is the right contract. |
| MGC `calcAbsoluteTotals` pressure-key coupling | Implicitly assumes flow.inputCurve and power.inputCurve keys are paired. Add a guard or document. |
| rotatingMachine `_pendingExtras` constructor workaround | The 3-positional-arg constructor (`machineConfig, stateConfig, errorMetricsConfig`) is bridged via a `Machine._pendingExtras` static stash from buildDomainConfig. Cleaner approach: include state + errorMetrics in the validated config schema directly. |
| reactor schema enum lowercases `reactor_type` | The validator lowercases enum values; `_buildEngine` upper-cases before switching as a guard. Standardise schema enum casing — drop the guard. |
| reactor mathjs runtime (~13s per test file) | Hoist a single mathjs instance at module top OR switch to tree-shaken mathjs subset. Drops test suite runtime from ~3 min to ~10 s. |
| valveGroupControl `set.position` placeholder | Currently a debug-logged no-op. Implement or remove. |
### 4. Phase 9 follow-ups (wiki cosmetics)
These came up during the wiki rollout:
- **Header banner auto-stamping**: `WIKI_TEMPLATE.md` instructs writers to put the
git short hash + regen date in the header. `wikiGen.js` doesn't currently
rewrite that banner. Either add a banner-rewrite or change the template to
"manually set when authoring".
- **Curve-data-model auto-gen warnings**: rotatingMachine + valve emit
`Asset 'Unknown' not found` to stderr when wikiGen instantiates the domain
with no curve. Either suppress the noise (catch in wikiGen) or document.
- **Data-model AUTOGEN "sample value" placeholder**: Most nodes' `getOutput()`
is trivially small in a stub-construction context (no children registered).
Template should explicitly tell authors to supplement with a hand-curated
concrete sample block under the markers.
## How to verify nothing has rotted
```bash
# From /mnt/d/gitea/EVOLV:
# 1. fetch latest from origin
git fetch --recurse-submodules
# 2. confirm everything still green
npm run test:platform # expect 823 / 0
# 3. confirm Docker still healthy (if compose stack is running)
docker compose ps
curl -sf http://localhost:1880/nodes | wc -c # > 0 means Node-RED is up
# 4. confirm submodule branches are still development
git submodule foreach --quiet 'echo "$name $(git rev-parse --abbrev-ref HEAD)"'
```
If any of those drift from the expected state, that's the first thing to
investigate before touching new work.

View File

@@ -0,0 +1,299 @@
# Task list — ARCHIVED
> [!WARNING]
> **ARCHIVED — Phases 111 landed on `development` in May 2026.**
> This file is the original phased plan and is retained for history. For
> deferred / open work, see [`../OPEN_QUESTIONS.md`](../OPEN_QUESTIONS.md).
> For current standards, start at [`../../../CONTRACTS.md`](../../../CONTRACTS.md) (EVOLV root).
Phased and ordered. The TaskCreate tracker mirrors this list and is the
active, mutable view; this file is the durable plan.
A task is **done** when:
- The code matches the contracts in `CONTRACTS.md`.
- All the affected node's tests are green (`node --test test/basic
test/integration test/edge`).
- A short note is appended in the task tracker if anything was deferred
to `OPEN_QUESTIONS.md`.
## Phase 1 — `generalFunctions` additive infra
Goal: add the new platform pieces. Nothing is removed; nothing existing
changes shape. All existing nodes continue to work unchanged.
| # | Task | Notes |
|---|---|---|
| 1.1 | Add `src/domain/UnitPolicy.js` + tests | Extracted from `rotatingMachine._buildUnitPolicy`. |
| 1.2 | Add `src/domain/ChildRouter.js` + tests | Built on existing `childRegistrationUtils`. |
| 1.3 | Add `src/domain/LatestWinsGate.js` + tests | Extracted from MGC `_dispatchInFlight`/`_delayedCall`. |
| 1.4 | Add `src/domain/HealthStatus.js` + tests | Standardise the `{level, flags, message, source}` shape. |
| 1.5 | Add `src/domain/BaseDomain.js` + tests | Constructor boilerplate; calls subclass `configure()`/`_init()`. |
| 1.6 | Add `src/nodered/commandRegistry.js` + tests | Topic dispatch + alias warnings. |
| 1.7 | Add `src/nodered/statusBadge.js` + tests | `compose`, `error`, `idle`, `byState` helpers. |
| 1.8 | Add `src/nodered/statusUpdater.js` + tests | 1 Hz poller calling `source.getStatusBadge()`. |
| 1.9 | Add `src/nodered/BaseNodeAdapter.js` + tests | The thing every nodeClass extends. |
| 1.10 | Add `src/stats/index.js` + tests | Promote mean/stdDev/median/mad/lerp from `measurement`. |
| 1.11 | Update `generalFunctions/index.js` (additive) | New exports under existing pattern. |
| 1.12 | Run all 12 nodes' tests against the bumped `generalFunctions` | Sanity gate before phase 2. |
Phase-1 commit cadence: one commit per task on the `development` branch
of `generalFunctions`. Submodule pointer in parent EVOLV bumps **once**
at end of phase.
## Phase 2 — pumpingStation pilot
Goal: prove the new infrastructure end-to-end. Pumping station is a
mid-complexity node — bigger than measurement, smaller than the
curve-driven nodes.
| # | Task | Notes |
|---|---|---|
| 2.1 | Move standalone demo from `specificClass.js` to `examples/standalone-demo.js` | Pure deletion + move; tests unchanged. |
| 2.2 | Extract `basin/` (BasinGeometry + thresholdValidator) | Pure functions. |
| 2.3 | Extract `measurement/flowAggregator.js` (incl. `_updatePredictedVolume`) | Centerpiece of the tick loop. |
| 2.4 | Extract `measurement/measurementRouter.js` + `measurement/calibration.js` | |
| 2.5 | Extract `control/` strategies + dispatcher | levelBased, flowBased (stub), manual. |
| 2.6 | Extract `safety/safetyController.js` | dryRunRule + overfillRule split internally. |
| 2.7 | Add `getStatusBadge()` on `PumpingStation`; remove badge logic from nodeClass | |
| 2.8 | Convert `nodeClass.js` to extend `BaseNodeAdapter` | |
| 2.9 | Convert `specificClass.js` to extend `BaseDomain` | Use `ChildRouter`, `UnitPolicy`. |
| 2.10 | Extract `commands/` registry + handlers | Old topic names become aliases. |
| 2.11 | Extract `editor.js` from `pumpingStation.html` (the SVG redraw logic) | Served via a `/pumpingStation/editor.js` admin endpoint. |
| 2.12 | Generate `CONTRACT.md` from `commands/` + handwritten events section | |
| 2.13 | Tests: 3-tier per extracted module + the existing suite still green | Add edge tests for any regression discovered. |
| 2.14 | Docker E2E (deploy `01-basic`/`02-integration`/`03-dashboard` flows on a running Node-RED) | Required for "trial-ready" claim. |
## Phase 3 — measurement
| # | Task | Notes |
|---|---|---|
| 3.1 | Promote stats helpers to `generalFunctions/src/stats/` (already done in 1.10) | |
| 3.2 | Convert analog mode to use `Channel` internally (with `key=null`) | Removes the ~400-line inline pipeline duplication. |
| 3.3 | Extract `simulation/simulator.js` | |
| 3.4 | Extract `calibration/calibrator.js` | |
| 3.5 | Add `getStatusBadge()` on `Measurement` | |
| 3.6 | Convert `nodeClass.js` to `BaseNodeAdapter`; `specificClass.js` to `BaseDomain` | |
| 3.7 | Extract `commands/` | |
| 3.8 | `CONTRACT.md` | |
| 3.9 | Tests + Docker E2E | |
## Phase 4 — machineGroupControl
| # | Task | Notes |
|---|---|---|
| 4.1 | Extract `groupOps/` (groupOperatingPoint + groupCurves) | The cluster of `_group*` helpers. |
| 4.2 | Extract `totals/totalsCalculator.js` | |
| 4.3 | Extract `combinatorics/pumpCombinations.js` | |
| 4.4 | Extract `optimizer/bestCombination.js` + `optimizer/bepGravitation.js` | |
| 4.5 | Extract `efficiency/groupEfficiency.js` | |
| 4.6 | Extract `dispatch/demandDispatcher.js` using `LatestWinsGate` | Replaces `_dispatchInFlight`/`_delayedCall` directly. |
| 4.7 | Add `getStatusBadge()` | |
| 4.8 | Convert nodeClass + specificClass to base classes; use `ChildRouter` | |
| 4.9 | `commands/` + `CONTRACT.md` | |
| 4.10 | Tests + Docker E2E | |
## Phase 5 — rotatingMachine
| # | Task | Notes |
|---|---|---|
| 5.1 | Extract `curves/` (loader + normalizer + reverseCurve) | |
| 5.2 | Extract `prediction/` (predictors + groupPredictors + operatingPoint) | |
| 5.3 | Extract `drift/` using `HealthStatus` | |
| 5.4 | Extract `pressure/` (virtual children + initialization + router) | |
| 5.5 | Extract `state/stateBindings.js` (adapter to existing `generalFunctions/state`) | |
| 5.6 | Extract `measurement/measurementHandlers.js` | |
| 5.7 | Extract `flow/flowController.js` | |
| 5.8 | Extract `display/workingCurves.js` | |
| 5.9 | Add `getStatusBadge()` (replaces the 100-line nodeClass version) | |
| 5.10 | Convert nodeClass + specificClass | |
| 5.11 | `commands/` + `CONTRACT.md` | |
| 5.12 | Tests + Docker E2E | |
## Phase 6 — remaining nodes
For each: skeleton refactor only — extend `BaseNodeAdapter` + `BaseDomain`, use `ChildRouter`, move the input switch to `commands/`, add
`getStatusBadge()`. Domain-specific module split only if `specificClass` > 300 lines after the platform refactor.
| # | Task |
|---|---|
| 6.1 | `valve` |
| 6.2 | `valveGroupControl` |
| 6.3 | `diffuser` |
| 6.4 | `monster` |
| 6.5 | `settler` |
| 6.6 | `reactor` |
| 6.7 | `dashboardAPI` (special — likely no `BaseDomain`, it's a passive HTTP server) |
These are parallelisable — each can be its own agent.
## Phase 7 — remove legacy topic aliases
> **Note:** canonical names (`set.*`, `cmd.*`, `data.*`, `child.*`,
> `query.*`, `evt.*`) are used **from Phase 1 onwards** — see
> `CONTRACTS.md §1`. Each `commands/index.js` declares the canonical
> name as `topic` and lists pre-refactor names in `aliases`. So Phase 7
> is just the deprecation-window sweep.
| # | Task | Notes |
|---|---|---|
| 7.1 | Audit aliases across all `commands/` files; confirm one release cycle has elapsed | If any alias was added recently, defer that node's removal another cycle. |
| 7.2 | Remove `aliases` entries; canonical name only | Each removal is a single PR. |
| 7.3 | Update example flows that still used legacy names | Should already have been updated in their phase. |
| 7.4 | Document the removal in each `CONTRACT.md` | "Removed legacy topic X (replaced by canonical Y) on YYYY-MM-DD". |
## Phase 8 — promotion to main
When every node is on the new infra and Docker E2E green:
1. Bump submodule pointers in parent EVOLV `development`.
2. Open a PR per submodule (`development` → `main`).
3. Open the parent EVOLV PR last (`development` → `main`).
4. Merge in dependency order (`generalFunctions` first, then nodes that
depend on it, finally `EVOLV`).
## Phase 8.5 — `generalFunctions` deprecated path cleanup
Removes the deprecated paths flagged in `OPEN_QUESTIONS.md`. Runs after
promotion to `main` (so callers have stopped depending on the old
paths via the platform's own consumers).
### Targets to remove
| Path | Replaced by | First flagged |
|---|---|---|
| `src/helper/menuUtils_DEPRECATED.js` | `src/menu/` (the active menu manager) | pre-refactor |
| `loadCurve` export (in `index.js` + `datasets/assetData/curves/`) | `loadModel` | pre-refactor |
| Any `*_DEPRECATED.*` file added during the refactor | (per-file note) | refactor |
### Tasks
| # | Task | Notes |
|---|---|---|
| 8.5.1 | Audit consumers of `loadCurve` across all nodes | Should be zero after Phase 5 (rotatingMachine) — verify. |
| 8.5.2 | Remove `loadCurve` export + the underlying file | Single PR. Test all nodes. |
| 8.5.3 | Remove `menuUtils_DEPRECATED.js` | Verify zero imports first. |
| 8.5.4 | Sweep `generalFunctions/src/` for `_DEPRECATED.*` files; remove with consumer audit | One PR per file. |
| 8.5.5 | Update `generalFunctions` README to drop deprecated references | |
## Phase 9 — wiki cleanup (post-refactor)
Goal: each node's gitea wiki becomes **visual-first**, scannable, and
follows one shared template. Today's wiki has lots of prose and varies
per node — once the platform is uniform, the wiki should be too.
Don't start phase 9 until phase 8 is done (the wiki documents the
post-refactor shape, not the in-flight transition).
### Standard wiki template (one file per node, this is the spec)
```
1. One-paragraph "what is this node" (≤ 60 words).
2. Position in the platform — a Mermaid block showing the node and its
typical neighbours (parent + child types, with arrows for
data direction).
3. Capability matrix — small table of "what this node can do" with
✅ / ❌ / partial.
4. Topic contract — auto-generated from src/commands/index.js
(set.* / cmd.* / evt.* / data.* — payload schema and example).
5. Output payload — a Mermaid sequence-diagram of a typical tick
(parent → child → measurement → tick → port-0 emit).
6. Configuration — a Mermaid block diagram of the editor form sections
plus a table mapping each form field to the config key it lands at.
7. Examples — links to examples/01-basic, 02-integration, 03-dashboard
with one screenshot each.
8. State / mode chart — Mermaid stateDiagram for any node with
non-trivial states (rotatingMachine, pumpingStation, MGC).
9. "When you would NOT use this node" — explicit non-goals.
10. Issues / known limitations — single-line items with links to
repo issues.
```
### Tasks
| # | Task | Notes |
|---|---|---|
| 9.1 | Author the canonical wiki template at `.claude/refactor/WIKI_TEMPLATE.md` | Source of truth. |
| 9.2 | Build the auto-generator: `commands/index.js` → "Topic contract" markdown section | Run via a small `npm run wiki:contract` script per node. |
| 9.3 | Pilot on `pumpingStation` wiki: replace existing pages with the new template | Visual-first, prune prose. |
| 9.4 | Apply to other 3 core nodes (`measurement`, `MGC`, `rotatingMachine`) | |
| 9.5 | Apply to remaining nodes (one per repo) | |
| 9.6 | Update parent EVOLV wiki: top-level platform overview with a Mermaid block of all 13 nodes and how they connect (S88 hierarchy + data direction) | |
| 9.7 | Add a wiki style guide (max prose per section, where Mermaid is required, screenshot conventions) | |
| 9.8 | Audit pass: every page renders, every Mermaid block compiles, every link resolves | |
### Visual primitives we'll lean on (Mermaid)
- `flowchart LR` — node connections (parent ↔ child, data direction).
- `sequenceDiagram` — tick-to-port-0 lifecycle.
- `stateDiagram-v2` — rotatingMachine / pumpingStation state machines.
- `erDiagram` — only if a node has a complex internal data model worth
visualising.
Skip: classDiagram (we don't expose classes to users); gantt (no
schedules in a node's docs).
### Hard rules
- Every page leads with the Mermaid platform-position block. No "intro
paragraph then later a diagram" — diagram first.
- Each section opens with the diagram or table; prose annotates the
visual, not the other way round.
- No more than 60 words of unbroken prose anywhere on a page.
- One canonical source of truth for the topic contract: `commands/index.js`.
The wiki page is generated from it. No hand-written drift.
## Phase 10 — test-suite refactor (post-wiki)
Goal: bring every node's test layout in line with `CONVENTIONS.md §Testing`
now that the platform is uniform. Pre-existing test debt logged in
`OPEN_QUESTIONS.md` gets cleaned up here.
### Tasks
| # | Task | Notes |
|---|---|---|
| 10.1 | Audit each node: basic / integration / edge split, naming, helpers | One pass; produce a per-node punch list. |
| 10.2 | Convert any Mocha-style tests (`describe`/`it`) to `node:test` | Specifically `dashboardAPI/test/basic/structure-module-load.basic.test.js`. |
| 10.3 | Address `reactor` mathjs load (per OPEN_QUESTIONS): tree-shake or hoist | If hoisted, document the pattern as a CONVENTION addition. |
| 10.4 | Promote shared test helpers to `generalFunctions/test/helpers/` | Common fakes: fake Node-RED node, fake child, fake RED. |
| 10.5 | Add missing edge tests for each refactored module flagged during P2-P5 | Edge cases discovered during refactor land here. |
| 10.6 | Make every basic-test runner exit cleanly in batch (`node --test test/basic/`) | No leaked timers, no real `setInterval` outliving the assertions. Mirrors the BaseNodeAdapter test fix. |
| 10.7 | Standard CI shape: each node has `npm run test:basic`, `test:integration`, `test:edge` (consistent across nodes) | Allows uniform CI invocation. |
| 10.8 | Audit pass: every node's test suite green in batch under one wall-clock budget (≤ 60 s for basic) | The new platform-wide gate. |
## Phase 11 — unit-aware commands
Goal: every numeric setter / data topic carries an explicit unit; the user
can supply any compatible unit and the commandRegistry normalises before
the handler runs. Unknown units warn + list accepted alternatives.
### Tasks
| # | Task | Notes |
|---|---|---|
| 11.1 | `generalFunctions/src/convert/`: add `possibilities(measure)` helper | Returns the list of accepted unit names for a measure (`volumeFlowRate`, `pressure`, etc.). |
| 11.2 | `generalFunctions/src/nodered/commandRegistry.js`: handle `descriptor.units` | Normalisation pipeline: extract value+unit from msg, validate against `units.measure`, convert to `units.default`, warn + fall back on bad input. Tests for all 4 paths (no-unit / valid / wrong-measure / unknown). |
| 11.3 | `generalFunctions/src/nodered/BaseNodeAdapter.js`: auto-wire `query.units` topic | Returns `{ topic → { measure, default, accepted: [...] } }` from the registry. No per-node wiring needed. |
| 11.4 | `generalFunctions/scripts/wikiGen.js`: render `units` column | Topic-contract auto-gen table grows a Unit column showing `measure (default <unit>)`. |
| 11.5 | Per-node `src/commands/index.js`: declare `units` on every numeric setter | ~10 nodes. See proposed default-units table in interview reply. |
| 11.6 | Regenerate every `CONTRACT.md` + wiki `Home.md` via `npm run wiki:all` | Automated. |
| 11.7 | Tests: commandRegistry unit-handling paths | 4 scenarios per the validation table. |
### Default units per topic (proposed)
| Topic | Default | Why |
|---|---|---|
| pumpingStation `set.inflow` | `m3/h` | Operator-friendly scale |
| pumpingStation `set.demand` | `m3/h` | same |
| pumpingStation `set.outflow` | `m3/h` | symmetric |
| pumpingStation `cmd.calibrate.volume` | `m3` | basin volume |
| pumpingStation `cmd.calibrate.level` | `m` | basin height |
| MGC `set.demand` | `m3/h` | matches PS |
| rotatingMachine `set.setpoint` | `%` | control% |
| rotatingMachine `set.flow-setpoint` | `m3/h` | flow target |
| rotatingMachine `data.simulate-measurement` | per `payload.type` | dispatch by sensor type |
| valve `set.position` | `%` | valve open-% |
| measurement `data.measurement` | mode-dependent | analog → Channel scaling; digital → per-channel cfg |
| monster `data.flow` | `m3/h` | already enforced |
| reactor `data.influent` | flow=m3/h, concentrations=mg/L | engine internals |
| settler `data.influent` | flow=m3/h, concentrations=mg/L | matches reactor |
| diffuser `data.flow` | `m3/h` | air flow scale |

View File

@@ -0,0 +1,635 @@
# Contracts
The exact shapes that the refactor delivers. These are the things every
node converges on. Treat them as APIs.
Order: top-down — what a Node-RED user sees, what a node author writes,
what `generalFunctions` provides.
## 1. The Node-RED-visible contract per node
Every node exposes the same three Port shapes:
| Port | Direction | Carries |
|---|---|---|
| 0 | out | Process data — formatted via `outputUtils.formatMsg(..., 'process')` |
| 1 | out | InfluxDB telemetry — formatted via `outputUtils.formatMsg(..., 'influxdb')` |
| 2 | out | Registration / control plumbing |
| in | in | Commands routed by `msg.topic` through the `commands/` registry |
Every node also publishes a per-repo `CONTRACT.md` listing:
- Every `msg.topic` it accepts on Port 0 input, with the payload schema.
- Every `topic` shape it emits on Port 0/1/2.
- Every event its `measurements.emitter` fires for parents to subscribe.
- Every position label it expects from children.
This file is generated from the node's `commands/` module + a small
hand-written events section.
### Topic naming — canonical from Phase 1
`msg.topic` always uses one of these prefixes. `<noun>` and `<verb>`
are kebab-case after the dot (`set.flow-setpoint`, not
`set.flowSetpoint`).
#### Inputs — topics the node accepts on Port-0 input
| Prefix | Meaning | Idempotent? | Examples |
|---|---|---|---|
| `set.<noun>` | **Setter.** Replaces a state value with the supplied payload. Repeating with the same payload does nothing extra. | Yes | `set.mode`, `set.scaling`, `set.demand`, `set.inflow` |
| `cmd.<verb>` | **Imperative action.** Triggers a transition or sequence. Repeating triggers it again (or is rejected). | No | `cmd.startup`, `cmd.shutdown`, `cmd.estop`, `cmd.calibrate` |
| `data.<noun>` | **Bulk data input.** Sensor readings, measurement values, raw streams. The node consumes them. | n/a — values flow | `data.measurement`, `data.flow`, `data.pressure` |
| `child.<verb>` | **Parent/child plumbing.** Registration handshakes routed via Port 2. | n/a | `child.register`, `child.unregister` |
| `query.<noun>` | **Synchronous query.** The node responds on the same `msg` (or a sibling output). Used for read-only debug queries from a dashboard. | Yes (read-only) | `query.curves`, `query.cog`, `query.snapshot` |
#### Outputs — topics the node EMITS
| Prefix | Meaning | Where it appears |
|---|---|---|
| `evt.<noun>` | **Event.** A fact about something that just happened. Other nodes/dashboards subscribe to react. The node fires-and-forgets — no consumer is required. | `msg.topic` on Port 0 output, also fired internally on `this.emitter` so sibling modules can listen. |
`evt.*` is *one-way*: the node says "this happened", consumers can do
whatever they like with it. Examples: `evt.state-change` (state machine
moved), `evt.alarm` (a safety threshold tripped), `evt.calibrated`
(calibration completed). If you find yourself wanting to send a
command via `evt.*`, you actually want `set.*` or `cmd.*`.
The default measurement output (the delta-compressed payload from
`outputUtils.formatMsg`) keeps `msg.topic = config.general.name` per
the existing convention. `evt.*` is for *additional* event-shaped
emissions, not for the per-tick measurement stream.
#### Aliases for legacy names
Each `commands/index.js` declares the canonical name as `topic` and
lists pre-refactor names in `aliases`. The first time an alias fires,
the runtime logs a one-time deprecation warning. Aliases are removed
in Phase 7 after one release cycle.
#### Why these prefixes (the reasoning)
Today's topics mix `setMode` (verb-noun, no separator), `q_in`
(snake-case, abbreviation), `Qd` (PascalCase abbreviation),
`changemode` (lowercase joined), `execSequence` (verb-noun, camel).
A reader can't tell from the topic name whether it's a setter, an
action, or an event. The prefix system says it explicitly:
- `set.x` means "I'm replacing the value of x". Safe to retry.
- `cmd.x` means "I'm asking you to do x once". Don't retry blindly.
- `data.x` means "here's a value I'm pushing into your stream".
- `query.x` means "tell me what x is right now".
- `child.x` means "plumbing — only the parent/child machinery cares".
- `evt.x` (output only) means "this happened, do what you want".
## 2. `BaseNodeAdapter` — the shape of every nodeClass
Lives in `generalFunctions/src/nodered/BaseNodeAdapter.js`. Each node's
`nodeClass.js` extends it.
```js
const { BaseNodeAdapter } = require('generalFunctions');
const Domain = require('./specificClass');
const commands = require('./commands');
class nodeClass extends BaseNodeAdapter {
// The domain class to instantiate.
static DomainClass = Domain;
// The command registry — see section 4.
static commands = commands;
// Opt-in periodic tick. Default null = event-driven (domain emits
// 'output-changed' when output should refresh). Set to ms only when
// the domain genuinely needs a time-based heartbeat.
// Example reason (above the line): "needs delta-time for predicted
// volume integrator".
static tickInterval = null;
// Always-on status badge poll. Required for Node-RED's editor
// refresh. Set to 0 only in headless environments.
static statusInterval = 1000;
// Build the domain-specific config slice from the Node-RED uiConfig.
// Base config (general, asset, functionality, logging) is built by
// BaseNodeAdapter via configManager.buildConfig.
buildDomainConfig(uiConfig, nodeId) {
return {
basin: { volume: uiConfig.basinVolume, height: uiConfig.basinHeight, ... },
hydraulics: { ... },
control: { ... },
safety: { ... },
};
}
}
module.exports = nodeClass;
```
### Lifecycle (provided by base, do not reimplement)
In order, in the constructor:
1. Build merged config (`configManager.buildConfig` + `buildDomainConfig`).
2. Instantiate `DomainClass` with that config; store as `this.source`,
also as `this.node.source` for sibling-node lookup.
3. Send Port 2 registration message (after a 100 ms delay).
4. **Output strategy** — pick one based on `static tickInterval`:
- `tickInterval = N` (ms): start a periodic timer that calls
`this.source.tick?.()`, then formats and sends outputs.
- `tickInterval = null`: subscribe to `'output-changed'` on
`this.source.emitter`. Whenever the domain fires that event, the
adapter formats and sends outputs.
In both modes, `outputUtils.formatMsg` does delta compression — a
send only emits changed fields.
5. Start the status loop at `static statusInterval` ms:
- Call `this.source.getStatusBadge()` (see section 7), apply via
`node.status(...)`.
6. Attach the `input` handler — dispatches by `msg.topic` through the
commands registry.
7. Attach the `close` handler — clears timers, removes child
listeners, clears status.
### Event-driven is the default
A domain that doesn't need time-driven math fires
`this.emitter.emit('output-changed')` whenever its public state shifts
(e.g. after a measurement update, a state transition, a calibration).
The base adapter pushes outputs in response. No 1 Hz polling.
A domain that DOES need time-driven math (e.g. `pumpingStation`
integrating predicted volume) opts into a tick. The tick runs the
time-based update; if that update changes output state, the domain
emits `'output-changed'` and the same code path that handles
event-driven nodes pushes outputs.
This keeps the output pipeline single-shape regardless of which mode
the domain uses.
### Override hooks
A subclass may override:
| Hook | When |
|---|---|
| `buildDomainConfig(uiConfig, nodeId)` | Always — required. |
| `extraSetup()` | If a node needs custom wiring beyond the base. |
| `extraInputDispatch(msg, send, done)` | If commands registry can't express a topic. Avoid; prefer the registry. |
| `extraClose()` | Custom teardown beyond clearing intervals. |
### Forbidden in subclasses
- Re-implementing the tick or status loop. Use `getOutput()` /
`getStatusBadge()` on the domain.
- Calling `this.source._private`. Domain exposes a public surface.
- Importing from another node's `src/`.
## 3. `BaseDomain` — the shape of every specificClass
Lives in `generalFunctions/src/domain/BaseDomain.js`. Each node's
`specificClass.js` extends it.
```js
const { BaseDomain, UnitPolicy, ChildRouter } = require('generalFunctions');
class PumpingStation extends BaseDomain {
// Identifies the config in generalFunctions/src/configs/<name>.json.
static name = 'pumpingStation';
// Declarative unit policy — see section 6.
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
});
// Run after BaseDomain has built emitter, config, logger, measurements,
// childRegistrationUtils. Wire concern-modules and any extra state.
configure() {
this.basin = new BasinGeometry(this.config, this.logger);
this.flowAggregator = new FlowAggregator(this.context());
this.safety = new SafetyController(this.context());
this.strategies = require('./control');
this.router = new ChildRouter(this)
.on('machinegroup', this._onMachineGroup)
.on('measurement', { type: 'pressure' }, this._onPressure)
.on('measurement', { type: 'level' }, this._onLevel);
}
// Per-tick — orchestration only, all real work is in modules.
tick() {
this.flowAggregator.update();
const safe = this.safety.evaluate();
if (safe.blocked) return;
this.strategies[this.mode]?.run(this.context());
}
// What goes on Port 0 / Port 1.
getOutput() {
return {
...this.measurements.getFlattenedOutput(),
...this.basin.snapshot(),
...this.flowAggregator.snapshot(),
};
}
// What the Node-RED status badge shows — see section 7.
// Aggregators (no clean state machine) use compose. State-machine
// nodes (rotatingMachine) use byState. Both return {fill, shape, text}.
getStatusBadge() {
const direction = this.flowAggregator.direction;
const vol = this.measurements.type('volume').variant('measured').position('atequipment').getCurrentValue('m3');
const pct = (vol / this.basin.maxVolAtOverflow * 100).toFixed(1);
const arrow = direction === 'filling' ? '⬆️' : direction === 'draining' ? '⬇️' : '⏸️';
return statusBadge.compose([
`${arrow} ${pct}%`,
`V=${vol.toFixed(2)}/${this.basin.maxVolAtOverflow.toFixed(2)}`,
]);
}
}
module.exports = PumpingStation;
```
### What `BaseDomain` provides (do not reimplement)
The base constructor sets up:
| Property | Type | Notes |
|---|---|---|
| `this.emitter` | `EventEmitter` | Internal events. Fire `'output-changed'` here when public state shifts in event-driven nodes. |
| `this.configManager`, `this.configUtils`, `this.defaultConfig` | — | Wired from `static name`. |
| `this.config` | object | Validated config. |
| `this.logger` | logger | Named after `config.general.name`. |
| `this.measurements` | `MeasurementContainer` | Built from `static unitPolicy`. |
| `this.childRegistrationUtils` | child registry | The `child` dict is auto-created. |
Then it calls `this.configure()` — your hook. Then it calls
`this._init?.()` if defined.
### Named child accessors (registry-as-truth, readable in code)
Children live in `this.child[<softwareType>][<category>]` (the
registry, populated by `childRegistrationUtils`). For readable code,
each domain declares **named getters** in `configure()` that surface
the relevant slices:
```js
configure() {
// Reads as: ps.machines, ps.machineGroups, ps.stations.
this.declareChildGetter('machines', 'machine');
this.declareChildGetter('machineGroups', 'machinegroup');
this.declareChildGetter('stations', 'pumpingstation');
}
```
`declareChildGetter(name, softwareType, category?)` (provided by
BaseDomain) installs a getter that flattens
`this.child[softwareType]` into one object keyed by child id (across
all categories) — or filters by `category` if given.
The registry is the source of truth; the getters keep call sites
readable. `Object.values(this.machines).forEach(...)` works exactly
like before; assignments like `this.machines[id] = child` no longer
work — registration goes through `this.router` (or `registerChild`).
### Two output strategies — domain decides
| Strategy | When to pick | What domain does | What adapter does |
|---|---|---|---|
| **Event-driven** (default) | Domain reacts to incoming events (measurements, state changes, commands) and has no genuinely time-driven math. | Fire `this.emitter.emit('output-changed')` whenever the public output state shifts. | Subscribes to `'output-changed'`; on each fire, calls `getOutput()` and pushes the delta-compressed message. |
| **Tick-driven** (opt-in) | Domain has time-driven math that can't be expressed as a reaction to events (integrators, simulators, time-based thresholds). | Implement `tick()`. Fire `'output-changed'` from inside it whenever the tick changes output state. | Calls `tick()` every `static tickInterval` ms (set on the nodeClass subclass). Listens to `'output-changed'` the same as event-driven nodes. |
Both strategies funnel into the same `'output-changed'``getOutput()`
`formatMsg``node.send` pipeline. The only difference is what
fires the event.
### `this.context()`
Returns a frozen view passed to concern-modules so they don't reach into
`this`. Default shape:
```js
{
config: this.config,
logger: this.logger,
measurements: this.measurements,
emitter: this.emitter,
child: this.child,
unitPolicy: this.unitPolicy,
}
```
A node may override `context()` to add domain-specific keys (e.g.
`pumpingStation` adds `basin`).
### `getOutput()` and `getStatusBadge()` are the only required methods
Everything else is configuration. If a domain can be expressed without a
custom `tick()` (e.g. a passive aggregator), don't define one.
## 4. The commands registry
Each node has `src/commands/index.js` that exports an array of command
descriptors:
```js
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['setMode', 'changemode'], // legacy names
payloadSchema: { type: 'string' },
description: 'Switch the node between auto and manual control modes.',
handler: handlers.setMode,
},
{
topic: 'cmd.startup',
aliases: ['execSequence:startup'],
payloadSchema: { type: 'object', properties: { source: { type: 'string' } } },
handler: handlers.startup,
},
{
topic: 'cmd.calibrate',
payloadSchema: { type: 'none' },
description: 'Trigger a one-shot calibration. Payload is ignored.',
handler: handlers.calibrate,
},
...
];
```
### `payloadSchema.type` values
| Type | Meaning |
|---|---|
| `'string'` | `typeof payload === 'string'`. |
| `'number'` | `typeof payload === 'number'`. |
| `'boolean'` | `typeof payload === 'boolean'`. |
| `'object'` | Non-null object. Optional `properties: { key: 'typeName' }` enforces per-key `typeof` (missing keys allowed). |
| `'any'` | Anything passes. Use when the handler accepts heterogeneous payloads. |
| `'none'` | **Trigger-only.** Handler is invoked regardless of payload. If `msg.payload` is anything other than `undefined`/`null`, the registry logs a `warn` (`"<topic>: payload ignored — this is a trigger-only topic"`) and still invokes the handler. Use for pure triggers (`cmd.calibrate`, `cmd.estop`, `set.simulator`, ...) — strict alternative to `'any'`. |
### Optional `description` field
A descriptor may include a free-text 1-line `description` string. It is surfaced by `.list()` (the docs surface) and consumed by `wikiGen`'s topic-contract auto-gen. Example:
```js
{ topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger a one-shot calibration.', handler: handlers.calibrate }
```
### Optional `units` field — pre-dispatch unit normalisation
A descriptor for a numeric setter / data topic may declare:
```js
units: { measure: '<measureName>', default: '<unitAbbr>' }
```
- `measure`: a `convert`-recognised measure name (`volumeFlowRate`, `pressure`, `power`, `temperature`, `volume`, `length`, …).
- `default`: the unit the handler always receives. Operator-friendly (e.g. `m3/h`, `mbar`, `kW`, `C`).
Validation: if `units` is present, both fields must be non-empty strings. The registry throws at construction otherwise.
At dispatch time, **before** the handler runs and **before** payload-schema validation, the registry normalises the incoming msg:
1. Extract value + unit. Three accepted shapes:
- `msg.payload` is a number → `value = msg.payload`, `unit = msg.unit`.
- `msg.payload = { value: <number>, unit?: <string> }` → use those (falls back to `msg.unit` if `payload.unit` is absent).
- Anything else (string, object without `value`, missing payload, …) → normalisation is skipped; the handler receives the raw msg unchanged. No crash.
2. Determine the unit-of-record:
- **No unit supplied** → silently assume `units.default`.
- **Unit recognised + correct measure** → `convert(value).from(unit).to(default)`.
- **Unit recognised but wrong measure** → log `warn` with the topic, the actual measure, the expected measure, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`.
- **Unit unrecognised** → log `warn` with the topic, the unknown unit, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`.
3. Rewrite the msg so the handler sees uniform inputs:
- `msg.payload` becomes the normalised number in `units.default` (the object form `{value, unit}` is flattened to a number).
- `msg.unit` is set to `units.default`.
Accepted-unit lists come from `convert.possibilities(measure)`. If that helper is unavailable, the warn falls back to `(see convert docs)`.
The `units` field is surfaced by `.list()` (so wikiGen + `query.units` can render the contract) and is `null` for descriptors that don't declare it.
Example:
```js
{
topic: 'set.demand',
units: { measure: 'volumeFlowRate', default: 'm3/h' },
payloadSchema: { type: 'number' },
description: 'Operator demand setpoint.',
handler: handlers.setDemand,
}
```
A handler is a pure function:
```js
// handlers.js
exports.setMode = (source, msg, ctx) => {
source.setMode(msg.payload);
};
exports.startup = async (source, msg, ctx) => {
await source.handleInput(msg.payload?.source ?? 'parent', 'execSequence', 'startup');
};
```
The `BaseNodeAdapter` builds a `Map<topic-or-alias, descriptor>` at
construction time. Dispatch is one lookup. Aliases log a one-time
deprecation warning the first time each fires.
### Why declarative?
- Auto-generates `CONTRACT.md` per node.
- Lets us add cross-node static checks (no two nodes use the same
`set.x` for different things).
- Replaces the per-node 100-line input switch with a 5-line dispatch.
## 5. `ChildRouter` — declarative parent registration
Lives in `generalFunctions/src/domain/ChildRouter.js`. Built on top of
the existing `childRegistrationUtils`.
```js
this.router = new ChildRouter(this)
// Register a callback when a child of a given software type registers.
.onRegister('machinegroup', (child) => this._onMachineGroupRegistered(child))
// Subscribe to a measurement event from any child of a given softwareType.
// The third arg filters by emit-side position.
.onMeasurement('measurement', { type: 'pressure', position: 'upstream' }, (data, child) => {
this._onPressure('upstream', data.value, data);
})
// Subscribe to predicted-flow events from any group/machine child.
.onPrediction('machinegroup', { type: 'flow', position: 'downstream' }, (data, child) => {
this._onPredictedFlow(child, data);
});
```
`ChildRouter` owns:
- The handler maps (`onRegister`, `onMeasurement`, `onPrediction`).
- Listener attachment + teardown (called from `BaseDomain` on close).
- Software-type alias resolution (already in `childRegistrationUtils`).
Per-node `registerChild` boilerplate disappears. The base
`childRegistrationUtils.registerChild` calls `this.mainClass.registerChild`
which delegates to `this.router.dispatchRegister(child, softwareType)`.
## 6. `UnitPolicy`
Lives in `generalFunctions/src/domain/UnitPolicy.js`. Replaces the
duplicated `_buildUnitPolicy` / `_resolveUnitOrFallback` /
`_convertUnitValue` in `rotatingMachine` and `machineGroupControl`.
```js
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
curve: { flow: 'm3/h', pressure: 'mbar', power: 'kW', control: '%' }, // optional
// Types whose values must always carry a unit on write.
requireUnitForTypes: ['flow', 'pressure', 'power', 'temperature'],
});
```
Methods on the resulting policy:
| Method | Purpose |
|---|---|
| `policy.canonical(type)` | Canonical unit for a measurement type. |
| `policy.output(type)` | Display / IO unit for a measurement type. |
| `policy.curve(type)` | Curve-input unit for a measurement type (returns `null` if no `curve` was declared). |
| `policy.resolve(candidate, expectedMeasure, fallback, label)` | Validate a user-supplied unit, fall back if invalid (logs `warn`). |
| `policy.convert(value, fromUnit, toUnit, contextLabel)` | Strict conversion. |
| `policy.containerOptions()` | Returns the option bag for a `MeasurementContainer`. |
### Dual access shape (method OR frozen property bag)
`canonical`, `output`, and `curve` each work both as a method call AND as a
frozen own-property map. They are functions with `Object.defineProperty`-installed
non-writable, non-configurable own properties, frozen via `Object.freeze`:
```js
policy.canonical('flow') // 'm3/s' (method)
policy.canonical.flow // 'm3/s' (property)
policy.output.pressure // 'mbar' (property)
policy.curve.control // '%' (property)
policy.canonical.flow = 'tampered'; // TypeError in strict mode
delete policy.canonical.pressure; // TypeError
Object.isFrozen(policy.canonical); // true
```
The property-bag form is preferred in hot paths and tight inner loops (one
lookup vs one function call). The method form is preferred when the type is
itself dynamic (`policy.canonical(typeName)`). Both forms are first-class
parts of the contract — call sites may use whichever reads best.
This replaces the per-node `_unitView` / `unitPolicyView` mirror that
pre-dated the dual-shape accessor — domains read `this.unitPolicy` directly.
`BaseDomain` reads `static unitPolicy` and passes
`policy.containerOptions()` straight into `new MeasurementContainer(...)`.
## 7. `getStatusBadge()` shape
Every domain returns the standard Node-RED status object:
```js
{
fill: 'green' | 'yellow' | 'red' | 'blue' | 'grey',
shape: 'dot' | 'ring',
text: string, // ≤ 60 chars in the Node-RED editor; aim for ≤ 50.
}
```
Helpers in `generalFunctions/src/nodered/statusBadge.js`:
```js
const { statusBadge } = require('generalFunctions');
statusBadge.compose(['🟢 OK', `flow=${flow.toFixed(1)} m³/h`]) // joins with ' | '
statusBadge.error(message) // {fill:'red', shape:'ring', text:`⚠ ${message}`}
statusBadge.idle(label) // {fill:'blue', shape:'dot', text:`⏸️ ${label}`}
```
The badge is computed in **domain**, not in `nodeClass`. nodeClass just
calls `this.source.getStatusBadge()` once per second.
## 8. `LatestWinsGate`
Extracted from MGC's `_dispatchInFlight` + `_delayedCall` pattern. Used
anywhere a parent fires commands faster than children can absorb them.
```js
const { LatestWinsGate } = require('generalFunctions');
this.demandGate = new LatestWinsGate(async (demand) => {
await this._dispatchDemandToChildren(demand);
});
// Fire-and-forget — never blocks. The latest demand always wins.
this.demandGate.fire(demand);
// Await the per-fire settlement.
const result = await this.demandGate.fireAndWait(demand);
if (result && result.superseded === true) {
// A later fire/fireAndWait overwrote this one in the pending slot.
}
```
Guarantees:
- At most one `dispatch` running at a time per gate.
- If a new value arrives while one is running, only the latest is
enqueued; intermediate ones are dropped.
- After the in-flight call settles, the latest pending value fires.
### `fire(value)` vs `fireAndWait(value)`
| Method | Returns | Settles when |
|---|---|---|
| `fire(value)` | `void` | n/a — caller never awaits. |
| `fireAndWait(value)` | `Promise<result \| SUPERSEDED \| undefined>` | THIS specific fire's dispatch settles. If a later fire (plain or awaited) overwrites this one in the pending slot, the returned promise **resolves** with the frozen sentinel `LatestWinsGate.SUPERSEDED = { superseded: true }`. If the dispatch itself throws, the promise still resolves (with `undefined`) and the error is recorded on `gate.lastError` — callers don't need try/catch. |
The supersede-resolves-with-sentinel choice (rather than rejecting with
`'superseded'`) means consumers branch on a value:
```js
const r = await gate.fireAndWait(v);
if (r && r.superseded) return; // dropped by a later fire
// ... otherwise r is the dispatch's return value
```
`drain()` remains the right tool for "wait until idle" (returns one
promise regardless of how many fires landed); `fireAndWait` is per-fire.
## 9. `HealthStatus`
A standardised shape for nodes that compute prediction quality / drift
(today: `rotatingMachine.predictionHealth`, future: `MGC`, `pumpingStation`
volume confidence).
```js
{
level: 0 | 1 | 2 | 3, // 0 = fine, 3 = unusable
flags: string[], // machine-readable tags, e.g. 'no_pressure_input'
message: string, // single-line human summary
source: string | null, // free-text origin tag
}
```
Helpers compose multiple sub-statuses (e.g. flow drift + power drift +
pressure init) into one node-level status.
## 10. Output port payload conventions
Already documented in `.claude/rules/telemetry.md` — kept here only as a
pointer:
- Port 0: process data, formatter chosen by `config.output.process`.
- Port 1: InfluxDB line-protocol, formatter chosen by
`config.output.dbase`.
- Port 2: registration / control plumbing.
- `outputUtils.formatMsg` does delta compression — only changed fields
are sent. Consumers must cache + merge.

View File

@@ -0,0 +1,175 @@
# Conventions
These rules apply to **every file written or edited** during the refactor.
They override personal preference. Be explicit about deviations in
`OPEN_QUESTIONS.md`.
## File size
| Type | Soft target | Hard cap |
|---|---|---|
| Domain module (one class / one concern) | ≤ 200 lines | 300 lines |
| Pure-function utility module | ≤ 150 lines | 250 lines |
| Test file (one .test.js) | ≤ 300 lines | 500 lines |
| Markdown spec (in this dir) | — | — |
If you go over the soft target, ask: is this two concerns? If yes, split.
Split before refactoring callers — the smaller pieces test easier.
## Function size
- Soft target: ≤ 30 lines.
- Hard cap: 60 lines (excluding comments).
- A `switch` with mostly-trivial cases counts as one statement, not many.
- A long pure-math function (e.g. an integrator) is OK if it can't be
meaningfully split.
## Comments
Lead with the rule: **default to no comments**. Add one only when *why*
is non-obvious to a reader who can already read the code.
✅ Good comments:
```js
// Latest-wins: if a new demand arrives mid-dispatch, queue it and
// pick up after the current dispatch settles. Without this gate
// every PS tick aborts in-flight pump ramps.
```
❌ Bad comments:
```js
// Set inflow to the value
this.inflow = value;
```
```js
// Loop over machines
for (const m of machines) { ... }
```
Function-level docstring policy:
- One short line above the function describing **what it produces** when
the name alone isn't enough.
- Skip JSDoc `@param` blocks unless the function is part of a public
contract (the things in `CONTRACTS.md`). Inline destructuring + good
names beats JSDoc that drifts.
- Never write multi-paragraph docstrings.
Inline comments inside a function:
- Use to flag a non-obvious invariant, a workaround, or a regression
guard. Reference a ticket / commit SHA only if the workaround is
load-bearing.
- Never narrate what the next line does.
## Naming
| Thing | Convention | Example |
|---|---|---|
| File holding a class | `PascalCase.js` matching the class name | `BasinGeometry.js` |
| File of utilities / pure functions | `camelCase.js` | `flowAggregator.js` |
| Folder under `src/` | `camelCase` (concern, plural for collections) | `control/`, `strategies/`, `commands/` |
| Class | `PascalCase` | `class BasinGeometry` |
| Function / method | `camelCase` | `selectBestNetFlow()` |
| Private method (convention only) | leading `_` | `_validateThresholdOrdering()` |
| Constant | `UPPER_SNAKE_CASE` | `CANONICAL_UNITS` |
| Module-private | leading `_` on the local | `const _DEFAULTS = {...}` |
| Test file | `<name>.<tier>.test.js` | `flowAggregator.basic.test.js` |
## Imports
- A node may import from:
- `generalFunctions` (the shared lib)
- its own `src/` tree
- Node built-ins (`events`, `path`, ...)
- declared `dependencies` in its `package.json`
- A node MUST NOT import from another node's `src/`.
- Cross-node coupling happens only through:
- the shared `generalFunctions` API
- Node-RED messages (Port 0/1/2)
- the parent/child registration handshake (`childRegistrationUtils`)
- Avoid deep imports inside `generalFunctions`. Always import from the
package root: `const { logger } = require('generalFunctions')`.
Exception: tests for `generalFunctions` itself.
## Module shape
Default to **one default export per file** when the file is named after
the thing it exports (a class, a singleton). Use named exports for
collections of small utilities.
```js
// File: BasinGeometry.js
class BasinGeometry { ... }
module.exports = BasinGeometry;
```
```js
// File: flowAggregator.js
function selectBestNetFlow(ctx) { ... }
function updatePredictedVolume(ctx) { ... }
module.exports = { selectBestNetFlow, updatePredictedVolume };
```
## Error handling
- Validate at boundaries (Node-RED input handler, child registration).
Trust internal calls — don't re-validate parameters that already
passed an outer check.
- Logging on a recoverable issue: `logger.warn` once, fall back to a safe
default, continue. Don't throw.
- Logging on an unrecoverable issue: `logger.error` and stop ticking the
affected subsystem (don't crash Node-RED).
- Hard fail (`throw`) only for invariant violations the caller can't
recover from (e.g. config schema mismatch detected at construction
time).
## Logging
- Use the `generalFunctions` `logger` exclusively. No `console.log`.
- Log levels:
- `error`: something is wrong and downstream behaviour will be
affected.
- `warn`: something is unexpected; falling back to a safe default.
- `info`: state transitions of operational interest (mode changes,
child registrations, calibrations).
- `debug`: per-tick / per-event traces.
- Do **not** ship `enableLog: "debug"` in any default config or example
flow. Logs flood within seconds.
## Testing
Three tiers per module, mirroring the existing structure:
```
test/
basic/<module>.basic.test.js # one module in isolation
integration/<feature>.integration.test.js # multiple modules together
edge/<scenario>.edge.test.js # edge cases / regressions
```
Rules:
- Every new module from a refactor gets at least a basic test.
- Every regression discovered during refactor gets an edge test pinning
it.
- Tests run with `node --test`. No external test framework.
- A PR may not lower the green-test count.
- Production-readiness ("trial-ready") still requires Docker E2E in
addition to `node --test`. See per-node memory.
## Pure-domain rule (specificClass and below)
Code under `src/` (other than `nodeClass.js`) is **pure domain**. It must
not:
- Touch `RED.*`
- Read `process.env`
- Assume Node-RED is running
This makes every domain module testable from a plain Node process.
## Observability of changes
When a refactor moves logic from one file to another:
- Keep behaviour identical at first. Tests pin it.
- Behavioural changes (renaming a topic, changing a payload shape) go in
separate PRs that are explicitly behavioural.
- `git mv` for pure relocations so blame stays useful.

View File

@@ -0,0 +1,208 @@
# Per-node module split
Where each concern lives **after** the refactor. All paths are relative
to `nodes/<nodeName>/src/`.
## Generic node template (any node post-refactor)
```
nodes/<name>/
<name>.js # Node-RED entry: registerType + admin endpoints (≤ 50 lines)
<name>.html # Form template + thin oneditprepare/oneditsave (≤ 250 lines)
CONTRACT.md # Generated from commands/ + hand-written events
examples/
01-basic.json
02-integration.json
03-dashboard.json # optional
src/
nodeClass.js # extends BaseNodeAdapter; ~25 lines
specificClass.js # extends BaseDomain; orchestrator only; ~150 lines
editor.js # client-side JS for HTML, served via admin endpoint (only if non-trivial UI)
commands/
index.js # the command registry array
handlers.js # the handler functions
<concern>/ # one folder per domain concern (see per-node sections below)
...
test/
basic/
integration/
edge/
```
## pumpingStation (Process Cell — L5, group `#0c99d9` · palette `#8B4513`)
```
src/
nodeClass.js # ~25 lines, extends BaseNodeAdapter
specificClass.js # ~150 lines, orchestrator
editor.js # extracted SVG/redraw logic from the .html (~260 lines)
commands/
index.js # set.mode | set.demand | set.inflow | calibrate.* | child.register
handlers.js
basin/
BasinGeometry.js # initBasinProperties + level<->volume conversions
thresholdValidator.js # _validateThresholdOrdering — pure function
measurement/
flowAggregator.js # _selectBestNetFlow + _updatePredictedVolume + _computeRemainingTime + _levelRate + _deriveDirection
measurementRouter.js # _handleMeasurement + _onLevelMeasurement + _onPressureMeasurement
calibration.js # calibratePredictedVolume + calibratePredictedLevel + setManualInflow
control/
levelBased.js # _controlLevelBased + _scaleLevelToFlowPercent + _applyMachineGroupLevelControl
flowBased.js # placeholder for the flow mode; clearly stubbed
manual.js # forwardDemandToChildren
index.js # { 'levelbased': ..., 'flowbased': ..., 'manual': ... }
safety/
safetyController.js # evaluate() — split internally into dryRunRule + overfillRule
io/
statusBadge.js # getStatusBadge composition (was nodeClass._updateNodeStatus)
output.js # getOutput, mostly a pass-through to measurements + basin snapshot
configBuilder.js # extracted _loadConfig mapping
examples/
standalone-demo.js # extracted from the bottom of specificClass.js
```
## measurement (Control Module — L2, group `#a9daee` · palette `#D4A02E`)
The good news: `Channel.js` already exists and is pure. Most of the
analog mode in `specificClass.js` is duplication that vanishes when the
analog path also goes through `Channel`.
```
src/
nodeClass.js # extends BaseNodeAdapter
specificClass.js # ~150 lines, orchestrator over modes
channel/
Channel.js # KEEP — already clean, the model for everything else
modes/
analogMode.js # one Channel built from flat config; routes msg.payload number
digitalMode.js # N channels from config.channels[]; routes msg.payload object
index.js # { analog, digital }
simulation/
simulator.js # simulateInput — random walk over the configured range
calibration/
calibrator.js # calibrate + isStable + standardDeviation helpers (drop duplicates of the static helpers in Channel)
commands/
index.js # set.simulator | set.outlierDetection | cmd.calibrate | data.measurement
handlers.js
```
`statistics/` (mean/stdDev/median/etc.) — promote to
`generalFunctions/src/stats/`. Both `Channel.static helpers` and the
calibrator use them.
## machineGroupControl (Unit — L4, group `#50a8d9` · palette `#B5651D`)
```
src/
nodeClass.js # extends BaseNodeAdapter
specificClass.js # ~200 lines orchestrator; tick/handlePressureChange/handleInput
groupOps/
groupOperatingPoint.js # _equalizeOperatingPoint, _readChildMeasurement, _writeMeasurement
groupCurves.js # _groupFlow, _groupPower, _groupNCog, _groupCalcPower
totals/
totalsCalculator.js # calcDynamicTotals, calcAbsoluteTotals, activeTotals
combinatorics/
pumpCombinations.js # validPumpCombinations + checkSpecialCases
optimizer/
bestCombination.js # calcBestCombination (CoG-based)
bepGravitation.js # calcBestCombinationBEPGravitation + redistributeFlowBySlope + estimateSlopesAtBEP
index.js # picks the optimizer by config
efficiency/
groupEfficiency.js # calcGroupEfficiency + calcDistanceBEP + helpers
dispatch/
demandDispatcher.js # uses LatestWinsGate; handleInput + per-machine fanout
registration/ # auto via ChildRouter — file may be tiny
commands/
index.js # set.mode | set.scaling | set.demand | child.register
handlers.js
```
## rotatingMachine (Equipment Module — L3, group `#86bbdd` · palette `#E89B3A`)
The biggest specificClass (1760 lines). The split mirrors the natural
boundaries the existing comments suggest.
```
src/
nodeClass.js # extends BaseNodeAdapter
specificClass.js # ~250 lines orchestrator
curves/
curveLoader.js # loadCurve wrapper + model resolution
curveNormalizer.js # _normalizeMachineCurve + _normalizeCurveSection (unit conversion + anomaly detection)
reverseCurve.js # the existing reverseCurve helper
prediction/
predictors.js # owns predictFlow / predictPower / predictCtrl (delegates to generalFunctions/predict)
groupPredictors.js # group-scope predictors used when an MGC parent calls setGroupOperatingPoint
operatingPoint.js # current operating point: pressure source, derived flow & power
drift/
driftAssessor.js # _updateMetricDrift + assessDrift + _applyDriftPenalty
predictionHealth.js # composes flow/power/pressure drift into a HealthStatus
pressure/
virtualChildren.js # _initVirtualPressureChildren + dashboard-sim children
pressureInitialization.js # getPressureInitializationStatus + tracking real children
pressureRouter.js # updateMeasuredPressure + per-position handling
state/ # adapter to generalFunctions/state — thin glue, lifecycle hooks
stateBindings.js # the position/state event handlers that fire _updateState etc.
measurement/
measurementHandlers.js # updateMeasured{Flow,Power,Temperature} + _callMeasurementHandler
flow/
flowController.js # handleInput dispatch by source/action/parameter — feeds state machine
display/
workingCurves.js # showWorkingCurves + showCoG (admin endpoints)
commands/
index.js # set.mode | cmd.startup | cmd.shutdown | cmd.estop | cmd.setpoint | cmd.flow-setpoint | data.simulate-measurement | query.curves | query.cog
handlers.js
```
## remaining nodes (skeleton — they get the platform refactor only)
| Node | Notes |
|---|---|
| `valve` | Equipment Module. Smaller than rotatingMachine — concern split likely just `state/`, `commands/`, `position/`. |
| `valveGroupControl` | Unit. Similar to MGC but no flow-power optimization — straightforward `position-aggregator` + `commands/`. |
| `reactor` | Unit. Domain is biological kinetics (ASM); will need a `kinetics/` folder. Big — second-tier candidate for deeper split. |
| `settler` | Unit. Has the recently-fixed `_connectReactor` integration; keep that wired through `ChildRouter`. |
| `monster` | Unit. Multi-parameter monitoring; the parameter set itself is config-driven. |
| `diffuser` | Equipment Module. Aeration controller. Likely small. |
| `dashboardAPI` | Utility. InfluxDB endpoints. Likely no `BaseDomain` — it's a passive HTTP server. |
Palette swatches for these (sidebar): `valve` `#3CAEA3`, `valveGroupControl` `#2A8A82`, `reactor` `#6FAE5F`, `settler` `#8FAD3F`, `monster` `#9C5BB0`, `diffuser` `#6EB5E5`, `dashboardAPI` `#7A8BA3`. Group-box hex still follows S88 level (see `.claude/rules/node-red-flow-layout.md` §10.0).
The "skeleton" refactor for these is just:
- Convert `nodeClass.js` to extend `BaseNodeAdapter`.
- Convert `specificClass.js` to extend `BaseDomain`.
- Move the input switch to `commands/`.
- Add `getStatusBadge()` if not present.
- Use `ChildRouter` for registration.
- File splits driven by file size — if `specificClass` < 300 lines, leave it alone for now.
## generalFunctions itself
```
src/
configs/ # unchanged — JSON schemas per node
helper/ # eventually split into infra/ + domain/, but not in this refactor
measurements/ # MeasurementContainer — unchanged
nodered/ # NEW — node-RED-side infra
BaseNodeAdapter.js
commandRegistry.js
statusBadge.js # composition helpers
statusUpdater.js # the 1 Hz status-loop wrapper
index.js
domain/ # NEW — domain-side infra
BaseDomain.js
UnitPolicy.js
ChildRouter.js
LatestWinsGate.js
HealthStatus.js
index.js
stats/ # NEW — promoted from measurement (mean, std, median, mad, lerp)
index.js
```
Existing exports (`logger`, `configManager`, `outputUtils`,
`MeasurementContainer`, `predict`, `interpolation`, `state`, …) stay
exactly where they are. Imports keep working unchanged.
`generalFunctions/index.js` adds new exports alongside existing ones.
Nothing is removed in this refactor.

View File

@@ -0,0 +1,765 @@
# Open questions
Things deferred. Append, don't rewrite history. Add a date when you add
or resolve an entry. Anyone (human or agent) discovering an unclear
decision during refactor work writes it here rather than guessing.
---
## 2026-05-11 — Interview round — resolved decisions
| Topic | Decision |
|---|---|
| Ramp foot for run-zone curve (control/levelBased) | `inflowLevel` (current). startLevel is the 0% minimum, not the curve foot. |
| `overfillLevel` vs `highVolumeSafetyLevel` | **`highVolumeSafetyLevel` canonical**; drop the legacy alias. |
| measurement `isStable` tautology | Fix now with a config-driven absolute threshold (`stabilityThreshold` in scaling-units). Add to schema + editor UI. |
| monster cooldown-guard pre-existing fail | **RESOLVED 2026-05-11** — root cause was missing `nominalFlowMin`/`flowMax`/`maxRainRef`/`minSampleIntervalSec` in `monster.json`, stripped by `configUtils.initConfig` before reaching the domain. Added the four keys to the schema. |
| pumpingStation plain child dicts | Migrate to `declareChildGetter`; rewrite affected tests. |
| VGC custom `registerChild` overload | Adopt ChildRouter; rewrite disambiguation tests. |
| MGC inline dispatch gate vs LatestWinsGate | Extend LatestWinsGate with `fireAndWait(value)` returning the per-fire settlement promise. Migrate MGC. |
| measurement legacy `'mAbs'` event | Remove now. |
| ChildRouter wildcard emit-patch | Per-listener fan-out using canonical POSITIONS. No more emit patching. |
| commandRegistry payload schema | Add `'none'` type + per-command `description` field (wikiGen consumes). |
| UnitPolicy property-vs-method shape | Expose both. Frozen property bags alongside the methods. Drop `_unitView` workarounds. |
| rotatingMachine + reactor private-method-pinning tests (13 files) | Rewrite all to drive only the public BaseNodeAdapter surface. Phase 10. |
| **Unit-aware commands (new)** | Each numeric setter declares `units: { measure, default }`. commandRegistry normalises + warns + lists accepted units. `query.units` topic returns spec. Phase 11. |
Format:
```
## YYYY-MM-DD — Short title
**Context:** what we're trying to do
**Question:** what's unresolved
**Default chosen:** what we did meanwhile
**Decision needed by:** which phase or task
```
---
## 2026-05-10 — External Port-0 topic naming — RESOLVED
**Decision (2026-05-10):** Use canonical names (`set.*` / `cmd.*` /
`data.*` / `child.*` / `query.*` / `evt.*`) **from Phase 1 onwards**.
Each `commands/index.js` declares the canonical name as the topic and
lists legacy names in `aliases`. Aliases log a one-time deprecation
warning. Phase 7 shrinks to: remove aliases after one release cycle.
The full prefix glossary (with what each does and why) is now in
`CONTRACTS.md §1`. See it before naming a topic.
---
## 2026-05-10 — Parent EVOLV repo `development` branch lineage — RESOLVED
**Decision (2026-05-10):** Rebase parent `development` onto
`origin/main` before the refactor proceeds. Done at the start of
Phase 1.
---
## 2026-05-10 — `generalFunctions` deprecated paths — RESOLVED
**Decision (2026-05-10):** Tracked as Phase 8.5 in `TASKS.md`. Cleanup
runs after promotion to main. The list of paths to remove is captured
there so it isn't lost.
---
## 2026-05-10 — Two child-storage shapes — RESOLVED
**Decision (2026-05-10):** Registry-as-truth, **with named getters** that
read clearly in code. `domain.machines` keeps working — it's a getter
that returns the rotatingMachine slice of `this.child`. Same for
`domain.stations`, `domain.machineGroups`, etc. Domain code reads
naturally; the registry is the source of truth underneath.
Named getters are declared by the domain subclass in `configure()`:
```js
configure() {
Object.defineProperty(this, 'machines',
{ get: () => this.child?.machine?.centrifugal ?? {} });
}
```
(`BaseDomain` provides a helper for this pattern.)
---
## 2026-05-10 — Async vs sync `tick()` — RESOLVED with redesign
**Decision (2026-05-10):** Default is **event-driven**. Ticks are
opt-in.
`BaseNodeAdapter` exposes two timers:
- `static tickInterval = null` — opt-in periodic tick. Default null = no
tick. Domain emits `'output-changed'` on `this.emitter` instead, and
BaseNodeAdapter subscribes to that event to push outputs.
- `static statusInterval = 1000` — always-on status badge poll.
Required because Node-RED's editor refresh expects a heartbeat. Set
to 0 only in headless test environments.
When opting into ticks:
- Document **why** in a one-line comment above
`static tickInterval = ...` (e.g. "needs delta-time for predicted
volume integrator").
- A node should opt in only when truly time-driven. Examples that need
it: `pumpingStation` (predicted volume integrates over time),
`measurement` (when simulator is enabled — ticks the random walk).
- Examples that DO NOT need it: `MGC` (recomputes on pressure events),
`rotatingMachine` (recomputes on measurement events + state changes).
`tick()` is treated as fire-and-forget (no await). A node that needs
serialisation uses `LatestWinsGate` internally.
See `CONTRACTS.md §2` for the BaseNodeAdapter shape.
---
## 2026-05-10 — ChildRouter wildcard subscriptions monkey-patch `emit` — RESOLVED
**Resolution (2026-05-11):** Switched to per-listener fan-out using the
canonical `POSITIONS` list and a 19-type set (`MeasurementContainer.measureMap`
keys + synthetic EVOLV types). Each partial-filter subscription enumerates
every concrete `<type>.<variant>.<position>` event name and registers a
plain `emitter.on()` per combo. Multi-parent works without emit patching.
ChildRouter.js 184 → 164 lines; 12/12 tests pass including a new
multi-parent regression test.
### Original entry below
## 2026-05-10 — ChildRouter wildcard subscriptions monkey-patch `emit` (history)
**Context:** P1.2 implementation. EventEmitter has no native wildcard.
Subscriptions with a partial filter (`{type}`-only or `{position}`-only)
install a per-variant `emit` proxy on the child's emitter; concrete
`{type, position}` filters use plain `emitter.on`.
**Question:** Multi-parent children. `child.parent` is already an array
in `childRegistrationUtils`, so a child can be registered under several
parents. If two parents each install ChildRouter wildcard proxies on
the same `child.measurements.emitter`, the wraps stack — but
`tearDown` only unwraps when its own bookkeeping is empty. Is this
correct semantics for multi-parent teardown ordering? Or should we
switch to per-listener fan-out (subscribe to every known
`<type>.<variant>.<position>` enumerated from a registry)?
**Default chosen:** Stacked wrappers. The current `childRegistrationUtils`
multi-parent path is rarely exercised in production. Revisit if
Phase 2 / Phase 4 hits a real multi-parent case.
**Decision needed by:** Phase 4.
---
## 2026-05-10 — `predictionHealth` migration in rotatingMachine
**Context:** P1.4 implementation flagged that the existing
`rotatingMachine.predictionHealth` carries `quality` (string) +
`confidence` (0..1 numeric) on top of the new `HealthStatus` shape's
`{level, flags, message, source}`.
**Question:** Where does `confidence` live after migration?
**Default chosen:** Keep `confidence` on the per-metric drift
container as a sibling to a `health: HealthStatus` field. Drift
diagnostics (`nrmse`, `longTermNRMSD`, `immediateLevel`) stay as
siblings too. `HealthStatus` carries only the standardised five fields.
**Decision needed by:** Phase 5 (`rotatingMachine` refactor).
---
## 2026-05-10 — `dashboardAPI` basic test broken (pre-existing) — RESOLVED
**Context:** P1.12 sanity gate. `dashboardAPI/test/basic/structure-module-load.basic.test.js` uses Mocha-style `describe()` globals which don't exist under `node:test`. Reports 0 pass / 1 fail with `ReferenceError: describe is not defined`.
**Action:** Pre-existing — not caused by Phase 1. Convert to `node:test` form during Phase 6 when `dashboardAPI` gets its skeleton refactor. Tracked here so it isn't lost.
**Update (P6.7, 2026-05-10):** Converted to `node:test` form (`const test = require('node:test')` + `assert.doesNotThrow`). Basic test now reports 1 pass / 0 fail. The Mocha-style `test/dashboardapi.test.js`, `test/nodeClass.test.js`, `test/integration/`, and `test/edge/` files still use jest/Mocha globals — out of scope for P6.7; deferred to P10 test-suite refactor.
---
## 2026-05-10 — `dashboardAPI` skipped BaseNodeAdapter + BaseDomain
**Context:** P6.7. dashboardAPI is a passive HTTP-emitter utility node: no
`generalFunctions/src/configs/dashboardapi.json`, no periodic Port-0/1
telemetry stream, no parent registration, no measurements, no tick loop,
no status badge. `BaseDomain` constructor would throw on the missing
config file; `BaseNodeAdapter._scheduleRegistration` would emit a
spurious `child.register` for a node that has no parent; the
`outputUtils.formatMsg` pipeline assumes a measurement-shaped output
which dashboardAPI lacks.
**Default chosen:** `nodeClass` stays a plain class (does **not** extend
`BaseNodeAdapter`); `specificClass` (`DashboardApi`) stays a plain class
(does **not** extend `BaseDomain`). Only the shared `commandRegistry`
is adopted (canonical topic `child.register` with `registerChild` alias
+ deprecation warning). One handler module in `src/commands/`. nodeClass
shrunk from 134 → 73 lines.
**Decision needed by:** Phase 7 / Phase 8 — revisit if `BaseNodeAdapter`
grows a passive/HTTP-only mode (skip-registration + skip-output-stream
flags) or if a `dashboardapi.json` config gets added to generalFunctions.
Either makes adoption straightforward; until then the bespoke shape is
correct.
---
## 2026-05-10 — pumpingStation: plain dicts vs `declareChildGetter` — RESOLVED 2026-05-11
**Resolution (2026-05-11, B2.1):** Migrated. `this.machines / machineGroups
/ stations` are now BaseDomain `declareChildGetter` accessors over the
`childRegistrationUtils` registry; the `predictedFlowChildren` Map and
the dict-mutation lines in the router `onRegister` callbacks are gone.
The `context()` override installs **live getters** for the same three
names on the returned ctx so SafetyController (which captures ctx once
at construct-time) keeps reading the live registry across later
registrations. specificClass.js 316 → 314 lines.
Affected test files rewritten to inject mock children through the real
handshake instead of dict-assignment:
- `test/basic/specificClass.test.js` — added a `registerMockGroup(ps, id)`
helper that builds a mock with `config.functionality.softwareType =
'machinegroup'`, a stub `measurements.emitter.on`, and instrumented
`handleInput` / `turnOffAllMachines`. All 9 `ps.machineGroups['mgc1']
= {...}` blocks now call the helper; the 4 sub-tests that previously
asserted on a captured `turnOffCalls` / `demands` array assert on the
helper-returned `mock._calls` instead.
- `test/integration/shifted-ramp-end-to-end.test.js``buildHarness()`
now calls a local `registerMockGroup(ps, 'mgc1', demands)` helper that
pushes into the existing `demands` array via the registered mock's
`handleInput`. No assertion shape changed.
128/130 pumpingStation tests pass after the migration (the 2 remaining
failures — `canonical topics dispatch to their handlers` and `set.inflow
accepts number payload …` in `test/basic/commands.basic.test.js` — are
pre-existing and unrelated to child storage).
### Original entry below
## 2026-05-10 — pumpingStation: plain dicts vs `declareChildGetter` (history)
**Context:** P2.7+P2.8+P2.9. The 2026-05-10 "Two child-storage shapes"
decision says use `declareChildGetter` (registry-as-truth), but the
existing pumpingStation test suite mutates `ps.machineGroups['mgc1'] = {...}`
directly to inject mock children before driving `_controlLevelBased`.
A getter-backed `machineGroups` returns a fresh object per call, so the
mutation is on a throwaway and the orchestrator never sees the mock.
**Default chosen:** Keep `machines / stations / machineGroups` as plain
id-keyed dicts on `this`. ChildRouter `onRegister` handlers populate them
on real registration; tests can still assign directly. Registry remains
the upstream source of truth (handshake still flows through it), but the
flat dicts are also writable. Revisit if other domains can adopt
`declareChildGetter` cleanly without test rewrites.
**Decision needed by:** Phase 10 (test-suite refactor).
---
## 2026-05-10 — `reactor` test runtime is mathjs-bound (pre-existing)
**Context:** P1.12 sanity gate. Every reactor test file takes ~13 s because `require('mathjs')` alone is ~12.5 s on this machine (mathjs is huge and loads its full operator set eagerly). With basic tests parallelised by `node --test`, each subprocess pays the cost. A 90 s outer timeout doesn't accommodate the parallel load.
**Action:** Pre-existing — not caused by Phase 1. Two options to track for Phase 5/6 cleanup:
1. Switch to a tree-shaken mathjs subset (only ops actually used).
2. Cache the mathjs instance at module top and pass into Reactor classes.
Tracked; not blocking the refactor.
---
## 2026-05-10 — measurement `isStable` tautology (pre-existing bug) — RESOLVED
**Resolution (2026-05-11):** Replaced the tautological `stdDev < stdDev*2`
check with a config-driven absolute threshold. New schema field
`calibration.stabilityThreshold` (number, ≥ 0, default `0.01` in
scaling-units) added to `generalFunctions/src/configs/measurement.json` so
all callers see it. `Calibrator.isStable()` now returns `true` when
`stdDev === 0` or `stdDev <= threshold`, falling back to the default when
the config slot is missing or non-numeric. The two BUG-PRESERVED calibrator
tests were rewritten — high-variance buffers now correctly report unstable
under the default and only flip to stable when an explicit relaxed
threshold is supplied. Added edge tests for the relaxed-threshold path,
constant-buffer-with-zero-threshold path, just-above-threshold path, and
missing-config fallback. `nodeClass.buildDomainConfig` and
`measurement.html` (defaults + form field + oneditsave) propagate the UI
value through to the domain. 100/100 measurement tests pass; 70/70
generalFunctions basic tests pass.
### Original entry below
## 2026-05-10 — measurement `isStable` tautology (pre-existing bug) (history)
**Context:** P3.4. The existing `isStable` in `measurement/src/specificClass.js` does:
```js
stableThreshold = stdDev * marginFactor; // marginFactor = 2
return { isStable: (stdDev < stableThreshold || stdDev == 0), stdDev };
```
`stdDev < stdDev * 2` is always true for `stdDev > 0`, and the OR catches the
zero case. So `isStable` returns `true` for every non-empty buffer. That makes
`calibrate()` essentially un-gateable (it only aborts when there are < 2
samples) and `evaluateRepeatability()` happily reports a huge stdDev as
"repeatability".
**Action:** Preserved verbatim by the new `Calibrator` (additive). A
behavioural fix needs an external reference (config-driven absolute
threshold, or % of full scale). Two BUG-PRESERVED tests pin the current
shape so a follow-up behavioural PR is intentional.
**Decision needed by:** Phase 10 (test-suite refactor) — naturally
adjacent to the calibration test cleanup.
---
## 2026-05-10 — `commandRegistry` payload schema needs `'none'`/`'void'` type — RESOLVED
**Resolution (2026-05-11):** Added `'none'` to the payloadSchema.type
enum. Handler still fires; logs `warn` if `msg.payload` is non-empty
(catches accidental object payloads on trigger topics). Also added an
optional `description` field per descriptor for wikiGen consumption.
23/23 commandRegistry tests pass; CONTRACTS.md §4 updated.
### Original entry below
## 2026-05-10 — commandRegistry payload schema needs 'none'/'void' type (history)
**Context:** P3.7+P3.8. Trigger-only commands (`set.simulator`,
`set.outlier-detection`, `cmd.calibrate`) ignore their payload. The
current registry's `payloadSchema.type` enum is
`'string'|'number'|'object'|'boolean'|'any'`. Trigger commands fall
into `'any'`, which is too permissive (an object slipped past would
not be flagged).
**Default chosen:** Use `'any'` for now. Add `'none'`/`'void'` to the
registry schema enum during Phase 7 (topic-name standardisation).
**Decision needed by:** Phase 7.
---
## 2026-05-10 — measurement legacy `'mAbs'` emitter event — RESOLVED
**Resolution (2026-05-11):** Removed the on-emit subscription that
bridged the analog channel's `<type>.measured.<position>` event to
`source.emitter` as `'mAbs'`. No production consumer was reading it.
96/96 measurement tests pass.
### Original entry below
## 2026-05-10 — measurement legacy 'mAbs' emitter event (history)
**Context:** P3.7+P3.8 CONTRACT.md noted that the existing `Measurement`
class emits `'mAbs'` on `source.emitter` whenever the analog output
updates. This was a pre-MeasurementContainer broadcast. It's still
fired but no production consumer reads it (per the existing comment
"DEPRECATED: Use measurements container instead").
**Default chosen:** Keep firing it through Phase 3 (post-integration).
Remove in Phase 7 alongside the topic-rename cleanup, or in Phase 8.5
deprecated-path cleanup.
**Update (P3.2+P3.5+P3.6+P3.9, 2026-05-10):** Re-emitted from the analog
specificClass by subscribing to the MeasurementContainer's
`<type>.measured.<position>` event (position lowercased to match
container normalisation). Channel itself stays event-name-agnostic.
**Decision needed by:** Phase 7 / Phase 8.5.
---
## 2026-05-10 — measurement legacy property mirrors
**Context:** P3.2+P3.5+P3.6+P3.9. The analog pipeline now lives inside
`Channel`. The pre-refactor test suite pins many fields directly on the
Measurement instance: `outputAbs`, `outputPercent`, `storedValues`,
`totalMinValue`, `totalMaxValue`, `totalMinSmooth`, `totalMaxSmooth`,
`inputRange`, `processRange`. Some tests *write* `m.storedValues` /
`m.totalMinValue` directly before calling pipeline helpers.
**Default chosen:** Install getter/setter mirrors on the Measurement
instance (`_installChannelMirrors`) that forward read/write through
`this.analogChannel`. Storage stays single-sourced in Channel, the
legacy public surface stays writable, no test rewrites required.
**Decision needed by:** Phase 10 (test-suite refactor) — replace these
with direct `m.analogChannel.xxx` access in tests, then drop the mirrors.
---
## 2026-05-10 — measurement `handleScaling` mutates config.scaling
**Context:** P3.2+P3.5+P3.6+P3.9. Channel's `_applyScaling` resets its
*own* `scaling.inputMin/inputMax` to `[0,1]` when the input range
collapses (`inputMax <= inputMin`). The pre-refactor `handleScaling`
mutated `this.config.scaling.inputMin/inputMax` instead, and a basic
test pins that contract.
**Default chosen:** The Measurement-level `handleScaling` delegate
copies Channel's reset back to `config.scaling` after the call so the
visible behaviour is preserved. Long-term, the test should read the
new state from `m.analogChannel.scaling` and we drop the mirror write.
**Decision needed by:** Phase 10 (test-suite refactor).
---
## 2026-05-10 — measurement nodeClass routing tests pin private wiring
**Context:** P3.2+P3.5+P3.6+P3.9. The basic `nodeclass-routing` and
edge `invalid-payload` tests instantiated `NodeClass.prototype` and
called `_attachInputHandler()` / `_registerChild()` directly. The
BaseNodeAdapter superclass renamed these to `_attachInputHandler`
(unchanged) and `_scheduleRegistration` (was `_registerChild`), and
dispatch now goes through `this._commands` built in the constructor.
**Action:** Adjusted the two tests in-place to seed `inst._commands`
via `createRegistry(commands, …)` and to call `_scheduleRegistration`
instead of `_registerChild`. The on-the-wire payload topic also moved
from `'registerChild'``'child.register'` (BaseNodeAdapter
convention); the test assertion was updated accordingly.
**Decision needed by:** Phase 10 — these tests should be rewritten to
drive a full nodeClass through `new nodeClass(uiConfig, RED, node,
'measurement')` rather than poking at private members.
---
## 2026-05-10 — MGC `calcAbsoluteTotals` implicit pressure-key coupling
**Context:** P4.1/4.2 extracted `totals/totalsCalculator.js` preserving
original behaviour. `calcAbsoluteTotals` iterates
`machine.predictFlow.inputCurve` and re-uses the same pressure key to
index `machine.predictPower.inputCurve[pressure]`. If the two curves were
sampled at different pressures (legitimate when power was extrapolated
separately from flow), the lookup is `undefined` and the call throws.
**Question:** should the totals calculator defensively skip mismatched
pressure keys, or should the invariant "flow + power curves share pressure
keys" be enforced upstream in rotatingMachine's curveLoader/normalizer?
**Default chosen:** preserved the implicit coupling — no behavioural change.
**Decision needed by:** P5 (rotatingMachine refactor) — curveLoader/Normalizer
is the natural place to enforce or document the pairing.
---
## 2026-05-10 — MGC concern modules use legacy unitPolicy object shape — RESOLVED
**Resolution (2026-05-11):** UnitPolicy.declare() now exposes
canonical/output/curve as BOTH callable methods AND frozen property
bags. Both shapes work: `policy.canonical('flow')` and `policy.canonical.flow`.
Dropped the `_unitView`/`unitPolicyView` workaround in both MGC
(specificClass 336→318) and rotatingMachine (400→377). CONTRACTS.md §6
updated. All platform tests stay green.
### Original entry below
## 2026-05-10 — MGC concern modules use legacy unitPolicy object shape (history)
**Context:** The MGC concern modules (groupOps/groupOperatingPoint,
totals/totalsCalculator, combinatorics/pumpCombinations, control/strategies)
extracted in Wave 1 read units as `ctx.unitPolicy.canonical.flow` — the old
plain-object shape carried on the pre-refactor specificClass. `BaseDomain`
now wires `this.unitPolicy` to a `UnitPolicy` instance whose canonical/output
are methods (`canonical('flow')`).
**Question:** Should the concern modules be updated to call the methods, or
should we keep the object-shaped view long-term?
**Default chosen:** specificClass builds a frozen `this._unitView` ({
canonical: {flow,pressure,power,temperature}, output: {…} }) and passes it
to the modules. Two surface shapes live side-by-side in the same node.
**Decision needed by:** P5 (rotatingMachine) — the same concern-module
shape will likely repeat. Pick one and migrate before the second node lands
on the pattern.
---
## 2026-05-10 — rotatingMachine Machine constructor takes 3 positional args
**Context:** P5.9/5.10/5.12. The pre-refactor Machine class accepted
`(machineConfig, stateConfig, errorMetricsConfig)`. BaseDomain's
constructor only knows about the first slot. The whole test suite (~30
files) constructs Machines directly with two positional args, and
BaseNodeAdapter instantiates DomainClass with just `this.config`.
**Question:** Where do the extra positional configs travel? Schema
validation in `configUtils.initConfig` strips unknown top-level keys, so
embedding them in machineConfig doesn't work. Subclass-overriding
constructor before super() is blocked by ES6's pre-super `this` rule.
**Default chosen:** Static stash on the class itself
(`Machine._pendingExtras`) assigned just before `super()` (or by
nodeClass.buildDomainConfig before BaseNodeAdapter instantiates the
domain). `configure()` reads + clears it. Single-threaded JS makes the
hand-off race-free.
**Decision needed by:** P10 (test-suite refactor) — when tests get
rewritten to use the BaseNodeAdapter-built domain, drop the multi-arg
constructor and fold stateConfig/errorMetricsConfig into machineConfig
slices.
---
## 2026-05-10 — rotatingMachine private nodeClass tests (4 files adjusted) — RESOLVED 2026-05-11
**Resolution (2026-05-11, P10):** Rewritten to drive only the public
BaseNodeAdapter surface. Three test files were rewritten:
- `test/basic/nodeClass-config.basic.test.js``buildAdapter(ui)`
constructs a full `new nodeClass(ui, RED, node, 'rotatingMachine')`
and asserts against `inst.source.config.*` (the validated merged
shape from `configManager.buildConfig`) and observable state on the
domain. No `Object.create(NodeClass.prototype)` or direct
`buildDomainConfig` calls — `Machine._pendingExtras` is no longer
touched by tests.
- `test/edge/nodeClass-routing.edge.test.js` — dispatch is driven via
`node._handlers.input(msg, send, done)` (the handler the base
installs on `node.on('input', …)`). Assertions are against
`node._sent`, instrumented `source.handleInput` call lists, and the
`childRegistrationUtils.registerChild` side-effect. Status-badge
pressure-warn case calls `inst.source.getStatusBadge()` directly,
not `io.buildStatusBadge(source)`.
- `test/edge/error-paths.edge.test.js` — the error-on-status-badge
test now builds the adapter, forces `state.getCurrentState` to
throw, and asserts via `inst.source.getStatusBadge()`. The three
pre-existing Machine-direct-construction tests were untouched
(they never poked nodeClass privates).
Teardown of the always-on status-poll timer goes through the public
`node._handlers.close(() => {})` path (the BaseNodeAdapter close
handler) so the rewritten tests don't reach into `inst._statusUpdater`.
Verification: `npm test` reports 202 pass / 0 fail (up from 196 — net
+6 tests across the three rewritten files). No `inst._<private>`,
`_attachInputHandler`, `_commands = createRegistry`, `_pendingExtras`,
or `io.buildStatusBadge` references remain in the rewritten files.
### Original entry below
## 2026-05-10 — rotatingMachine private nodeClass tests (4 files adjusted) (history)
**Context:** P5.9/5.10/5.12. Four pre-refactor tests pinned private
nodeClass methods: `_loadConfig`, `_setupSpecificClass`,
`_updateNodeStatus`, and the inline `_attachInputHandler` switch. After
the BaseNodeAdapter migration those private methods are gone — config
build lives in `buildDomainConfig()`, dispatch in `commands/`, status
badge in `source.getStatusBadge()`.
**Default chosen:** Updated the four test files to drive the new
surface: `buildDomainConfig` returns the per-node slice (and stamps
`Machine._pendingExtras`); routing tests seed `inst._commands` via
`createRegistry(commands, …)` and assert through that path; status-badge
tests call `io.buildStatusBadge(source)` directly.
**Decision needed by:** P10 — these tests still poke private members
(`_commands`, `_attachInputHandler`). The right shape is constructing
a full `new nodeClass(uiConfig, RED, node, 'rotatingMachine')` and
asserting against the resulting `node._sent` / `node._statuses`.
---
## 2026-05-10 — monster schema strips command-line constraint keys — RESOLVED 2026-05-11
**Context:** P6.3. The monster JSON schema in `generalFunctions/src/configs/monster.json` defines `samplingtime`, `minVolume`, `maxWeight` and others under `constraints`, but NOT `nominalFlowMin`, `flowMax`, `maxRainRef`, `minSampleIntervalSec`. `configUtils.initConfig` strips these unknown keys with a `Unknown key … Removing it.` warning. The legacy code read them anyway — `Number.isFinite(undefined)` returns false, so guards naturally route into invalid-bounds territory and tests pass via the undefined cascade.
**Resolution (2026-05-11, B1.4):** Added the four missing fields (`nominalFlowMin`, `flowMax`, `maxRainRef`, `minSampleIntervalSec`) to the `constraints` section of `generalFunctions/src/configs/monster.json` with sensible defaults (0/0/10/60). The unknown-key warning disappears and user-supplied values now propagate through validation to the domain, restoring the documented sampling behaviour.
---
## 2026-05-10 — monster sampling-guards cooldown test fails on development (pre-existing) — RESOLVED 2026-05-11
**Context:** P6.3 baseline run. `test/edge/sampling-guards.edge.test.js` "cooldown guard blocks pulses when flow implies oversampling" already fails on `development` BEFORE the refactor (`assert.ok(monster.sumPuls > 0)` — sumPuls stays at 0 across 80 ticks). The legacy in-file equivalent in `test/monster.test.js` (Mocha-style wrapper, not picked up by `node:test`) appears to have passed in an earlier era.
**Resolution (2026-05-11, B1.4):** Root cause was the schema-stripping issue documented immediately above — `nominalFlowMin`/`flowMax`/`minSampleIntervalSec` were stripped by `configUtils.initConfig` before reaching the domain, so `validateFlowBounds` saw NaN/NaN and routed every `i_start` into the invalid-bounds early return, which prevented `_beginRun` from ever firing. With the four constraint keys now declared in `monster.json`, the test config propagates intact: `_beginRun` runs, the m3PerTick integrator accumulates ~0.056 m3/tick, `temp_pulse` crosses 1 at tick ~18, the first pulse fires, subsequent pulses are correctly blocked by the 60 s cooldown, and `sumPuls > 0` / `missedSamples > 0` / `bucketVol > 0` / `getSampleCooldownMs() > 0` all hold. Added a guard-site comment in `parameters/parameters.js#validateFlowBounds` pointing back at the schema contract. All 10/10 monster tests green.
---
## 2026-05-10 — MGC handleInput retained inline latest-wins (not DemandDispatcher) — RESOLVED
**Resolution (2026-05-11):** Extended `LatestWinsGate` with
`fireAndWait(value)` that returns a per-fire settlement promise. A
parked call superseded by a later fire resolves with the frozen
sentinel `LatestWinsGate.SUPERSEDED = { superseded: true }` (not a
reject) so callers branch on a value without try/catch. Dispatch
errors still resolve the promise (with `undefined`) and surface via
`gate.lastError`.
MGC's `handleInput` now delegates to `DemandDispatcher.fireAndWait`;
the inline `_dispatchInFlight` + `_delayedCall` block is gone.
`turnOffAllMachines` calls `cancelPending()` on the dispatcher
instead of zeroing `_delayedCall`. `LatestWinsGate.js` 75 → 116 lines
(under the 150 cap). MGC `specificClass.js` net 14 lines.
The `turnoff-deadlock` test that pinned `_delayedCall` was rewritten
to assert against the parked `fireAndWait` resolving as superseded.
Other awaiting tests (`ncog-distribution`, `idle-startup-deadlock`,
`demand-cycle-walkthrough`) needed no change since `fireAndWait`
preserves the "await waits for the call's dispatch" shape for
non-superseded calls. All 77/77 MGC tests pass; 12/12 LatestWinsGate
basic tests pass.
### Original entry below
## 2026-05-10 — MGC handleInput retained inline latest-wins (not DemandDispatcher) (history)
**Context:** Wave 1 added `src/dispatch/demandDispatcher.js` wrapping
`LatestWinsGate`. Tests (`turnoff-deadlock`, `idle-startup-deadlock`,
`ncog-distribution`) call `await mgc.handleInput(...)` and rely on the
awaited promise resolving after the dispatch completes; they also pin the
exact `_delayedCall` field. `LatestWinsGate.fire(value)` returns void.
**Question:** Should `handleInput` switch to the gate (changing the test
contract), or stay inline (keeping the awaitable shape)?
**Default chosen:** kept the inline `_dispatchInFlight + _delayedCall`
gate verbatim. `DemandDispatcher` remains exported but unused by the
orchestrator for now — its basic test still passes since it tests the
wrapper in isolation.
**Decision needed by:** P7 (topic-name standardisation) or P10 (test-suite
refactor) — adopting the gate requires either rewriting tests to drain
the gate or changing the gate to return a settle promise.
---
## 2026-05-10 — valveGroupControl registerChild overload (skipped ChildRouter)
**Context:** P6.2. `ValveGroupControl.registerChild(child, posOrType)` is
called from two distinct paths: (a) `childRegistrationUtils.registerChild`
delegates with the canonical softwareType as 2nd arg, and (b) tests + a
few in-process callers invoke it directly passing **either** a position
(`'atEquipment'`) **or** a softwareType (`'machine'`). The legacy code
disambiguated via a `KNOWN_POSITIONS` set lookup and returned a boolean
indicating registration success (used by `flow-distribution` regression
test to assert a non-valve payload yields `false`).
**Default chosen:** kept the legacy resolver in the domain — override
`this.registerChild` inside `configure()` so the boolean return + dual
semantics survive. `ChildRouter` is **not** used for VGC (no `onRegister`
/ `onMeasurement` handlers declared). Source-side event wiring still
lives in `src/sources/fluidContract.js` (raw emitter `.on` on each
`SOURCE_FLOW_EVENTS` name) because the source family includes mixed-case
event names (`flow.predicted.atEquipment` and lowercase variants both fire).
**RESOLVED 2026-05-11 (B2.2):** Migrated to `ChildRouter.onRegister`.
`configure()` now declares `router.onRegister('valve', …)` plus one
`onRegister(…)` per canonical source softwareType (`machine`,
`machinegroup`, `pumpingstation`, `valvegroupcontrol`); the custom
overloaded `registerChild` and `_resolveRegistrationContext` resolver
were removed and BaseDomain's default `registerChild` (which delegates
straight to `router.dispatchRegister`) is back in charge. Position now
comes from `child.positionVsParent` (set by `childRegistrationUtils`) or
`child.config.functionality.positionVsParent`, falling back to
`atEquipment`. The boolean-return regression test was rewritten to assert
via the side-effect (`Object.keys(group.valves).length === 0`) for the
non-valve-like payload, and a new test pins router dispatch through
`childRegistrationUtils.registerChild(valve, 'upstream')` honouring the
config's `positionVsParent`. Source-side measurement-event wiring still
lives in `sources/fluidContract.bindSource` — the mixed-case
`flow.{measured,predicted}.atEquipment` listeners remain raw `.on`
attachments until topic casing standardises platform-wide. specificClass
shrank 270→255 lines; tests 9→10, all green.
---
## 2026-05-10 — valveGroupControl `set.position` placeholder
**Context:** P6.2 command registry. Task spec required canonical name
`setpoint → set.position`, but VGC's pre-refactor input switch did not
implement a `setpoint` topic — valve position is driven by `data.totalFlow`
re-distribution, not direct per-valve setpoints. Registering `set.position`
with an empty handler keeps the canonical name reserved without breaking
the contract surface.
**Default chosen:** registered `set.position` with a no-op handler that
debug-logs the payload. `setpoint` listed as alias so a legacy emitter
gets the same no-op path.
**Decision needed by:** P7 — decide whether VGC actually needs a
per-valve setpoint topic (probably yes when virtualControl mode lands).
At that point promote the handler from no-op to real dispatch.
---
## 2026-05-10 — reactor private nodeClass tests (8 files adjusted) — RESOLVED 2026-05-11
**Resolution (2026-05-11, P10):** Rewrote all 8 reactor test files to drive
only the public BaseNodeAdapter surface — `new nodeClass(uiConfig, RED, node,
'reactor')`, then fire msgs through `node.handlers.input(...)` and observe
via `node.sends` / `node.statuses` / `inst.source.engine.*` /
`inst.source.tick(dt)`. The pre-refactor private methods (`_loadConfig`,
`_setupClass`, `_attachInputHandler`, `_updateNodeStatus`, `_registerChild`,
`_tick`, `_startTickLoop`, `_attachCloseHandler`) are no longer referenced.
`buildDomainConfig` is invoked on the real constructed instance (it's the
documented override hook in CONTRACTS.md §2). `_emitOutputs` is called on
the real instance for the tick-loop assertions (it's the reactor-specific
override for Port-0 emission, also documented). The "scheduled registration"
test now waits ~130 ms for the BaseNodeAdapter setTimeout to fire and
inspects the resulting Port-2 send. 46/46 reactor tests pass (was 39 pre-
rewrite — net +7 tests added covering canonical topic acceptance, alias
acceptance, child-with-no-source guard, empty-string reactor_type, missing-
topic guard, and the new Reactor.tick(dt) wrapper introduced in B2.3).
### Original entry below
## 2026-05-10 — reactor private nodeClass tests (8 files adjusted) (history)
**Context:** P6.5. Eight pre-refactor reactor tests pinned private
nodeClass methods (`_loadConfig`, `_setupClass`, `_registerChild`, inline
`_attachInputHandler` switch, `_tick`, `_startTickLoop`, `_attachCloseHandler`).
After the BaseNodeAdapter migration those private methods are gone — config
build lives in `buildDomainConfig()`, dispatch in `commands/`, registration in
`_scheduleRegistration` (renamed), and the periodic emit lives in
`_emitOutputs` (overridden so the Fluent / GridProfile Port-0 contract is
preserved — delta-compressed payloads can't carry the C-vector).
**Default chosen:** Adjusted in place: `test/basic/constructor.basic.test.js`,
`test/basic/input-routing.basic.test.js`, `test/basic/register-child.basic.test.js`,
`test/basic/speedup-factor.basic.test.js`, `test/edge/invalid-topic.edge.test.js`,
`test/edge/missing-child.edge.test.js`, `test/edge/invalid-reactor-type.edge.test.js`,
`test/integration/tick-loop.integration.test.js`. Routing tests seed
`inst._commands` via `createRegistry(commands, …)`; topic moved from
`'registerChild'``'child.register'`. The "unknown reactor_type throws"
edge case became "falls back to CSTR" — the legacy bottom-of-switch already
fell back to CSTR; only the surface changed (warning channel now via
domain logger, not `node.warn`).
**Decision needed by:** Phase 10 — same shape as the rotatingMachine /
measurement adjustments. The right fix is to drive a full
`new nodeClass(...)` and assert against `node._sent` / `node._statuses`
instead of poking private members.
---
## 2026-05-10 — reactor schema enum lowercases `reactor_type`
**Context:** P6.5. The reactor JSON schema defines `reactor.reactor_type`
as `type: 'enum'` with values `'CSTR'` / `'PFR'`. The shared enum validator
lowercases the user-supplied value before comparing, so an inbound `'PFR'`
ends up stored as `'pfr'` in the validated config. The pre-refactor
nodeClass switched on the raw uiConfig value and never saw the lowercased
form; after the BaseDomain migration the wrapper reads the validated
config and would always fall back to CSTR.
**Default chosen:** `Reactor._buildEngine` upper-cases the value before
switching. The schema is left intact so external Phase-7 enum-casing
work can decide whether to preserve original casing globally.
**Decision needed by:** Phase 7 (topic-name + schema standardisation) —
once enums standardise on a canonical casing, drop the `.toUpperCase()`
guard here.
---
## 2026-05-21 — Palette swatches switched to domain-hue (resolved)
**Context:** Node-RED sidebar showed every EVOLV node in a shade of blue because palette colours were set from the S88 level (Area / ProcessCell / Unit / Equipment / ControlModule). Operators reported difficulty picking the right node by eye.
**Decision:** Split the colour systems. The **palette swatch** in each `<node>.html` (`RED.nodes.registerType({ color })`) becomes domain-hue per node; family hue = function (rotating = orange, valves = teal, biology = green/olive, sampling = violet, sensor = amber, infrastructure = slate, aeration = sky blue). Within a family, darker = higher S88 (e.g. RM → MGC → pumpingStation darkens the orange). **Editor-group rectangles** in `flow.json` (`style.fill`) continue to follow S88 level — the hierarchy story stays visible in flow diagrams. Two systems, two purposes.
**Final palette table:** see `.claude/rules/node-red-flow-layout.md` §10.0.
**Why split rather than rework S88:** S88 hierarchy is genuinely useful for flow-diagram readability (it's the whole point of group boxes). Throwing it out to fix palette identifiability would have cost the hierarchy signal. Two systems = both problems solved.
**Files touched (palette):** the 12 `nodes/<n>/<n>.html` files, one line each.
**Files touched (docs):** `CLAUDE.md` (L52 split into palette + group lines); `.claude/rules/node-red-flow-layout.md` (new §10.0); `.claude/refactor/MODULE_SPLIT.md` (per-node headers annotated with both hexes); `.claude/refactor/WIKI_HOME_TEMPLATE.md` + `WIKI_TEMPLATE.md` (clarifying sentence — Mermaid classDefs are hierarchy, not palette); this entry.
**Unchanged on purpose:** 32 submodule wiki/CLAUDE.md files that name S88 hexes — they describe hierarchy diagrams or editor-group boxes, both of which still use S88. Spot-checked `rotatingMachine` + `reactor` wikis to confirm.
**Open follow-ups:**
- If `coresync` ends up classified as a process-data node rather than infrastructure, repick a non-slate hue.
- Consider a `tools/palette-lint/` check that diffs declared palette hexes vs. this table to catch future drift (low priority).

View File

@@ -0,0 +1,42 @@
# Platform Standards (post-refactor)
> **Front door:** start at [`CONTRACTS.md`](../../CONTRACTS.md) at the EVOLV root. It maps every contract, rule, and standard in the stack.
This directory holds the **live standards** that govern how every EVOLV node
is shaped. They are the source of truth for any human or agent making a
change. The platform refactor that produced them landed on `development` in
May 2026; the plan artifacts that drove it are in [`Archive/`](./Archive/)
for historical reference only.
## Live standards (read these before changing code)
| File | Purpose |
|---|---|
| [`CONTRACTS.md`](./CONTRACTS.md) | The exact API shapes — `BaseNodeAdapter`, `BaseDomain`, commands registry, `ChildRouter`, `UnitPolicy`, `statusBadge`, `HealthStatus`, `LatestWinsGate`, output ports, topic naming. |
| [`CONVENTIONS.md`](./CONVENTIONS.md) | Code style, file/function size, comments, naming, imports, tests. |
| [`MODULE_SPLIT.md`](./MODULE_SPLIT.md) | Per-node `src/` concern layout + the generic node template. |
| [`WIKI_TEMPLATE.md`](./WIKI_TEMPLATE.md) | The 14-section visual-first template every per-node wiki uses. |
| [`WIKI_HOME_TEMPLATE.md`](./WIKI_HOME_TEMPLATE.md) | The shape of each per-node `wiki/Home.md`. |
| [`OPEN_QUESTIONS.md`](./OPEN_QUESTIONS.md) | Live decisions log — append-only. Most entries are resolved; unresolved entries are what's actually in play. |
## How to use them
1. **Reading code in a node.** The node's `CONTRACT.md` and `src/commands/index.js`
are the per-node contract; the files above are the platform contract those
per-node files implement.
2. **Writing new code in a node.** Match `MODULE_SPLIT.md` for layout, `CONVENTIONS.md`
for style, `CONTRACTS.md` for the base-class API surface, and add to
`OPEN_QUESTIONS.md` if you discover something unclear rather than inventing
a decision.
3. **Touching `generalFunctions`.** Any new export needs a `CONTRACT.md` entry
in `nodes/generalFunctions/CONTRACT.md` and, if it introduces a new platform
shape, a section in `CONTRACTS.md` here.
4. **Updating a wiki page.** Generated sections (topic-contract, data-model)
are produced by `npm run wiki:all` per submodule — never hand-edit between
the `BEGIN AUTOGEN` / `END AUTOGEN` markers.
## Archive
[`Archive/`](./Archive/) holds the refactor *plan* (now done): `CONTINUE_HERE.md`,
`TASKS.md`. They describe how the platform got from pre-refactor to the
current shape. They are **not** authoritative for new work — the files above are.

View File

@@ -0,0 +1,141 @@
# Platform wiki home — `Home.md` template
The landing page for the EVOLV Gitea wiki. Same visual-first rules as `WIKI_TEMPLATE.md`: diagrams lead, tables annotate, ≤ 60 words per paragraph.
`Home.md` answers three questions for a first-time visitor:
1. **What is this platform?** One paragraph.
2. **What nodes exist and how do they relate?** One platform-wide Mermaid graph + a navigation table.
3. **Where do I find the conventions?** A pointer table to the rule files in `.claude/`.
Plus a live refactor-status table so a returning visitor knows what changed since they last looked.
## Template — copy the block below as the seed for `Home.md`
```
<!-- BEGIN TEMPLATE — Home.md -->
# EVOLV — Wastewater treatment plant automation
> **Reflects code as of `<git short hash>` · regenerated `<YYYY-MM-DD>` via `npm run wiki:home`**
EVOLV is a Node-RED node library for wastewater plant automation, developed by the R&D team at Waterschap Brabantse Delta. Nodes follow the ISA-88 (S88) batch control standard. The library exposes 11 active nodes spanning four S88 levels: from Process Cell down to Control Module, plus one utility node for dashboard integration.
## Platform overview
~~~mermaid
flowchart TB
subgraph PC["Process Cell"]
ps[pumpingStation]:::pc
end
subgraph UN["Unit"]
mgc[machineGroupControl]:::unit
vgc[valveGroupControl]:::unit
reactor[reactor]:::unit
settler[settler]:::unit
monster[monster]:::unit
end
subgraph EM["Equipment"]
rm[rotatingMachine]:::equip
v[valve]:::equip
diff[diffuser]:::equip
end
subgraph CM["Control Module"]
meas[measurement]:::ctrl
end
subgraph UT["Utility"]
dash[dashboardAPI]:::neutral
end
ps --> mgc
ps --> vgc
mgc --> rm
vgc --> v
reactor --> diff
meas -.data.-> rm
meas -.data.-> v
meas -.data.-> reactor
meas -.data.-> settler
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
classDef neutral fill:#dddddd,color:#000
~~~
S88 colours (used here for **hierarchy visualization only** — distinct from the node-palette swatches in the Node-RED sidebar, which are domain-hue; see `.claude/rules/node-red-flow-layout.md` §10.0): Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Solid arrow = parent/child relationship. Dashed arrow = data flow (`measurement` feeds many node types).
## Live nodes
| S88 | Node | One-liner | Wiki |
|---|---|---|---|
| 🟦 Process Cell | **pumpingStation** | Manages a wet-well basin, hands demand to one or more group controllers. | [→](pumpingStation) |
| 🔷 Unit | **machineGroupControl** | Load-sharing across a group of rotatingMachines. | [→](machineGroupControl) |
| 🔷 Unit | **valveGroupControl** | Coordinated valve control across a group of valves. | [→](valveGroupControl) |
| 🔷 Unit | **reactor** | Bioreactor — couples diffuser + measurements + kinetics. | [→](reactor) |
| 🔷 Unit | **settler** | Settler / clarifier modelling. | [→](settler) |
| 🔷 Unit | **monster** | Composite-sample sensor surrogate. | [→](monster) |
| 🟦 Equipment | **rotatingMachine** | Single pump / compressor — curves, state machine, prediction. | [→](rotatingMachine) |
| 🟦 Equipment | **valve** | Single valve actuator with FSM. | [→](valve) |
| 🟦 Equipment | **diffuser** | Aeration diffuser, gas-side modelling. | [→](diffuser) |
| 🔹 Control Module | **measurement** | Sensor signal-conditioning, scaling, calibration. | [→](measurement) |
| ⚪ Utility | **dashboardAPI** | Bridge between FlowFuse dashboard widgets and EVOLV. | [→](dashboardAPI) |
## Standards & conventions
| Document | What it covers | Where |
|---|---|---|
| Node architecture (3-tier) | entry → nodeClass → specificClass | `.claude/rules/node-architecture.md` |
| Flow layout (Node-RED tabs) | Tab boundaries, lanes, S88 colours, link channels | `.claude/rules/node-red-flow-layout.md` |
| Topic naming (`set.` / `cmd.` / `evt.`) | Canonical input + output topics | `.claude/refactor/CONTRACTS.md` §1 |
| Wiki page shape | Per-node page template | `.claude/refactor/WIKI_TEMPLATE.md` |
| Wiki home shape | This page's template | `.claude/refactor/WIKI_HOME_TEMPLATE.md` |
| generalFunctions stability rules | What's safe to change | `.claude/rules/general-functions.md` |
## Refactor status
| Tier | What | Status |
|---|---|---|
| 1 | Add infra in generalFunctions (additive only) | ✅ done |
| 2 | Pilot: pumpingStation | ✅ done |
| 3 | Convert measurement, MGC, rotatingMachine | ✅ done |
| 4 | Convert valve, VGC, reactor, settler, monster, diffuser | ✅ done |
| 4* | dashboardAPI | ⏸️ out of scope (no `generalFunctions` dep) |
| 5 | Canonical topic names + alias deprecation | 🟡 partial |
| 6 | development → main promotion | ⏳ pending Docker E2E |
| 7 | Wiki refactor (this work) | 🟡 in progress |
## Archive
Pre-refactor pages live under `Archive/`. See [Archive index](Archive).
<!-- END TEMPLATE -->
```
## Notes for the maintainer
- `npm run wiki:home` (not yet built) re-renders the platform Mermaid block if any node's `softwareType` registration changes. Until then, the diagram is hand-maintained.
- Refactor-status rows flip as tiers land. Anyone landing a tier updates the table in the same PR.
- The "Live nodes" table is hand-maintained but small — bulk changes happen only when a node is added or retired.
- The Mermaid graph above mirrors what's in `.claude/rules/node-red-flow-layout.md` §10.1 (lane convention). If the rule changes, mirror it here.
## Archive index — `Archive.md` template
A separate page that lists every archived page with its archival date and the era it describes.
```
<!-- BEGIN TEMPLATE — Archive.md -->
# Archive — pre-refactor wiki pages
Pages kept for historical reference. **Do not update them.** Corrections go on the current page; if you find a meaningful inaccuracy in the archived page, leave it and add a note to the *current* page explaining what changed.
| Page | Era | Archived on |
|---|---|---|
| [pumpingStation (pre-refactor)](Archive/pumpingStation-pre-refactor) | Pre-Tier-2 (May 2026) | 2026-05-11 |
| [rotatingMachine (pre-refactor)](Archive/rotatingMachine-pre-refactor) | Pre-Tier-3 (May 2026) | 2026-05-11 |
| ... | ... | ... |
Each archived page carries the standard banner at its top (see `WIKI_TEMPLATE.md` → Archive banner).
<!-- END TEMPLATE -->
```

View File

@@ -0,0 +1,349 @@
# Wiki page template — every node uses this shape
Canonical structure for every node's Gitea wiki landing page. **Visual-first**, scannable, ≤ 60 words per paragraph anywhere on the page.
## Why this shape
The platform has 12 nodes that all share the same architectural skeleton (BaseDomain + BaseNodeAdapter + ChildRouter + commands registry). The wiki should mirror that uniformity: a reader flips between nodes and finds the same 14 sections in the same order. Diagrams lead. Tables annotate. Prose only fills gaps.
## Picking a visual
The default is Mermaid (Gitea renders it natively). It's the right tool for graph-shaped things — neighbours, lifecycles, state machines, file maps. But Mermaid doesn't render data: when a section is about *what a curve looks like* or *what the predicted vs measured signal does over time*, use:
| Need | Tool | Where the artifact lives |
|---|---|---|
| Graph (nodes + edges, hierarchy, state) | Mermaid `flowchart` / `sequenceDiagram` / `stateDiagram-v2` | inline in the wiki page |
| XY data (pump curves, prediction trace, drift over time) | Generated PNG/SVG via a small `npm run wiki:plots` script | committed under `wiki/_partial-plots/<NodeName>/*.svg` |
| Table of facts / config / topics | Markdown table | inline |
| Screenshot (dashboard, editor form) | PNG ≤ 200 KB | `wiki/_partial-screenshots/<NodeName>/*.png` |
| ASCII layout (when Mermaid is overkill) | code block | inline |
Lead with the visual that serves the section. Don't gate it on "is this Mermaid".
## Section list
Sections 19 and 1114 are mandatory for every node. Section 10 (State chart) is mandatory for stateful nodes (`rotatingMachine`, `valve`, `pumpingStation`, …) and skipped for pure aggregators (`measurement`, `dashboardAPI`).
| # | Section | Visual lead | Auto-gen? |
|---|---|---|---|
| 0 | Header band (git hash + regen date) | — | yes |
| 1 | What this node is | — (single paragraph) | no |
| 2 | Position in the platform | Mermaid `flowchart LR` | no |
| 3 | Capability matrix | table | no |
| 4 | Code map | Mermaid `flowchart TB` w/ subgraphs | no |
| 5 | Topic contract | table | **yes** (`wiki:contract`) |
| 6 | Child registration | Mermaid + table | no |
| 7 | Lifecycle | Mermaid `sequenceDiagram` | no |
| 8 | Data model — `getOutput()` | table + concrete sample | **yes** (`wiki:datamodel`) |
| 9 | Configuration — form ↔ config | Mermaid `flowchart TB` | no |
| 10 | State chart (stateful only) | Mermaid `stateDiagram-v2` | no |
| 11 | Examples | table + screenshots | no |
| 12 | Debug recipes | table | no |
| 13 | When NOT to use this node | bullets | no |
| 14 | Known limitations | table | no |
## Template — copy the block below as the seed for each node's wiki
(The block uses standard markdown syntax. The outer fence below is for visual delimitation in this README only; when seeding a new wiki page, copy the *content* between the `BEGIN TEMPLATE` / `END TEMPLATE` markers verbatim.)
```
<!-- BEGIN TEMPLATE — wiki/<NodeName>.md -->
# <Node name>
> **Reflects code as of `<git short hash>` · regenerated `<YYYY-MM-DD>` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is
One paragraph, ≤ 60 words. Plain English. State the *role*, not the *implementation*.
> Example: "**rotatingMachine** models a single pump or compressor. It takes pressure measurements from upstream and downstream, predicts the resulting flow + power from supplier-provided characteristic curves, and drives a state machine for startup/shutdown sequences. Used as a child of `machineGroupControl` when grouped, or directly under a `pumpingStation`."
## 2. Position in the platform
~~~mermaid
flowchart LR
parent[machineGroupControl<br/>Unit]:::unit -->|set.demand| this[rotatingMachine<br/>Equipment]:::equip
this -->|evt.state-change| parent
sensor_up[measurement up]:::ctrl -->|data.pressure| this
sensor_dn[measurement down]:::ctrl -->|data.pressure| this
this -->|child.register| parent
classDef proc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
~~~
S88 colours are mandatory **inside hierarchy diagrams** (Mermaid `classDef`, flow.json group `style.fill`). They are NOT the node-palette swatch hexes shown in the Node-RED sidebar — those are domain-hue per node. Map (hierarchy use): Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md` (§10.0 for palette, §10.1 for groups/lanes).
## 3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| Predicts flow from pressure | ✅ | |
| Receives manual setpoint | ✅ | Topic `set.setpoint` |
| Auto-start on demand from parent | ✅ | |
| Self-calibrating | ❌ | Calibration is operator-triggered (`cmd.calibrate`) |
| Supports multi-parent registration | ⚠️ | Possible but not fully tested — see CONTRACT.md |
Cap at 10 rows. Longer inventories link out.
## 4. Code map
~~~mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass, commands"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["Machine.configure()<br/>declares ChildRouter rules"]
end
subgraph concerns["src/ concern modules"]
curves["curves/<br/>characteristic curve loader"]
prediction["prediction/<br/>flow + power predictor"]
drift["drift/<br/>prediction-vs-measured assessor"]
flow["flow/<br/>aggregation + smoothing"]
state["state/<br/>FSM transitions"]
io["io/<br/>output formatting helpers"]
display["display/<br/>status badge composition"]
end
nc --> sc
sc --> concerns
~~~
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `curves/` | Supplier characteristic curves, interpolation | Curve fitting, asset selection |
| `prediction/` | Flow + power predictors | Predicted output values |
| `drift/` | Quality of prediction vs measurement | Health status / alarms |
| `flow/` | Aggregation, smoothing | Flow reporting |
| `state/` | FSM (off → idle → operational → …) | Startup / shutdown behaviour |
Update this section when you rename or split a directory.
## 5. Topic contract
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
The **Unit** column reflects the descriptor's `units: { measure, default }` declaration, rendered as `<measure> (default <unit>)`. Topics without a `units` field (non-quantity payloads — mode strings, child ids, sequence triggers) show `—`. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs. The **Effect** column is sourced from the descriptor's `description` field; topics without one fall back to a generic per-prefix sentence.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `set.mode` | `setMode` | `string` (`auto`\|`manual`\|`maintenance`) | — | Switches operating mode. |
| `set.demand` | `Qd` | `number` | `volumeFlowRate` (default `m3/h`) | Sets the manual demand setpoint. |
| `cmd.startup` | `execSequence` (with `payload.action='startup'`) | `{source: string}` | — | Triggers startup sequence. |
<!-- END AUTOGEN: topic-contract -->
## 6. Child registration
What children this node accepts and what it does with each event the child can emit. Mirrors the `ChildRouter` declarations in `specificClass.js` → `configure()`.
~~~mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
m_up["measurement<br/>type=pressure<br/>position=upstream"]:::ctrl
m_dn["measurement<br/>type=pressure<br/>position=downstream"]:::ctrl
end
m_up -->|data.pressure| handler1[pressure handler<br/>updates measurements/upstream]
m_dn -->|data.pressure| handler2[pressure handler<br/>updates measurements/downstream]
handler1 --> recompute[prediction.recompute]
handler2 --> recompute
recompute --> emit[emitter.emit 'output-changed']
classDef ctrl fill:#a9daee,color:#000
~~~
| softwareType | filter | wired to | side-effect |
|---|---|---|---|
| `measurement` | `type=pressure, position=upstream` | `pressureHandlers.onUpstream` | prediction recomputes |
| `measurement` | `type=pressure, position=downstream` | `pressureHandlers.onDownstream` | prediction recomputes |
## 7. Lifecycle — what one event (or tick) does
~~~mermaid
sequenceDiagram
participant parent
participant node as this node
participant sensor as measurement child
participant out as Port-0 output
sensor->>node: data.pressure (3.4 bar, upstream)
node->>node: ChildRouter → pressure handler
node->>node: prediction recomputes
node->>node: drift assesses prediction vs measured
node->>node: getOutput() composes snapshot
node->>out: msg{topic, payload, [process|influx]}
parent->>node: set.demand (15 m³/h)
node->>node: state.handleInput → maybe transition
~~~
One screen max. For multiple distinct flows (idle vs running vs error), pick the most common and link out to the rest.
## 8. Data model — `getOutput()`
What lands on Port 0. Composed in domain `getOutput()`, then delta-compressed by `outputUtils.formatMsg`.
**Abstract schema** (always include):
<!-- BEGIN AUTOGEN: datamodel-schema -->
| Key | Type | Unit | Source |
|---|---|---|---|
| `<type>.<variant>.<position>.<childId>` | number | per `UnitPolicy.output(type)` | MeasurementContainer |
| `state` | string | — | `state/` |
| `predictionHealth.level` | 03 | — | `drift/` |
| `predictionHealth.flags` | string[] | — | `drift/` |
<!-- END AUTOGEN: datamodel-schema -->
**Concrete sample** (include only when the *shape* is hard to grok from the schema — e.g. nested objects, sparse keys, or unit conventions a newcomer would get wrong):
~~~json
{
"flow.measured.downstream.default": 12.4,
"pressure.measured.upstream.default": 3.4,
"power.measured.atequipment.default": 18.2,
"state": "operational",
"predictionHealth": { "level": 1, "flags": ["pressure_init_warming"], "message": "warmup phase", "source": "rotatingMachine#pump-A" }
}
~~~
Concrete samples must come from a known-good test run — never made-up values. Regenerate when concern modules change shape.
## 9. Configuration — editor form ↔ config keys
~~~mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Mode dropdown]
f2[Demand input]
f3[Threshold %]
end
subgraph config["Domain config slice"]
c1[control.mode]
c2[control.targets.demand]
c3[safety.thresholdPercent]
end
f1 --> c1
f2 --> c2
f3 --> c3
~~~
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| Mode | `control.mode` | `auto` | enum | `control/strategies.js` |
| Demand | `control.targets.demand` | `0` | ≥ 0 | `dispatch/` |
| Threshold % | `safety.thresholdPercent` | `95` | 0100 | `safety/guards.js` |
## 10. State chart (stateful nodes only)
~~~mermaid
stateDiagram-v2
[*] --> off
off --> idle: cmd.startup
idle --> warmingup: setpoint > 0
warmingup --> operational: warmup_time elapsed
operational --> coolingdown: cmd.shutdown
coolingdown --> off: cooldown_time elapsed
operational --> emergencystop: cmd.estop
emergencystop --> off: cmd.reset
~~~
Skip this section for stateless nodes (`measurement`, `dashboardAPI`).
## 11. Examples
| Tier | File | What it shows | Mandatory? |
|---|---|---|---|
| Basic | `examples/01-Basic.json` | Inject + dashboard, no parent | ✅ |
| Integration | `examples/02-Integration.json` | Wired to `<parent>` + 1 child | ✅ if has parent |
| Dashboard | `examples/03-Dashboard.json` | Live FlowFuse charts | ⭕ optional |
One screenshot per tier where helpful. PNG ≤ 200 KB under `wiki/_partial-screenshots/<NodeName>/`. Docker compose snippet under `examples/README.md`.
## 12. Debug recipes
How to diagnose the common failure modes. One table row per recipe.
| Symptom | First thing to check | Where to look |
|---|---|---|
| Status badge stuck on `⚠ no input` | Did the measurement child register? Watch Port 2. | Editor debug tap on Port 2 |
| `flow.measured.downstream` not updating | Confirm the child's emitted topic matches the `ChildRouter` filter. | `specificClass.js` → `configure()` |
| Prediction `level=3` | Run `enableLog: 'debug'` *temporarily*; look for drift evaluator output. | container log |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
## 13. When you would NOT use this node
Two or three bullets, one sentence each. Forces explicit non-goals.
- Use rotatingMachine for a **single** pump. For groups of 2+ pumps with load sharing, use `machineGroupControl` as the parent.
- Don't use rotatingMachine to model a passive non-return valve — use `valve` (no curve, no FSM-driven motor).
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | Drift confidence drops to 0 when pressure missing > 30 s | `.claude/refactor/OPEN_QUESTIONS.md` |
| 2 | Multi-parent teardown ordering | Gitea issue #42 |
Link to repo issues when they exist. Keep this table living — it's the contract with the user about what "works".
<!-- END TEMPLATE -->
```
## Hard rules for editors
1. Section 2 (Position in the platform) appears **before any prose**. Diagrams lead.
2. Every section opens with a diagram, table, or chart. Prose annotates the visual; never the other way round.
3. **Max 60 words per paragraph.** A paragraph longer than that splits into bullets or moves into a table.
4. The topic contract (section 5) and data-model schema (section 8) are **auto-generated** between the `BEGIN AUTOGEN` / `END AUTOGEN` markers. Don't hand-edit between markers.
5. Mermaid is the default for graph structures. Use generated SVG/PNG for XY data (curves, time series). Use tables for facts.
6. Skip `classDiagram` (we don't expose classes to users) and `gantt` (no schedules in node docs).
7. **Concrete sample payloads must come from a known-good test run.** Made-up numbers rot silently.
8. S88 colour codes are non-negotiable in section 2. Match the palette in `.claude/rules/node-red-flow-layout.md`.
## Archive banner — paste at the top of every archived page
```
> **⚠️ ARCHIVED — pre-refactor (Tier 14, 2026-05)**
>
> This page describes the architecture before the platform refactor.
> The current page is **[<NodeName>](../<NodeName>)**.
>
> Kept for historical reference only. **Do not update.**
```
Archived pages move to `Archive/<NodeName>-pre-refactor.md` in the Gitea wiki repo. After moving, the page is read-only — corrections go on the current page, not the archive.
## Auto-generation — Phase 9 follow-up
Two scripts per node, wired in `package.json`:
```json
"scripts": {
"wiki:contract": "node scripts/generate-contract.js > wiki/_partial-topics.md",
"wiki:datamodel": "node scripts/generate-datamodel.js > wiki/_partial-datamodel.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
}
```
- **`generate-contract.js`** walks `src/commands/index.js`, emits one table row per descriptor between the topic-contract markers.
- **`generate-datamodel.js`** instantiates the domain with the default config, calls `getOutput()`, emits the abstract schema between the datamodel-schema markers. If `wiki/sample-output.fixture.json` exists, the concrete-sample block below the markers is also overwritten.
- `describeSchema` walks the lightweight `{type, properties}` schema and produces a one-line readable form.
## What lives where
| Artifact | Location | Hand-edited? |
|---|---|---|
| Canonical page source | `wiki/<NodeName>.md` in the node's repo | Yes (except inside AUTOGEN markers) |
| Auto-generated partials | written inline between AUTOGEN markers | No — generated |
| Plots | `wiki/_partial-plots/<NodeName>/*.svg` | No — generated |
| Screenshots | `wiki/_partial-screenshots/<NodeName>/*.png` | Yes (committed) |
| Gitea wiki UI | mirror — re-rendered from `wiki/` on push | No |
| Archived pre-refactor pages | `Archive/<NodeName>-pre-refactor.md` in the wiki repo | No (read-only after archival) |
The Gitea wiki repo is separate from each node's source repo. The `wiki/` directory in each node's repo is canonical; a `wiki-sync` workflow (not yet built) mirrors it into the Gitea wiki repo on each push to `development` / `main`.

View File

@@ -1,5 +1,7 @@
---
paths:
- "nodes/*/*.js"
- "nodes/*/*.html"
- "nodes/*/src/**"
---
@@ -23,9 +25,63 @@ Every node follows entry → nodeClass → specificClass:
- Port 1: InfluxDB telemetry payload
- Port 2: Registration/control plumbing (parent-child handshakes)
## File-Naming Convention
The folder name is the canonical node name and every per-node file MUST match it
exactly (case-sensitive). No abbreviations.
| Path | Required name |
|---|---|
| Folder | `nodes/<nodeName>/` |
| Entry file | `nodes/<nodeName>/<nodeName>.js` |
| Editor HTML | `nodes/<nodeName>/<nodeName>.html` |
| nodeClass | `nodes/<nodeName>/src/nodeClass.js` |
| specificClass | `nodes/<nodeName>/src/specificClass.js` |
| Editor JS modules | `nodes/<nodeName>/src/editor/*.js` |
`machineGroupControl/mgc.js`, `valveGroupControl/vgc.js`, and
`dashboardAPI/dashboardapi.js` are legacy drift. New nodes MUST use the full
folder name; legacy nodes get renamed when next touched (rename = update entry
file, HTML file, `package.json#node-red.nodes`, and any test imports in one
commit).
## Editor JS Layout — `src/editor/`
Editor-side JavaScript that exceeds a couple of dozen lines lives in modular
files under `nodes/<nodeName>/src/editor/`, served by the entry file via:
```js
const path = require('path');
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
if (err && !res.headersSent) res.status(404).send('// editor module not found');
});
});
```
The HTML file then loads them as plain `<script src="/<nodeName>/editor/<file>.js">`
tags. Conventional modules:
| File | Purpose |
|---|---|
| `index.js` | Namespace setup (`window.EVOLV.nodes.<nodeName>.editor = …`), shared helpers |
| `oneditprepare.js` | Implementation called from the `.html`'s `oneditprepare` hook |
| `oneditsave.js` | Implementation called from the `.html`'s `oneditsave` hook |
| Feature modules | One per visual concern, e.g. `basin-diagram.js`, `mode-cards.js`, `timing-donut.js`, `hover-couple.js` |
The `.html` shrinks to: register defaults, declare HTML template, delegate
`oneditprepare`/`oneditsave` to the modules. Inline JS in the `.html` is fine
for **trivial** nodes (≤ ~50 lines of editor JS); past that, extract.
Reference implementations: `pumpingStation/src/editor/` and
`machineGroupControl/src/editor/`. `rotatingMachine` is currently inline and
should be migrated when the editor JS next grows.
## Admin Endpoints
- `GET /<nodeName>/menu.js` — Dynamic menu configuration for editor
- `GET /<nodeName>/configData.js` — Runtime configuration for editor
- `GET /<nodeName>/editor/:file` — (when present) editor JS modules from `src/editor/`
## Submodule Awareness
Most `nodes/*` directories are git submodules. Keep edits scoped to the target node's directory.

View File

@@ -0,0 +1,535 @@
---
paths:
- "nodes/*/examples/**"
- "examples/**"
---
# Node-RED Flow Layout Rules
How to lay out a multi-tab Node-RED demo or production flow so it is readable, debuggable, and trivially extendable. These rules apply to anything you build with `examples/` flows, dashboards, or production deployments.
## 1. Tab boundaries — by CONCERN, not by data
Every node lives on the tab matching its **concern**, never where it happens to be wired:
| Tab | Lives here | Never here |
|---|---|---|
| **🏭 Process Plant** | EVOLV nodes (rotatingMachine, MGC, pumpingStation, measurement, reactor, settler, …) + small per-node output formatters | UI widgets, demo drivers, one-shot setup injects |
| **📊 Dashboard UI** | All `ui-*` widgets, the wrapper functions that turn a button click into a typed `msg`, the trend-feeder split functions | Anything that produces data autonomously, anything that talks to EVOLV nodes directly |
| **🎛️ Demo Drivers** | Random generators, scripted scenarios, schedule injectors, anything that exists only to drive the demo | Real production data sources (those go on Process Plant or are wired in externally) |
| **⚙️ Setup & Init** | One-shot `once: true` injects (setMode, setScaling, auto-startup) | Anything that fires more than once |
**Why these four:** each tab can be disabled or deleted independently. Disable Demo Drivers → demo becomes inert until a real data source is wired. Disable Setup → fresh deploys don't auto-configure (good for debugging). Disable Dashboard UI → headless mode for tests. Process Plant always stays.
If you find yourself wanting a node "between" two tabs, you've named your concerns wrong — re-split.
## 2. Cross-tab wiring — link nodes only, named channels
Never wire a node on tab A directly to a node on tab B. Use **named link-out / link-in pairs**:
```text
[ui-slider] ──► [link out cmd:demand] ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
[random gen] ─► [link out cmd:demand] ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► [link in cmd:demand] ──► [router] ──► [MGC]
many link-outs may target one link-in
```
### Naming convention
Channels follow `<direction>:<topic>` lowercase, kebab-case after the colon:
- `cmd:` — UI / drivers → process. Carries commands.
- `evt:` — process → UI / external. Carries state events.
- `setup:` — setup tab → wherever. Carries one-shot init.
Examples used in the pumping-station demo:
- `cmd:demand`, `cmd:randomToggle`, `cmd:mode`
- `cmd:station-startup`, `cmd:station-shutdown`, `cmd:station-estop`
- `cmd:setpoint-A`, `cmd:setpoint-B`, `cmd:setpoint-C`
- `cmd:pump-A-seq` (start/stop for pump A specifically)
- `evt:pump-A`, `evt:pump-B`, `evt:pump-C`, `evt:mgc`, `evt:ps`
- `setup:to-mgc`
### Channels are the contract
The list of channel names IS the inter-tab API. Document it in the demo's README. Renaming a channel is a breaking change.
### When to use one channel vs many
- One channel, many emitters: same kind of message from multiple sources (e.g. `cmd:demand` is fired by both the slider and the random generator).
- Different channels: messages with different *meaning* even if they go to the same node (e.g. don't fold `cmd:setpoint-A` into a generic `cmd:pump-A` — keep setpoint and start/stop separate).
- Avoid one mega-channel: a "process commands" channel that the receiver routes-by-topic is harder to read than separate channels per concern.
### Don't use link-call for fan-out
`link call` is for synchronous request/response (waits for a paired `link out` in `return` mode). For fan-out, use plain `link out` (mode=`link`) with multiple targets, or a single link out → single link in → function-node fan-out (whichever is clearer for your case).
## 3. Spacing and visual layout
Nodes need air to be readable. Apply these constants in any flow generator:
```python
LANE_X = [120, 380, 640, 900, 1160, 1420] # 6 vertical lanes per tab
ROW = 80 # standard row pitch
SECTION_GAP = 200 # extra y-shift between sections
```
### Lane assignment (process plant tab as example)
| Lane | Contents |
|---|---|
| 0 (x=120) | Inputs from outside the tab — link-in nodes, injects |
| 1 (x=380) | First-level transformers — wrappers, fan-outs, routers |
| 2 (x=640) | Mid-level — section comments live here too |
| 3 (x=900) | Target nodes — the EVOLV node itself (pump, MGC, PS) |
| 4 (x=1160) | Output formatters — function nodes that build dashboard-friendly payloads |
| 5 (x=1420) | Outputs to outside the tab — link-out nodes, debug taps |
Inputs flow left → right. Don't loop wires backwards across the tab.
### Section comments
Every logical group within a tab gets a comment header at lane 2 with a `── Section name ──` style label. Use them liberally — every 3-5 nodes deserves a header. The `info` field on the comment carries the multi-line description.
### Section spacing
`SECTION_GAP = 200` between sections, on top of the standard row pitch. Don't pack sections together — when you have 6 measurements on a tab, give each pump 4 rows + a 200 px gap to the next pump. Yes, it makes tabs scroll. Scroll is cheap; visual confusion is expensive.
## 4. Charts — the trend-split rule
ui-chart with `category: "topic"` + `categoryType: "msg"` plots one series per unique `msg.topic`. So:
- One chart per **metric type** (one chart for flow, one for power).
- Each chart receives msgs whose `topic` is the **series label** (e.g. `Pump A`, `Pump B`, `Pump C`).
### Required chart properties (FlowFuse ui-chart renders blank without ALL of these)
Derived from working charts in rotatingMachine/examples/03-Dashboard. Every property listed below is mandatory — omit any one and the chart renders blank with no error message.
```json
{
"type": "ui-chart",
"chartType": "line",
"interpolation": "linear",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"bins": 10,
"width": 12,
"height": 6,
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"colors": ["#0095FF","#FF0000","#FF7F0E","#2CA02C","#A347E1","#D62728","#FF9896","#9467BD","#C5B0D5"],
"textColor": ["#666666"],
"textColorDefault": true,
"gridColor": ["#e5e5e5"],
"gridColorDefault": true
}
```
**Key gotchas:**
- `interpolation` MUST be set (`"linear"`, `"step"`, `"bezier"`, `"cubic"`, `"cubic-mono"`). Without it: no line drawn.
- `yAxisProperty: "payload"` + `yAxisPropertyType: "msg"` tells the chart WHERE in the msg to find the y-value. Without these: chart has no data to plot.
- `xAxisPropertyType: "timestamp"` tells the chart to use `msg.timestamp` (or auto-generated) for the x-axis.
- `width` and `height` are **numbers, not strings**. `width: 12` (correct) vs `width: "12"` (may break).
- `removeOlderPoints: ""` (empty string) → retention is controlled by removeOlder + removeOlderUnit only. Set to a number string to additionally cap points per series.
- `colors` array defines the palette for auto-assigned series colours. Provide at least 3.
### The trend-split function pattern
A common bug: feeding both flow and power msgs to a single function output that wires to both charts. Both charts then plot all metrics, garbling the legend.
**Fix:** the trend-feeder function MUST have one output per chart, and split:
```js
// outputs: 2
// wires: [["chart_flow"], ["chart_power"]]
const flowMsg = p.flowNum != null ? { topic: 'Pump A', payload: p.flowNum } : null;
const powerMsg = p.powerNum != null ? { topic: 'Pump A', payload: p.powerNum } : null;
return [flowMsg, powerMsg];
```
A null msg on a given output sends nothing on that output — exactly what we want.
### Chart axis settings to actually configure
- `removeOlder` + `removeOlderUnit`: how much history to keep (e.g. 10 minutes).
- `removeOlderPoints`: cap on points per series (200 is sensible for a demo).
- `ymin` / `ymax`: leave blank for autoscale, or set numeric strings if you want a fixed range.
## 5. Inject node — payload typing
Multi-prop inject must populate `v` and `vt` **per prop**, not just the legacy top-level `payload` + `payloadType`:
```json
{
"props": [
{"p": "topic", "vt": "str"},
{"p": "payload", "v": "{\"action\":\"startup\"}", "vt": "json"}
],
"topic": "execSequence",
"payload": "{\"action\":\"startup\"}",
"payloadType": "json"
}
```
If you only fill the top-level fields, `payload_type=json` is silently treated as `str`.
## 6. Dashboard widget rules
- **Widget = display only.** No business logic in `ui-text` formats or `ui-template` HTML.
- **Buttons emit a typed string payload** (`"fired"` or similar). Convert to the real msg shape with a tiny wrapper function on the same tab, before the link-out.
- **Sliders use `passthru: true`** so they re-emit on input messages (useful for syncing initial state from the process side later).
- **One ui-page per demo.** Multiple groups under one page is the natural split.
- **Group widths should sum to a multiple of 12.** The page grid is 12 columns. A row of `4 + 4 + 4` or `6 + 6` works; mixing arbitrary widths leaves gaps.
- **EVERY ui-* node needs `x` and `y` keys.** Without them Node-RED dumps the node at (0,0) — every text widget and chart piles up in the top-left of the editor canvas. The dashboard itself still renders correctly (it lays out by group/order, not editor x/y), but the editor view is unreadable. If you write a flow generator helper, set `x` and `y` on the dict EVERY time. Test with `jq '[.[] | select(.x==0 and .y==0 and (.type|tostring|startswith("ui-")))]'` after generating.
## 7. Do / don't checklist
✅ Do:
- Generate flows from a Python builder (`build_flow.py`) — it's the source of truth.
- Use deterministic IDs (`pump_a`, `meas_pump_a_u`, `lin_demand_to_mgc`) — reproducible diffs across regenerations.
- Tag every channel name with `cmd:` / `evt:` / `setup:`.
- Comment every section, even short ones.
- Verify trends with a `ui-chart` of synthetic data first, before plumbing real data through.
❌ Don't:
- Don't use `replace_all` on a Python identifier that appears in a node's own wires definition — you'll create self-loops (>250k msg/s discovered the hard way).
- Don't wire across tabs directly. The wire IS allowed but it makes the editor unreadable.
- Don't put dashboard widgets next to EVOLV nodes — different concerns.
- Don't pack nodes within 40 px of each other — labels overlap, wires snap to wrong handles.
- Don't ship `enableLog: "debug"` in a demo — fills the container log within seconds and obscures real errors.
## 8. The link-out / link-in JSON shape (cheat sheet)
```json
{
"id": "lout_demand_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:demand",
"mode": "link",
"links": ["lin_demand_to_mgc"],
"x": 380, "y": 140,
"wires": []
}
```
```json
{
"id": "lin_demand_to_mgc",
"type": "link in",
"z": "tab_process",
"name": "cmd:demand",
"links": ["lout_demand_dash", "lout_demand_drivers"],
"x": 120, "y": 1500,
"wires": [["demand_fanout_mgc_ps"]]
}
```
Both ends store the paired ids in `links`. The `name` is cosmetic (label only) — Node-RED routes by id. Multiple emitters can target one receiver; one emitter can target multiple receivers.
## 9. Node configuration completeness — ALWAYS set every field
When placing an EVOLV node in a flow (demo or production), configure **every config field** the node's schema defines — don't rely on schema defaults for operational parameters. Schema defaults exist to make the validator happy, not to represent a realistic plant.
**Why this matters:** A pumpingStation with `basinVolume: 10` but default `heightOverflow: 2.5` and default `heightOutlet: 0.2` creates an internally inconsistent basin where the fill % exceeds 100%, safety guards fire at wrong thresholds, and the demo looks broken. Every field interacts with every other field.
**The rule:**
1. Read the node's config schema (`generalFunctions/src/configs/<nodeName>.json`) before writing the flow.
2. For each section (basin, hydraulics, control, safety, scaling, smoothing, …), set EVERY field explicitly in the flow JSON — even if you'd pick the same value as the default.
3. Add a comment in the flow generator per section explaining WHY you chose each value (e.g. "basin sized so sinus peak takes 6 min to fill from startLevel to overflow").
4. Cross-check computed values: `surfaceArea = volume / height`, `maxVolOverflow = heightOverflow × surfaceArea`, gauge `max` = basin `height`, fill % denominator = `volume` (not overflow volume).
5. If a gauge or chart references a config value (basin height, maxVol), derive it from the same source — never hardcode a number that was computed elsewhere.
## 10. Verifying the layout
Before declaring a flow done:
1. **Open the tab in the editor — every wire should run left → right.** No backward loops.
2. **Open each section by section comment — visible in 1 screen height.** If not, raise `SECTION_GAP`.
3. **Hit the dashboard URL — every widget has data.** `n/a` everywhere is a contract failure.
4. **For charts, watch a series populate over 30 s.** A blank chart after 30 s = bug.
5. **Disable each tab one at a time and re-deploy.** Process Plant alone should still load (just inert). Dashboard UI alone should serve a page (just empty). If disabling a tab errors out, the tab boundaries are wrong.
## 10. Hierarchical placement — by S88 level, not by node name
The lane assignment maps to the **S88 hierarchy**, not to specific node names. Any node that lives at a given S88 level goes in the same lane regardless of what kind of equipment it is. New node types added to the platform inherit a lane by their S88 category — no rule change needed.
### 10.0 Two color systems — palette swatch vs. editor group
EVOLV uses two distinct color schemes for two distinct purposes. Mixing them up is the most common visual-design bug we see in flows.
| System | Where it's set | What it signals | Scheme |
|---|---|---|---|
| **Palette swatch** | `RED.nodes.registerType(..., { color })` in `<node>.html` | "Which node am I picking from the sidebar?" | **Domain-hue per node** (table below) |
| **Editor group rectangle** | `style.fill` on a `group` node in `flow.json` | "Which S88 cluster does this box represent?" | **S88 level** (§10.1 table) |
**Palette swatches (set 2026-05-21).** Family hue = function. Within a family, darker = higher S88 / "more controller-ish."
| Node | Hex | Family |
|---|---|---|
| `rotatingMachine` | `#E89B3A` | 🟧 orange — leaf (individual machine) |
| `machineGroupControl` | `#B5651D` | 🟫 orange — mid (parent of RM) |
| `pumpingStation` | `#8B4513` | 🟤 orange — dark (top of pump hierarchy) |
| `valve` | `#3CAEA3` | 🟦 teal — leaf |
| `valveGroupControl` | `#2A8A82` | 🟦 teal — dark (parent of valve) |
| `reactor` | `#6FAE5F` | 🟩 green — biology |
| `settler` | `#8FAD3F` | 🟢 olive — biology |
| `diffuser` | `#6EB5E5` | 🟦 sky blue — aeration |
| `monster` | `#9C5BB0` | 🟪 violet — sampling |
| `measurement` | `#D4A02E` | 🟨 amber — sensor |
| `dashboardAPI` | `#7A8BA3` | ⬜ slate — infrastructure |
| `coresync` | `#54647B` | ⬛ dark slate — infrastructure |
**Important:** the §10.1 "Colour" column below refers to **editor groups + lane backgrounds** (S88), not to the palette swatch. Don't use the S88 hex inside `registerType`; don't use the palette hex inside a `flow.json` group `style.fill`.
### 10.1 Lane convention (x-axis = S88 level)
| Lane | x | Purpose | S88 level | Colour | Current EVOLV nodes |
|---:|---:|---|---|---|---|
| **L0** | 120 | Tab inputs | — | (none) | `link in`, `inject` |
| **L1** | 360 | Adapters | — | (none) | `function` (msg-shape wrappers) |
| **L2** | 600 | Control Module | CM | `#a9daee` | `measurement` |
| **L3** | 840 | Equipment Module | EM | `#86bbdd` | `rotatingMachine`, `valve`, `diffuser` |
| **L4** | 1080 | Unit | UN | `#50a8d9` | `machineGroupControl`, `valveGroupControl`, `reactor`, `settler`, `monster` |
| **L5** | 1320 | Process Cell | PC | `#0c99d9` | `pumpingStation` |
| **L6** | 1560 | Output formatters | — | (none) | `function` (build dashboard payload from port 0) |
| **L7** | 1800 | Tab outputs | — | (none) | `link out`, `debug` |
Spacing: **240 px** between lanes. Tab width ≤ 1920 px (fits standard monitors without horizontal scroll in the editor).
**Area level** (`#0f52a5`) is reserved for plant-wide coordination and currently unused — when added, allocate a new lane and shift formatter/output one lane right (i.e. expand to 9 lanes if and when needed).
### 10.2 The group rule (Node-RED `group` boxes anchor each parent + its children)
Use Node-RED's native `group` node (the visual box around a set of nodes — not to be confused with `ui-group`) to anchor every "parent + direct children" cluster. The box makes ownership unambiguous and lets you collapse the cluster in the editor.
**Group rules:**
- **One Node-RED group per parent + its direct children.**
Example: `Pump A + meas-A-up + meas-A-dn` is one group, named `Pump A`.
- **Group colour = parent's S88 colour.**
So a Pump-A group is `#86bbdd` (Equipment Module). A reactor group is `#50a8d9` (Unit).
- **Group `style.label = true`** so the box shows the parent's name.
- **Group must contain all the children's adapters / wrappers / formatters** too if those exclusively belong to the parent. The box is the visual anchor for "this is everything that owns / serves Pump A".
- **Utility groups for cross-cutting logic** (mode broadcast, station-wide commands, demand fan-out) use a neutral colour (`#dddddd`).
JSON shape:
```json
{
"id": "grp_pump_a",
"type": "group",
"z": "tab_process",
"name": "Pump A",
"style": { "label": true, "stroke": "#000000", "fill": "#86bbdd", "fill-opacity": "0.10" },
"nodes": ["meas_pump_a_u", "meas_pump_a_d", "pump_a", "format_pump_a", "lin_setpoint_pump_a", "build_setpoint_pump_a", "lin_seq_pump_a", "lout_evt_pump_a"],
"x": 80, "y": 100, "w": 1800, "h": 200
}
```
`x/y/w/h` is the bounding box of contained nodes + padding — compute it from the children's positions.
### 10.3 The hierarchy rule, restated
> Nodes at the **same S88 level** (siblings sharing one parent) **stack vertically in the same lane**.
>
> Nodes at **different S88 levels** (parent ↔ child) sit **next to each other on different lanes**.
### 10.4 Worked example — pumping station demo
```
L0 L1 L2 L3 L4 L5 L6 L7
(input) (adapter) (CM) (EM) (Unit) (PC) (formatter) (output)
┌── group: Pump A (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
│ [lin-set-A] [build-A] │
│ [lin-seq-A] │
│ [meas-A-up] │
│ [meas-A-dn] → [Pump A] → │
│ [format-A] →[lout-evt-A]
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌── group: Pump B (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
│ ... same shape ... │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌── group: Pump C (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐
│ ... same shape ... │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌── group: MGC — Pump Group (#50a8d9) ──────────────────────────────────────────────────────────────────────────────┐
│ [lin-demand] [demand→MGC+PS] [MGC] [format-MGC]→[lout-evt-MGC]
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌── group: Pumping Station (#0c99d9) ───────────────────────────────────────────────────────────────────────────────┐
│ [PS] [format-PS]→[lout-evt-PS]
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌── group: Mode broadcast (#dddddd, neutral) ───────────────────────────────────────────────────────────────────────┐
│ [lin-mode] [fan-mode] ─────────────► to all 3 pumps in the Pump A/B/C groups │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌── group: Station-wide commands (#dddddd) ─────────────────────────────────────────────────────────────────────────┐
│ [lin-start] [fan-start] ─► to pumps │
│ [lin-stop] [fan-stop] │
│ [lin-estop] [fan-estop] │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
What that buys:
- Search "Pump A" highlights the whole group box (parent + sensors + adapters + formatter).
- S88 colour of the group box tells you the level at a glance.
- Wires are horizontal within a group; cross-group wires (Pump A port 2 → MGC) cross only one band.
- Collapse a group in the editor and it becomes a single tile — clutter disappears during reviews.
### 10.5 Multi-input fan-in rule
Stack link-ins tightly at L0, centred on the destination's y. Merge node one lane right at the same y.
### 10.6 Multi-output fan-out rule
Source at the y-centre of its destinations; destinations stack vertically in the next lane. Wires fork cleanly without jogging.
### 10.7 Link-in placement (within a tab)
- All link-ins on **L0**.
- Order them top-to-bottom by the y of their **first downstream target**.
- Link-ins that feed the same destination share the same y-band as that destination.
### 10.8 Link-out placement (within a tab)
- All link-outs on **L7** (the rightmost lane).
- Each link-out's y matches its **upstream source's** y, so the wire is horizontal.
### 10.9 Cross-tab wire rule
Cross-tab wires use `link out` / `link in` pairs (see Section 2). Direct cross-tab wires are forbidden.
### 10.10 The "no jog" verification
- A wire whose source y == destination y is fine (perfectly horizontal).
- A wire that jogs vertically by ≤ 80 px is fine (one row of slop).
- A wire that jogs by > 80 px means **the destination is in the wrong group y-band**. Move the destination, not the source — the source's position was determined by its own group.
## 11. Dashboard tab variant
Dashboard widgets are stamped to the real grid by the FlowFuse renderer; editor x/y is for the editor's readability.
- Use only **L0, L2, L4, L7**:
- L0 = `link in` (events from process)
- L2 = `ui-*` inputs (sliders, switches, buttons)
- L4 = wrapper / format / trend-split functions
- L7 = `link out` (commands going back)
- **One Node-RED group per `ui-group`.** Editor group's name matches the `ui-group` name. Colour follows the S88 level of the represented equipment (MGC group = `#50a8d9`, Pump A group = `#86bbdd`, …) so the editor view mirrors the dashboard structure.
- Within the group, widgets stack vertically by their visual order in the dashboard.
## 12. Setup tab variant
Single-column ladder L0 → L7, ordered top-to-bottom by `onceDelay`. Wrap in a single neutral-grey Node-RED group named `Deploy-time setup`.
## 13. Demo Drivers tab variant
Same as Process Plant but typically only L0, L2, L4, L7 are used. Wrap each driver (random gen, scripted scenario, …) in its own neutral Node-RED group.
## 14. Spacing constants (final)
```python
LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
SIBLING_PITCH = 40
GROUP_GAP = 200
TAB_TOP_MARGIN = 80
GROUP_PADDING = 20 # extra px around child bounding box for the Node-RED group box
S88_COLORS = {
"AR": "#0f52a5", # Area (currently unused)
"PC": "#0c99d9", # Process Cell
"UN": "#50a8d9", # Unit
"EM": "#86bbdd", # Equipment Module
"CM": "#a9daee", # Control Module
"neutral": "#dddddd",
}
# Registry: drop a new node type here to place it automatically.
NODE_LEVEL = {
"measurement": "CM",
"rotatingMachine": "EM",
"valve": "EM",
"diffuser": "EM",
"machineGroupControl": "UN",
"valveGroupControl": "UN",
"reactor": "UN",
"settler": "UN",
"monster": "UN",
"pumpingStation": "PC",
"dashboardAPI": "neutral",
}
```
Helpers for the build script:
```python
def place(lane, group_index, position_in_group, group_size):
"""Compute (x, y) for a node in a process group."""
x = LANE_X[lane]
band_centre = TAB_TOP_MARGIN + group_index * (group_size * SIBLING_PITCH + GROUP_GAP) \
+ (group_size - 1) * SIBLING_PITCH / 2
y = band_centre + (position_in_group - (group_size - 1) / 2) * SIBLING_PITCH
return int(x), int(y)
def wrap_in_group(child_ids, name, s88_color, nodes_by_id, padding=GROUP_PADDING):
"""Compute the Node-RED group box around a set of children."""
xs = [nodes_by_id[c]["x"] for c in child_ids]
ys = [nodes_by_id[c]["y"] for c in child_ids]
return {
"type": "group", "name": name,
"style": {"label": True, "stroke": "#000000", "fill": s88_color, "fill-opacity": "0.10"},
"nodes": list(child_ids),
"x": min(xs) - padding, "y": min(ys) - padding,
"w": max(xs) - min(xs) + 160 + 2 * padding,
"h": max(ys) - min(ys) + 40 + 2 * padding,
}
```
## 15. Verification checklist (extends Section 9)
After building a tab:
1. **No wire jogs > 80 px vertically within a group.**
2. **Each lane contains nodes of one purpose only** (never an `ui-text` on L3; never a `rotatingMachine` on L2).
3. **Peers share a lane; parents and children sit on adjacent lanes.**
4. **Every parent + direct children sit inside one Node-RED group box, coloured by the parent's S88 level.**
5. **Utility groups** (mode broadcast, station commands, demand fan-out) wrapped in neutral-grey Node-RED groups.
6. **Section comments at the top of each group band.**
7. **Editor scrollable in y but NOT in x** on a normal monitor.
8. **Search test:** typing the parent's name in the editor highlights the whole group box.
## 16. S88 colour cleanup (separate follow-up task)
These nodes don't currently follow the S88 palette. They should be brought in line in a separate session before the placement rule is fully consistent across the editor:
- `settler` (`#e4a363` orange) → should be `#50a8d9` (Unit)
- `monster` (`#4f8582` teal) → should be `#50a8d9` (Unit)
- `diffuser` (no colour set) → should be `#86bbdd` (Equipment Module)
- `dashboardAPI` (no colour set) → utility, no S88 colour needed
Until cleaned up, the placement rule still works — `NODE_LEVEL` (Section 14) already maps these to their semantic S88 level regardless of the node's own colour.

View File

@@ -0,0 +1,205 @@
---
paths:
- "nodes/*/src/**"
- "nodes/*/test/**"
- "nodes/*/examples/**"
---
# Output-Coverage Rule — Every Output, Every State, Every Layer
## Why this rule exists
On 2026-05-14 the machineGroupControl dashboard crashed the FlowFuse `ui-chart` with
`Cannot read properties of null (reading 'y')`. Cause: a 17-output fan-out function
where 16 outputs used a safe helper that returned `null` (drop the msg) when its source
field was missing, and **one** output was hand-written to emit `{ topic: …, payload: null }`
instead. The chart received a msg with a literal null payload, and crashed.
The bug pattern is generic:
> "I tested the populated state by watching the dashboard. The empty / pre-first-tick /
> degraded state was never tested for that one output."
The class of bug repeats anywhere a node has many outputs (Port 0 keys, dashboard widgets,
function-node ports, InfluxDB fields). The mitigation is process, not vigilance: **every
output must be enumerated, and every output must be exercised by a test in every state
the output can be in.**
## Scope — what counts as an "output"
Anything that can deliver a value to a downstream consumer, on any layer:
| Layer | Output kind | Examples |
|---|---|---|
| specificClass | Public method return shape | `getOutput()`, `getStatus()`, `getFlattenedOutput()` keys |
| specificClass | Event payloads | `emit('stateChange', …)`, `emit('rejected', …)` |
| nodeClass → Port 0 | Process-data keys (after delta compression) | `atEquipment_predicted_flow`, `mode`, `relDistFromPeak` |
| nodeClass → Port 1 | InfluxDB telemetry fields | every field name written via `outputUtils.formatForInflux` |
| nodeClass → Port 2 | Registration / control msgs | `registerChild`, `unregisterChild`, `assetType` |
| examples/*.json | Function node output **ports** | each index in `outputs:N` / `wires:[[…]]` |
| examples/*.json | Dashboard widget **sources** | every `ui-text`, `ui-chart`, `ui-template`, `ui-gauge`, `ui-switch`, … node that receives a msg |
| examples/*.json | Cross-tab `link-out` channels | each `cmd:*` / `evt:*` / `setup:*` channel name |
If a downstream consumer can pull `.x.y.z` off a msg, `.x.y.z` is an output and must be
tested in every state — populated, missing, zero, NaN, negative, very large.
## The manifest — required artifact per node
Every node ships `test/_output-manifest.md` (markdown table, source-controlled). The
manifest is the single source of truth for "what does this node emit, and where is it
tested?"
```markdown
# <nodeName> output manifest
## Port 0 (process data)
| Key | Source method | Type | States tested | Test file |
|---|---|---|---|---|
| mode | nodeClass._buildPort0 | string ('AUTO'|'MAN'|…) | all 4 modes, missing | test/basic/output-port0.test.js |
| atEquipment_predicted_flow | specificClass.getFlattenedOutput | number m³/s, null pre-tick | populated, null | test/basic/output-port0.test.js |
| relDistFromPeak | … | number 0..1, null when no BEP curve | populated, null | … |
## Port 1 (InfluxDB telemetry)
| Field | Source | Type | States tested | Test file |
|---|---|---|---|---|
| … | … | … | … | … |
## Port 2 (registration / control plumbing)
| Topic | Source | Payload shape | States tested | Test file |
|---|---|---|---|---|
| … | … | … | … | … |
## Example flow function-node outputs
For each example flow in examples/, list every function node with N > 1 outputs:
### examples/02-Dashboard.json :: fn_status_split (outputs: 17)
| # | Target node | Topic | Payload shape | Populated test | Empty test |
|---|---|---|---|---|---|
| 0 | ui_txt_mode | — | string | ✔ flow-fixture.test.js | ✔ flow-fixture.test.js |
| 10 | ui_chart_flow | 'Flow' | number, or whole msg null | ✔ | ✔ |
| 14 | ui_chart_eta | 'η (%)' | number, or whole msg null | ✔ | ✔ |
| … |
## Dashboard widgets
| Widget id | Source port | Expected msg shape | Crash-safe on null upstream? |
|---|---|---|---|
| ui_chart_eta | fn_status_split[14] | `{topic:'η (%)', payload:number}` or no-msg | ✔ |
| … |
```
## Internal tests — `test/basic/output-*.test.js`
Every node has at least one test file whose sole job is enumerating outputs. Every key
in the Port-0/1/2 manifest above gets:
1. **A presence test** — the key exists in the relevant getter / formatter output.
2. **A populated-state test** — drive the node into a state where the key has a real
value; assert type and (where applicable) range.
3. **A degraded-state test** — drive the node into a state where the underlying source
is missing / pre-tick / NaN. Assert the key is either **absent** or **explicitly null**.
Pick one convention per node and stick to it; never let the same key be sometimes
absent and sometimes null.
Pattern:
```js
test('Port 0 emits every manifest key after warm-up', () => { /* … */ });
test('Port 0 keys are absent (not null) before first tick', () => { /* … */ });
test('Port 0 omits efficiency keys when no BEP curve is configured', () => { /* … */ });
```
## Node-RED flow tests — `test/integration/flow-*.test.js`
For every example flow under `examples/`, ship a fixture test that loads the JSON and
drives it through `node-red-node-test-helper` (or an equivalent harness). The test must:
1. **Inject an empty msg** (`{payload:{}}`) into the EVOLV node's input. Assert that
**every** downstream function node, link-out, and `ui-*` widget either receives
nothing OR receives a msg whose payload satisfies the widget's contract (no
`payload: null`, no missing required `topic`, no `payload.x` / `payload.y` undefined
for scatter charts).
2. **Inject a fully-populated msg** matching the node's real Port-0 shape. Assert that
**every** downstream consumer receives the expected payload.
3. **Inject a degraded msg** (a real-life partial state — e.g. eta missing but flow
present). Assert no consumer receives malformed input.
Helper sketch:
```js
const helper = require('node-red-node-test-helper');
const flow = require('../examples/02-Dashboard.json');
test('no fan-out output ever emits { payload: null }', async () => {
await helper.load(allNodes, flow);
const taps = wireTapEveryDownstream(helper, 'fn_status_split');
helper.getNode('fn_status_split').receive({ payload: {} }); // empty
helper.getNode('fn_status_split').receive({ payload: fullFixture }); // populated
helper.getNode('fn_status_split').receive({ payload: partial }); // degraded
for (const tap of taps) {
for (const msg of tap.messages) {
assert.notEqual(msg.payload, null, `${tap.id} got payload:null`);
if (tap.node.type === 'ui-chart' && tap.node.yAxisProperty?.includes('.')) {
const [, prop] = tap.node.yAxisProperty.split('.');
assert.ok(msg.payload?.[prop] !== undefined, `${tap.id} missing payload.${prop}`);
}
}
}
});
```
`wireTapEveryDownstream` is a shared helper (lives in `test/helpers/flow-taps.js`) that
walks `flow.wires` from the named node and installs a recording listener on each target.
## Static lint pass — `npm run lint:flow-outputs`
A repo-level script under `tools/lint-flow-outputs.mjs` (single file, no deps) walks every
`examples/*.json`, finds every `function` node with `outputs > 1` and:
1. Cross-checks that the number of `wires` arrays equals `outputs`.
2. Parses the `func` source and, for each `return [...]` element, flags any object literal
of the form `{ ... payload: <ternary>: null }` or `{ ... payload: null }`. Those must
be rewritten as a helper that returns `null` (the whole msg) so the function node
skips the output entirely.
3. For each `ui-chart` in the flow, verifies the chart has the full required-property set
from `.claude/rules/node-red-flow-layout.md` §4 (`interpolation`, `yAxisProperty`,
`yAxisPropertyType`, `xAxisType`, `xAxisPropertyType`, …).
4. Exits non-zero on any finding. Wired into CI.
## Verification checklist — when can you declare a node "done"
Before merging any change that touches a node's outputs (Port 0/1/2 keys, function-node
ports, dashboard widgets, telemetry fields):
- [ ] `_output-manifest.md` is updated for every added / removed / renamed output.
- [ ] `test/basic/output-*.test.js` covers every manifest entry in both **populated** and **degraded** states.
- [ ] If you touched an example flow: `test/integration/flow-*.test.js` covers empty, populated, and degraded inputs to the flow.
- [ ] `npm run lint:flow-outputs` passes (no `payload: null` literals, no missing chart fields).
- [ ] Visual smoke: deploy the example flow, open the dashboard, and **before any data flows in** confirm the page loads without errors in the browser console and without exceptions in the Node-RED log.
## Anti-patterns
- ❌ Hand-writing one output of an N-output fan-out instead of using the shared helper.
If outputs 0-15 use `chart(topic, v, scale)`, output 16 also uses `chart(topic, v, scale)`.
No exceptions.
- ❌ "I tested the dashboard, it looks right" — visual confirmation of one warm state is
not coverage. The degraded / pre-first-tick state is where dashboards crash.
- ❌ Emitting `{ payload: null }` from a function node. Either return the whole msg as
`null` (the function-node convention for "don't emit on this output") or supply a
default that the consumer can render.
- ❌ "I'll add the test later" — the manifest entry without a corresponding passing test
is a regression vector. Land them together.
- ❌ Mixing conventions per node (sometimes a missing field is `null`, sometimes absent).
Pick one per node, document it in the manifest, enforce it in the test.
## Migration plan for existing nodes
Existing nodes don't have `_output-manifest.md` yet. The rule applies prospectively:
**any PR that touches a node's outputs must add or update the manifest for at least the
outputs it touched.** A repo-wide backfill (one PR per node, generating the manifest
from existing tests + flows) is tracked in `.agents/improvements/IMPROVEMENTS_BACKLOG.md`.

View File

@@ -1,14 +1,14 @@
---
paths:
- "nodes/*/src/nodeClass.js"
- "nodes/*/src/specificClass.js"
- "nodes/*/src/output/**"
- "nodes/generalFunctions/src/outputUtils/**"
---
# Telemetry Rules
## Output Port Convention
- Port 0: Process data (downstream node consumption)
- Port 1: InfluxDB telemetry payload
- Port 2: Registration/control plumbing
Output port convention (Port 0/1/2) is documented in `.claude/rules/node-architecture.md`. This file covers only the Port 1 payload shape and downstream contracts.
## InfluxDB Payload Structure
Port 1 payloads must follow InfluxDB line protocol conventions:

View File

@@ -5,18 +5,18 @@ paths:
# Testing Rules
## 3-Tier Test Structure
Every node must have:
- `test/basic/*.test.js` — Unit tests for individual functions
## Test Structure
Every node has at minimum:
- `test/basic/*.test.js` — Unit tests for individual functions (specificClass domain logic)
- `test/integration/*.test.js` — Node interaction and message passing tests
- `test/edge/*.test.js` — Edge cases, error conditions, boundary values
- `test/helpers/` (optional) — Shared test utilities for this node
Edge-case tests live wherever they fit (in `basic/` for pure-logic edges, in `integration/` for runtime edges). Don't require a separate `test/edge/` directory.
## Test Runner
```bash
node --test nodes/<nodeName>/test/basic/*.test.js
node --test nodes/<nodeName>/test/integration/*.test.js
node --test nodes/<nodeName>/test/edge/*.test.js
```
## Test Requirements
@@ -25,11 +25,7 @@ node --test nodes/<nodeName>/test/edge/*.test.js
- Example flows (`examples/`) must stay in sync with implementation
## Example Flows
Each node must maintain:
- `examples/README.md`
- `examples/basic.flow.json`
- `examples/integration.flow.json`
- `examples/edge.flow.json`
Each node should ship at least one runnable example under `examples/` plus an `examples/README.md` describing it. Beyond that, add only what the node's complexity demands — not every node needs separate basic/integration/edge flow files.
## No Node-RED Runtime in Unit Tests
Basic tests should test specificClass domain logic without requiring a running Node-RED instance.

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}

217
.claude/skills/README.md Normal file
View File

@@ -0,0 +1,217 @@
# Workflow skills — research → prototype → grill-me → prd → prd-to-issues → ship-it
A six-skill chain that takes a vague idea from "I wonder if we could…" to merged, end-to-end-verified code. Four collaborative phases up front to lock down what's being built; two largely-autonomous phases that execute against that contract.
```
/research <topic> MOSTLY external + repo knowledge into a brief
/prototype <claim> MOSTLY throwaway spike to test the riskiest assumption
/grill-me <topic> TOGETHER pressure-test what survived
/prd TOGETHER synthesize PRD; gaps stay explicit
/prd-to-issues MOSTLY thin vertical-slice issues; file on "create"
/ship-it AFK shell loop ships every slice end-to-end
```
You don't have to use every skill on every feature. Small tweaks may skip `/research` and `/prototype`. Bigger / novel work uses the whole chain.
## Mode taxonomy
| Mode | Meaning | Skills |
|---|---|---|
| **TOGETHER** | Needs your turn-by-turn judgment. No autonomous path. | grill-me |
| **MOSTLY TOGETHER** | Drafts / fetches / builds AFK. You review the output. Any visible-to-team action needs your explicit "go". | research, prototype, prd, prd-to-issues |
| **AFK** | No human in the loop. Logs questions to issues instead of asking. | ship-it |
The chain is structured so AFK execution only starts after the human-locked phases have nailed down the contract. Autonomous code never runs against undefined contracts.
---
## When to use each
### `/research <topic>` — MOSTLY TOGETHER
Fans out Explore + WebSearch agents in parallel, synthesizes findings into a research brief, and names open unknowns explicitly (which become candidates for `/prototype`).
**Use when:** the topic touches anything you haven't done before in this codebase. Novel libraries, unfamiliar patterns, "how do others solve this".
**Don't use it for:** stuff you already understand. The point is to fetch what you don't know, not summarize what you do.
### `/prototype <claim>` — MOSTLY TOGETHER
Builds a throwaway spike to test ONE falsifiable assumption. Code lives in `.prototypes/` (gitignored) and is never promoted to the main codebase. Output is evidence — verdict, numbers, observed behavior — that feeds the PRD.
**Use when:** `/research` surfaced an Open Unknown that "we'll find out when we build it". Better to find out for an hour of spike cost than a week of half-built feature.
**Don't use it for:** building a "lightweight v0" you secretly plan to evolve. Prototypes are evidence; production code is the real implementation. The skill rejects scope creep mid-spike.
### `/grill-me <topic>` — TOGETHER
Senior staff engineer running a brutal-but-fair interview. One hard question at a time, honest critique (no praise filler), drills into weak spots. Stay on topic until exhausted; say `stop` for an honest 3-bullet debrief.
**Use when:** `/research` and `/prototype` (if used) have built up enough context that you need to pressure-test your own thinking before locking it in a PRD.
**Don't use it for:** rubber-stamping a finished idea, or when you want validation. Designed to find gaps, not to agree.
### `/prd` — TOGETHER (drafts AFK after grilling)
Engineering PRD: Problem, Goals, Non-goals, Users & scenarios, Functional + Non-functional requirements, Constraints, Success metrics, Open Questions, Out of scope. Things you nailed in grilling become firm requirements. Things you hedged become Open Questions with the specific gap named — gaps don't get papered over.
**Use when:** the grilling exposed enough that the feature shape is clear. Or standalone when you already have full context.
**Don't use it for:** strategy decks, market sizing, "why now". This is for engineering.
### `/prd-to-issues` — MOSTLY TOGETHER
Breaks the PRD into **thin vertical slices** — each issue cuts end-to-end through every integration layer (schema → service → API → UI → tests; or sensor → broker → parser → store → dashboard). First slice is a walking skeleton. Prerequisites get absorbed into the slice that needs them, not filed separately. Per-issue `Slice check` block proves every layer is covered, plus a coverage matrix at the top of the draft showing PRD → issue mapping. Self-audit runs **before** the draft is shown to you.
**Output:** draft inline → you reply `create` → files to the tracker (`gh` for GitHub, `tea` for Gitea).
**Don't use it for:** horizontal task lists ("DB work", "API work", "frontend work"). The skill rejects layer-cake slicing.
### `/ship-it` — AFK
Shell loop in `.claude/skills/ship-it/loop.sh`. Picks the next ready issue, dispatches a fresh headless Claude to ship it end-to-end (failing e2e test first → implement layer by layer → full suite → outermost-layer smoke check → commit `Closes #N` → PR with acceptance-criteria checkboxes + smoke evidence → CI gate → merge or leave-for-review), then moves on. One commit per issue. Status streams to terminal; tail logs from another shell; Ctrl-C anytime.
Undecidable issues get labeled `needs-decision` and skipped. Three consecutive failures stops the loop for human review.
**Don't use it for:** issues whose acceptance criteria aren't testable. The loop will skip them.
---
## A worked example
Adding live sensor display for a new flow meter to the operator dashboard.
```bash
# 1. Fetch what we don't already know
/research adding live flow-meter readings to the operator dashboard
# Brief lands; surfaces an Open Unknown:
# "Can Node-RED sustain 1Hz updates across 12 dashboard panels for 10 min
# straight without dropping frames?"
# 2. Test the risky assumption
/prototype Node-RED can stream 1Hz updates to 12 Grafana panels for 10 min straight
# Spike runs in .prototypes/nodered-throughput/;
# Verdict: confirmed, 14% CPU peak. Evidence captured. Prototype stays gitignored.
# 3. Pressure-test the design
/grill-me adding live flow-meter readings to the operator dashboard
# 68 hard questions; surfaces gaps in alerting and missing-data handling.
# 4. Lock down the contract
/prd
# PRD drafts with the alerting decision as a firm requirement and the
# missing-data behavior as an explicit Open Question.
# 5. Slice it
/prd-to-issues
# 5 slices; coverage matrix confirms every PRD requirement maps to a slice.
# Reply `create` → issues #142..#146 filed.
# 6. Walk away
/ship-it
# Preflight, plan, "Start? Reply `go`." → `go` → shell loop runs.
# Another terminal: tail -f .ship-it-logs/run-*.log
```
After ship-it exits, the summary tells you what shipped, what's open for review, what hit `needs-decision`.
## Skipping skills
The chain is a default, not a mandate:
- **Tiny well-understood change:** straight to `/prd-to-issues` (or file the issue by hand and run `/ship-it`).
- **Bigger but stack-familiar:** skip `/research` and `/prototype`; start at `/grill-me`.
- **Pure research, no implementation yet:** stop after `/research` or `/prototype` — the brief or findings are the deliverable.
- **Existing PRD from somewhere else:** `/prd-to-issues <path>` and go.
---
## File layout
```
.claude/skills/
├── README.md ← this file
├── research/
│ └── SKILL.md
├── prototype/
│ └── SKILL.md
├── grill-me/
│ └── SKILL.md
├── prd/
│ └── SKILL.md
├── prd-to-issues/
│ └── SKILL.md
└── ship-it/
├── SKILL.md ← entry point; chat-side bootstrap
├── loop.sh ← orchestrator (the actual loop)
└── iterate.md ← per-issue prompt the loop dispatches
.prototypes/ ← throwaway spike code (gitignored, created by /prototype)
.ship-it-logs/ ← ship-it loop logs (recommend gitignoring)
docs/
├── research/ ← saved research briefs ("save it" in /research)
└── prd/ ← saved PRDs ("save it" in /prd)
```
---
## Configuration
### ship-it tracker support
- **GitHub** — `gh` CLI required (`gh auth status`)
- **Gitea** — `tea` CLI required (`go install code.gitea.io/tea@latest && tea login add`)
- Auto-detected from `git remote get-url origin`
### ship-it env vars
| Var | Default | Purpose |
|---|---|---|
| `SHIP_IT_TRUNK` | `main` | Trunk branch (set to `development` for the EVOLV repo) |
| `SHIP_IT_MAX` | 50 | Iteration cap |
| `SHIP_IT_MAX_FAIL` | 3 | Consecutive failures before stop |
| `SHIP_IT_TIMEOUT` | 30m | Per-issue timeout |
| `SHIP_IT_LOG_DIR` | `<repo>/.ship-it-logs` | Log directory |
Example for EVOLV:
```bash
SHIP_IT_TRUNK=development bash .claude/skills/ship-it/loop.sh
```
### Issue label expected by ship-it
The loop filters to open issues with label `slice` and without `blocked`, `needs-decision`, or `ci-failed`. `/prd-to-issues` applies `slice` by default. If you file issues by hand, add the label or ship-it won't pick them up.
---
## Troubleshooting
**`ship-it` won't start: "tea CLI not installed".**
Repo remote is Gitea but you don't have `tea`. Install it (`go install code.gitea.io/tea@latest && tea login add`) or push to a GitHub mirror.
**`ship-it` exits immediately: "git tree is dirty".**
Commit or stash before running. The loop won't risk mixing WIP into a slice.
**`ship-it` says "backlog empty" but I have open issues.**
Filter requires label `slice` AND none of `blocked` / `needs-decision` / `ci-failed`. Check labels.
**An issue keeps getting `needs-decision`.**
Acceptance criteria probably aren't testable at the outermost layer. Rewrite as observable (e.g. "POST /x returns 201 and row appears on dashboard"), drop the label, rerun.
**`/prototype` keeps wanting to "tidy up" the spike before reporting.**
That's a sign the assumption isn't sharp enough — Claude is filling time. Sharpen the assumption and rerun, or just say "stop, report what you have now."
**`/research` returns shallow results.**
The decomposed questions were too broad. Ask it to redo with a tighter scope, or constrain ("only this repo" / "only Node-RED + InfluxDB stack").
**`/prd-to-issues` drafts look like layer cake.**
Stop, say "reslice — these are horizontal." The skill's self-audit should catch this, but if it doesn't, push back explicitly.
---
## Design principles
- **Front-load gap discovery.** Research, prototype, grill-me, PRD — each phase exists to surface gaps before they cost real implementation time.
- **Gaps are explicit, never hidden.** Open Unknowns in `/research` → spike claims in `/prototype` → Open Questions in PRD → `needs-decision` labels on issues. Nothing gets papered over.
- **Vertical slices, always.** No "implement the backend first". Every slice exercises every layer.
- **AFK only after the contract is locked.** Autonomous code only runs against decisions already on paper.
- **Throwaway means throwaway.** Prototypes are evidence; the real implementation in production code happens fresh in `/ship-it`.
- **Outermost-layer verification.** "Tests pass" isn't enough — the loop confirms user-observable behavior actually works before reporting shipped.
- **One commit per slice.** Small, reviewable, revertible.

View File

@@ -0,0 +1,43 @@
---
name: grill-me
description: Run a technical interview-style grilling on a topic the user names. Ask hard questions one at a time, wait for the user's answer, critique honestly, then drill deeper into weak spots. Use when the user invokes /grill-me or asks to be "grilled", "quizzed hard", or "interviewed" on a technical topic.
---
# Grill Me — Technical Interview Mode
**Mode: TOGETHER (human-in-the-loop).** Every turn waits for the user's answer. There is no autonomous path through this skill — without the user replying, there is nothing to grill. Do not try to predict their answers or batch questions to "save time".
You are now a senior staff engineer running a brutal but fair technical interview. The user wants to be tested, not coddled. Treat them like a strong candidate you respect enough to push.
## How to behave
1. **One question at a time.** Never ask multiple questions in a single turn. Wait for the answer before continuing.
2. **Adapt difficulty live.** Open at the level the user names (or mid-level if unspecified). If they nail it cleanly, raise the bar next turn. If they fumble, drill into the specific gap before moving on — don't pity-advance.
3. **Critique honestly.** No "great answer!" filler. If the answer is wrong, say so plainly and explain why. If it's partially right, name exactly what's missing. If it's strong, say "solid" in one line and move on — don't pad.
4. **Follow the gap.** When an answer reveals a weak spot (vague hand-waving, wrong mental model, missing edge case), your next question targets that spot directly. Do not let the user route around weakness.
5. **No leading questions.** Don't telegraph the answer in the question. "What does the GIL do?" not "Why does the GIL prevent true parallelism in CPython?"
6. **Demand specifics.** If they say "it's faster," ask how much and why. If they say "the database handles it," ask which guarantee and at what isolation level. Push past buzzwords.
7. **End on demand.** When the user says "stop", "done", or "enough", give a 3-bullet honest debrief: what they nailed, what was shaky, what to study next. No participation trophies.
## Question quality bar
- Real interview questions, not trivia. Prefer "design X under Y constraint" or "this code has a bug — find it" over "what does keyword Z mean".
- Mix categories across the session: fundamentals → system design → debugging → tradeoff judgment.
- Include at least one question per session where the *honest* answer is "it depends" — and grill them on what it depends on.
- For code/design questions, give just enough context to answer. Don't write essays in the question.
## Session flow
**First turn:** If the user provided a topic with the invocation (e.g. `/grill-me distributed systems`), start immediately with question 1 on that topic. If no topic, ask: "What do you want to be grilled on, and at what level (junior / mid / senior / staff)?" Then wait.
**Each subsequent turn:** Critique the previous answer in 13 sentences, then ask the next question. That's it. No recap, no preamble.
**On request to stop:** Deliver the debrief and exit interviewer mode.
## What not to do
- Don't give hints unless the user explicitly asks ("hint please" / "I'm stuck"). Even then, give the smallest hint that unblocks.
- Don't switch topics randomly. Stay on the thread until it's exhausted or the user changes it.
- Don't break character with meta-commentary like "as an AI" or "I'll now ask…". Just ask.
- Don't grade on a curve. A staff-level question gets staff-level scrutiny regardless of how the user is doing.

View File

@@ -0,0 +1,169 @@
---
name: prd-to-issues
description: Break a PRD down into thin vertical-slice issues — each one cuts end-to-end through every integration layer so it can be demoed and tested on its own, instead of integrating layer-by-layer. Designed to follow a /prd session. Drafts inline first; only creates issues in the tracker after explicit user confirmation. Use when the user invokes /prd-to-issues, asks to "turn the PRD into issues", "create tickets", "slice this into stories", or "file these as issues".
---
# PRD → Issues
**Mode: MOSTLY TOGETHER.** Drafting and the self-audit can run AFK. But filing issues is visible to teammates, so the create step *always* requires an explicit "create" / "file them" from the user. Drafting and showing the list does not count as approval.
You are now a tech lead translating a PRD into a backlog of **thin vertical slices**. The job is to produce issues an engineer can pick up, ship end-to-end, and demo — without coming back to ask "what does this mean", and without waiting for a separate team to finish a horizontal layer first.
## Core principle: vertical slices, not layers
Every issue must cut through **all** the integration layers the feature touches — even if the slice is laughably narrow on each layer. The first slice is a **walking skeleton**: the thinnest possible path from input to output that exercises every layer, so you discover integration problems on day one instead of week four.
What this looks like in practice depends on the stack. Examples:
- **Web feature:** schema migration (one column) + service method (one case) + API endpoint (happy path only) + UI element (one button, one state) + one integration test that hits all of it. Not: "issue 1: schema, issue 2: service, issue 3: API, issue 4: UI".
- **Data pipeline (this repo's style):** sensor/source config + MQTT topic + Node-RED parse function (one measurement) + InfluxDB write + Grafana panel (one chart) — all wired up for a single signal end-to-end. Not: "issue 1: all MQTT topics, issue 2: all parse functions, issue 3: all dashboards".
- **Infra:** one service + its compose entry + reverse-proxy route + TLS + a smoke-test curl that returns 200 — all in one issue. Not: "issue 1: compose, issue 2: nginx, issue 3: certs".
After the walking skeleton, subsequent slices **deepen** one user-visible behavior at a time (next measurement, next edge case, next UI state), still cutting through all layers each time.
## Inputs
In order of preference:
1. A PRD already drafted in the current conversation (typical case — the `/prd` skill just ran).
2. A path the user passed: `/prd-to-issues docs/prd/foo.md`.
3. If neither, ask once: "Point me at the PRD (path or paste it)."
Do not invent a PRD. If there's nothing to work from, stop and ask.
## Tracker detection
Check the git remote of the current repo to pick the right tool:
- `github.com` → use `gh issue create` (already on user's allowlist).
- `gitea.*` or any other Gitea host → use `tea issues create` if available; otherwise prompt the user to file manually or hit the Gitea API with `curl` (requires a token — ask first).
- No remote / detached → draft only, do not offer to create.
Run `git remote get-url origin` to detect. Mention the detected tracker in your draft preamble so the user can correct it.
## How to slice it
One issue per **demoable end-to-end behavior**. Work through the PRD this way:
1. **Identify the layers.** From the PRD, list every integration layer this feature touches (e.g. DB → service → API → UI → tests; or sensor → broker → parser → store → dashboard). Write this list in the draft preamble so the user can sanity-check it.
2. **Pick the first slice = walking skeleton.** The simplest user-observable behavior that exercises every layer. One signal, one happy path, one button. It should feel embarrassingly small. That's correct.
3. **Order the rest by depth, not by layer.** Each subsequent slice adds one new user-visible behavior or one new edge case, still cutting through all layers. Examples of "next slice":
- The same flow for a second input type (second measurement, second user role, second file format).
- An error case made visible end-to-end (validation error → API 4xx → UI shows it).
- A non-functional bar made observable end-to-end (add the metric, the alert, and the dashboard tile in one slice).
4. **Absorb prerequisites into the slice that needs them.** A schema migration, a new dependency, a config change — these ride along inside the first slice that requires them, scoped to *just* what that slice needs. They are not separate "infra issues" filed ahead of time.
5. **Open Questions from the PRD** → separate **spike** issues, timeboxed (default 1 day), with definition-of-done = "decision documented in [link]". Spikes are the one exception to the vertical-slice rule because they exist to remove unknowns, not to ship behavior.
6. **Out-of-scope items** → do **not** file. Mention once in the preamble as "explicitly skipped per PRD".
Right-size: if a slice would take >3 days of focused work, it's not thin enough — narrow the behavior (one signal instead of three, one happy path instead of all error cases) rather than splitting it horizontally. If you find yourself wanting to write "issue 1: backend, issue 2: frontend", stop and reslice.
## Issue format
Each issue is:
```
### <number>. <title>
**Title:** <imperative, ≤72 chars. Names the end-to-end behavior, not the layer. "Show live flow rate on dashboard for FT-001" not "Add InfluxDB write for flow sensors">
**Labels:** <comma-separated. Suggest from: slice, spike, infra, docs, blocked, good-first-issue>
**Depends on:** <issue numbers in this list, or "none". Most slices should be "none" — if everything depends on slice 1, that's a smell that slice 1 is doing too much>
**Estimate:** <S / M / L — S=½ day, M=12 days, L=3 days. Anything >L means reslice thinner, not split horizontally>
**Slice — layers touched**
<One line listing every layer this issue crosses, e.g. `schema → ingest service → API → UI → integration test`. Confirms the slice is actually vertical. If the list has only one layer, this isn't a slice — go back and reframe.>
**Context**
<13 sentences. Why this exists, linking back to the PRD section. Don't restate the whole PRD.>
**Scope**
- <bullet of what's in — phrased as behavior, not tasks. "Posting valid form persists row and shows success toast" not "write controller method">
- <bullet of what's in>
**Out of scope**
- <bullet — call out the next slice that *will* handle the thing you're deferring, so reviewers see it's not forgotten. Skip the block only if there's no real risk of scope creep.>
**Acceptance criteria**
- [ ] <end-to-end testable criterion — observable at the outermost layer. "Hitting POST /x with body Y returns 201 and the new row appears on the dashboard within 5s" beats "row exists in table">
- [ ] <testable criterion>
- [ ] <testable criterion>
**Slice check** ✓ / ⚠
<One short block per issue that you fill in yourself before presenting. Walk the layer inventory and mark each layer as covered or deferred. Example:
- schema: ✓ adds `flow_rate` column
- ingest service: ✓ parses one MQTT topic
- API: ✓ GET /sensors/FT-001 returns latest reading
- UI: ✓ dashboard tile shows value, auto-refresh 5s
- integration test: ✓ end-to-end happy path
- alerting: ⚠ deferred to slice #4 (out of scope, by design)
If any layer from the inventory is neither covered nor explicitly deferred to a named later slice, mark the issue with ⚠ overall and fix it before presenting. The user sees this block — it's the visible proof the slice is complete.>
**Notes** (optional)
<Pointers to files, prior art, gotchas surfaced during /grill-me. Skip if nothing useful.>
```
Quality bar:
- Acceptance criteria must be checkable by reading them. "Works correctly" is not a criterion; "POST /foo with body X returns 201 and persists row in table Y" is.
- Title is imperative and specific. "Auth" is bad; "Add JWT validation to /api/v1 middleware" is good.
- Context links *back* to the PRD ("Implements REQ-3 from PRD §6.1"). Don't re-justify the feature.
## Self-audit before presenting
After drafting all issues, **before showing them to the user**, run this audit and fix anything that fails. Do not skip it — the audit is the difference between a backlog that actually ships end-to-end and one that papers over gaps.
**Per-issue checks:**
1. Does the `Slice — layers touched` line include every layer from the inventory, or explicitly defer the missing ones to a later, named slice?
2. Does every layer in the `Slice check` block have a ✓ or a ⚠-with-reason? No silent omissions.
3. Is at least one acceptance criterion observable at the *outermost* layer (the one a user or operator sees)? If all criteria are internal (DB rows, log lines), the slice isn't actually end-to-end.
4. Does the title name a behavior, not a layer? Reject "Add InfluxDB write…"; accept "Show flow rate on dashboard…".
5. Is the slice independently demoable — could you record a 30-second clip showing it work, without depending on a sibling issue?
**Whole-PRD coverage check** (build a coverage matrix in your head, then render it in the preamble — see below):
1. Every functional requirement in the PRD maps to at least one slice that *fully delivers* it (or to a clearly named later slice). No requirement is left half-covered across multiple slices that all defer the last mile.
2. Every non-functional requirement (perf, security, observability) is anchored to a specific slice — even if it's a small thread inside a larger slice. Don't let NFRs float.
3. Every PRD Open Question has a spike issue.
4. Every Out-of-scope item is mentioned once in the preamble — not silently dropped.
5. The union of all slices' `layers touched` covers the full layer inventory. If a layer never appears, either the feature doesn't need it (and the inventory was wrong — fix it) or you missed a slice.
If any check fails, **fix the draft before presenting it**. Don't show the user a draft you know is incomplete and expect them to catch it.
After the audit passes, include a short **Coverage matrix** at the top of the draft so the user can verify too:
```
Coverage matrix:
REQ-1 (functional) → slice #1, #3
REQ-2 (functional) → slice #2
NFR p95 < 200ms → slice #2 (perf test)
NFR observability → slice #1 (metrics + dashboard)
Open Q: which auth? → spike #S1
Out of scope: SSO → not filed (per PRD §10)
Layer inventory: schema → service → API → UI → tests → metrics
Layers in slices: schema(#1,#3) service(#1,#2,#3) API(#1,#2,#3) UI(#1,#3) tests(all) metrics(#1)
```
If the matrix surfaces a gap mid-presentation, stop and revise — don't ask the user to accept a known-incomplete backlog.
## Flow
1. Read the PRD (from chat or file).
2. Detect the tracker; note it in one line at the top: `Tracker: gitea.wbd-rd.nl/RnD/infra (via tea CLI)` or similar.
3. Draft the issues (do not present yet).
4. **Run the self-audit above.** Fix anything that fails. Repeat until clean.
5. Output the draft: tracker line, layer inventory, coverage matrix, then the numbered issues (each with its inline `Slice check` block), then a "Dependency graph:" block if there are cross-issue blockers.
6. **Stop.** Ask: "Looks right? Reply 'create' to file them, 'edit N: <change>' to revise a specific issue, or 'skip N' to drop one."
7. On `create`: file the issues using the detected tracker's CLI, in dependency order so blocker references resolve. After each one, print the issue number and URL. If a command fails, stop and surface the error — do not continue blindly.
8. After creation, print a final summary: `Filed N issues: #123, #124, …`.
## Safety
Filing issues is visible to teammates. Never create issues without an explicit "create" / "file them" / "go ahead" from the user — drafting and showing the list does not count as approval. If the user said something ambiguous like "ok" or "looks good", confirm once more before creating.
If the tracker requires auth and the credential isn't present (e.g. no `GITEA_TOKEN`, `gh auth status` fails), stop and tell the user what's needed. Don't try to work around it.
## What not to do
- **Don't slice horizontally.** No "issue 1: database, issue 2: API, issue 3: UI". If your draft looks like a layer cake, reslice.
- **Don't front-load prerequisites as separate issues.** The migration, the new dependency, the config change ride inside the slice that needs them.
- Don't file the PRD itself as an issue. The PRD is the source; issues are the work.
- Don't create a giant "Epic: <feature>" tracking issue unless the user asked for one. Most teams already have milestones or projects for that.
- Don't pad issues with restated PRD text. Link, don't copy.
- Don't assign issues, set milestones, or add to projects unless the user told you which. Leave assignment empty.
- Don't add comments like "Generated from PRD by Claude" to the issue body. The issues stand on their own.

View File

@@ -0,0 +1,59 @@
---
name: prd
description: Write a product requirements document for a feature or initiative. Designed to follow a /grill-me session — synthesizes what the grilling exposed (the real problem, the gaps, the tradeoffs the user committed to) into a sharp PRD. Also works standalone. Use when the user invokes /prd, asks for a "PRD", "product requirements", or says something like "now write this up" after a grilling.
---
# PRD — Product Requirements Document
**Mode: TOGETHER (human-in-the-loop).** The PRD encodes decisions and tradeoffs the user owns. Draft from context, but expect the user to review and edit. The skill *can* draft AFK once a grilling has already happened (that's most of the input), but the final document needs the user's eyes before it feeds `/prd-to-issues`.
You are now a senior PM writing a PRD that engineering will actually use. The job is to lock down what's being built, why, and what success looks like — not to sell the idea or pad it with strategy slides.
## Continuity with grill-me
If a `/grill-me` session preceded this in the current conversation, mine it as primary input. The grilling already exposed:
- What the user *actually* knows vs. is hand-waving
- Which constraints they committed to (real) vs. which they ducked (open question)
- Edge cases that came up and how they answered
The PRD should reflect that. Things the user nailed go in as firm requirements. Things they hedged on go in **Open Questions** with the specific gap named — don't paper over them. If a tradeoff was explicitly chosen during the grilling, write it as a decision, not a question.
If there was no preceding grilling, ask one question first: "What's the feature, who's it for, and what's the deadline (or 'none')?" Then proceed.
## Structure
Produce the PRD as a single markdown document. Use exactly these sections, in this order. Skip a section only if it would be empty — never include a section just to write "N/A".
1. **Title & one-line summary** — the feature name and a sentence a stranger could understand.
2. **Problem** — what's broken or missing today, who it hurts, evidence it matters. No solution talk yet.
3. **Goals** — 25 bullets, each a concrete outcome (not an activity). "Reduce X by Y" beats "improve X".
4. **Non-goals** — what this explicitly will *not* do. This section is load-bearing; do not skip it. Pull from things the user pushed back on or de-scoped during the grilling.
5. **Users & scenarios** — who uses it, in what situation. 13 concrete scenarios written as "When X, the user does Y to achieve Z." No personas with names and hobbies.
6. **Requirements**
- **Functional** — numbered list. Each requirement is testable. "The system shall…" or "Given X, when Y, then Z." If a requirement can't be verified by reading it, rewrite it.
- **Non-functional** — performance budgets, security/privacy, scale, accessibility, observability. Numbers where possible.
7. **Constraints & dependencies** — what's fixed (existing systems, stack choices, deadlines, headcount) and what this depends on shipping first.
8. **Success metrics** — how we'll know it worked, with a target and a measurement source. "Adoption" is not a metric; "≥40% of weekly active X use the feature within 8 weeks, measured via event Y" is.
9. **Open questions** — explicit unknowns with an owner and a deadline-to-resolve where possible. This is where grilling gaps land.
10. **Out of scope** — same energy as Non-goals, but for things that *could* be in a v2. One bullet each, no justification needed.
## Tone & quality bar
- Specific over comprehensive. A 1-page PRD that engineers can build from beats a 6-page one they skim.
- Write to engineers, not execs. Skip the market-sizing, the "why now", the strategy paragraph. The Problem section is enough motivation.
- Every requirement must be testable. If you can't write the test, the requirement is too vague.
- Prefer numbers over adjectives. "Fast" is meaningless; "p95 < 200ms" is a contract.
- Call out the tradeoff the user is making, especially when they made it deliberately during grilling. Make it visible so reviewers can't accidentally undo it.
- Don't invent. If the grilling didn't establish a number, deadline, or stakeholder, leave it as an Open Question — don't fabricate one to look complete.
## Output mode
Default: write the PRD inline in the chat as markdown. If the user said "save it" or "write to file", write it to `docs/prd/<short-kebab-name>.md` (create the directory if missing). Confirm the path after writing.
## What not to do
- No emojis, no excessive bold, no marketing voice. This is an engineering document.
- No "Background" section that retells history. Problem is enough.
- No "Phases" or "Rollout Plan" unless the user asked — that's a separate doc.
- Don't ask clarifying questions mid-draft. If grilling didn't cover it and you can't infer it, it goes in Open Questions.
- Don't grade or comment on the idea. Write the PRD for the feature as briefed.

View File

@@ -0,0 +1,65 @@
---
name: prototype
description: Build a throwaway spike to falsify or confirm a single risky assumption. Code lives in .prototypes/ (gitignored) and is never promoted to the main codebase. Reports findings as evidence that feeds /prd. Use when the user invokes /prototype, says "spike X", "throwaway test for Y", "can we actually do Z" — typically after /research surfaces an Open Unknown.
---
# Prototype — throwaway spike
**Mode: MOSTLY TOGETHER.** The build and run go AFK, but the user picks the assumption to test and decides what the findings mean. The output is *evidence*, not production code.
You are now an engineer running a time-boxed spike to learn one thing. The point is to falsify or confirm an assumption fast — not to build a feature, not to produce code anyone will reuse.
## Hard rules
1. **One assumption per prototype.** If the user gives you two, ask which matters most; the other can be a second prototype.
2. **The assumption must be falsifiable.** "Will it be fast?" → no. "Can Node-RED sustain 1k msg/s to InfluxDB on the dev VM for 10 min?" → yes. If the user's claim isn't falsifiable, refuse and ask for a sharper one before building anything.
3. **Throwaway means throwaway.** Code lives in `<repo-root>/.prototypes/<short-name>/` only. The directory is gitignored (add it as the first step if it isn't). Nothing in `.prototypes/` is ever committed to the main codebase. No exceptions.
4. **Time-box.** Default budget: 30 minutes of work and ≤200 LOC. If the user gave a different budget, use that. When you blow through, stop and report whatever you've got.
## Steps
1. **Restate the assumption** in falsifiable form. Show it to the user. Wait one turn for confirmation or correction — this is the only mid-skill checkpoint.
2. **Pick the minimum viable test.** Options:
- **Code spike** — throwaway script that exercises the question. Most common.
- **Reading spike** — deep read of a library/spec/codebase, no code. Use when the question is "does X support Y" and the docs would tell you.
- **Manual integration spike** — run a command, hit an endpoint, observe. Use when the question is about a real service's behavior.
3. **Set up the dir.**
```bash
ROOT=$(git rev-parse --show-toplevel)
mkdir -p "$ROOT/.prototypes/<name>"
grep -qxE '\.prototypes/?' "$ROOT/.gitignore" 2>/dev/null || echo '.prototypes/' >> "$ROOT/.gitignore"
```
4. **Build the smallest thing that tests the assumption.** Resist polish. No tests on the prototype itself, no error handling, no docs, no abstractions. Hardcode values. Inline everything.
5. **Run it.** Capture output. If it crashes in a way that's *about* the assumption (e.g. memory blows up at 1k msg/s), that's a finding — not a bug to fix.
6. **Iterate up to the budget.** If a quick adjustment sharpens the test, make it. If you're tempted to refactor or expand scope, stop and report instead.
7. **Report findings.** In chat, using this structure:
```
# Prototype findings: <assumption>
**Verdict:** confirmed | falsified | inconclusive
**Budget used:** <e.g. 22 min, 140 LOC>
## What I did
<23 sentences. What the spike actually exercised.>
## Evidence
<concrete output, numbers, logs, observed behavior. Paste the relevant snippet.>
## What this changes in our mental model
<one paragraph — what we believed before vs. what we believe now>
## Recommended next step
<one sentence — usually /prd, sometimes another /prototype, sometimes "kill this idea">
## Prototype location (do not import)
.prototypes/<name>/
```
## What not to do
- **Don't promote the prototype.** Even if it works beautifully. The next phase is `/prd` → `/prd-to-issues` → `/ship-it` implementing the real thing in production code — not adapting the spike.
- **Don't polish.** Tests, types, lint-clean, comments — none of it. The code is disposable.
- **Don't expand scope.** "Since I'm here, I'll also test…" — no. File the second question for a separate prototype.
- **Don't commit `.prototypes/`.** Ever. If you find yourself wanting to share the prototype, share the *findings*, not the code.
- **Don't ask the user mid-build.** If the assumption was underspecified, you should have caught that in step 1. Once running, run.

View File

@@ -0,0 +1,70 @@
---
name: research
description: Gather external knowledge and codebase context for a topic before committing to a direction. Fans out Explore + WebSearch agents in parallel, synthesizes findings into a research brief, and names open unknowns explicitly. Use when the user invokes /research, says "look into X", "what's the prior art on Y", or "research how Z works" — typically before /grill-me or /prd.
---
# Research — fetch knowledge into a brief
**Mode: MOSTLY TOGETHER.** The fetching and synthesis run AFK (Agent subagents do the legwork). The brief lands in chat; you decide what's worth pursuing. No external state is changed.
You are now a senior engineer doing a focused research pass. Goal: enough knowledge to make a good `/prd` decision later — no more. Do not write code, do not pick a winner, do not write the PRD. Lay out what's known, what's available, and what's still unknown.
## Inputs
The user names a topic. If they didn't give constraints, ask exactly one question: "Any constraints I should anchor against (existing stack, deadline, must-use library)?" Then proceed.
## How to research
1. **Decompose the topic into 35 specific questions.** Show these in chat before fetching — gives the user a chance to reroute if you mis-framed it.
2. **Fan out in parallel** using the Agent tool. Launch concurrently in a single message:
- **Explore agent** — codebase patterns, prior art in this repo, related modules. Question: "Does this repo already do something like X? Where? What patterns does it use?"
- **general-purpose agent (with WebSearch)** — external docs, library options, well-known design patterns, published case studies. Question: "What are the established approaches to Y? What libraries handle Z?"
- Optional third agent for git/PR history if the topic has a long lineage in this codebase.
3. **Synthesize, don't dump.** When agents report back, write a brief — not a transcript.
## Output
Inline by default, in this exact structure:
```
# Research brief: <topic>
## Questions
1. <decomposed question>
2. ...
## What's already in this codebase
- <finding> (path/to/file.ts:42)
- ...
## External options
- **<option>** — <one-line eval. when it fits, when it doesn't>
- ...
## Prior art
- <link> — <one-line takeaway>
- ...
## Open unknowns
- <thing no source can answer; candidate for /prototype>
- ...
## Recommended next step
<one sentence>
```
Say "save it" → write to `docs/research/<short-kebab-name>.md`.
## Quality bar
- Specific over comprehensive. A 1-page brief that surfaces the real decision beats a 5-page survey.
- Cite sources for every claim. `file:line` for codebase, URL for external. No floating assertions.
- Name what you don't know. If a question can't be answered from sources, that's an Open Unknown, not a gap to paper over with confident-sounding speculation.
- Don't recommend a winner among external options. Surface tradeoffs; `/prd` picks.
## What not to do
- Don't write code. Not even illustrative snippets. The output is a brief, not a sketch.
- Don't open files yourself to skim — let the Explore agent do that. Synthesizing is your job.
- Don't fabricate. If WebSearch returns nothing useful, say "no relevant prior art found" instead of inventing one.
- Don't make product decisions. "Should we use X or Y?" → both, with tradeoffs, then "your call."

View File

@@ -0,0 +1,115 @@
---
name: ship-it
description: AFK autopilot. Drives a shell loop that works through every ready issue in the tracker (GitHub via gh, Gitea via tea), implementing each vertical slice end-to-end and committing per issue. Status streams to the terminal so the human can tail progress locally and Ctrl-C anytime. The shell is the loop; each iteration dispatches one fresh headless Claude run to ship one issue. Use when the user invokes /ship-it, says "go AFK on this", "work the backlog", "ralph the issues", or "ship everything".
---
# Ship It — AFK backlog autopilot
**Mode: AFK.** No human in the loop. Does not ask questions mid-run. If a slice is undecidable, the iteration labels the issue `needs-decision` and the loop moves on. The human gets one summary at the end, not chatter during.
## How this works (read before invoking)
The actual loop runs in a shell script: `.claude/skills/ship-it/loop.sh`. **The shell is the loop**, not you. Each iteration shells out to a fresh, headless `claude -p` invocation that processes exactly one issue using `.claude/skills/ship-it/iterate.md` as its prompt. Three reasons this design beats "LLM keeps going inside one session":
1. **Fresh context per issue.** No drift, no accumulated history bloating the window.
2. **Visible in the terminal.** Progress streams to stdout and tees to a log file. The human can tail it from another shell, see commits land, and Ctrl-C cleanly.
3. **Survives session close.** Closing the interactive Claude window doesn't kill the loop. Re-attach by tailing the log.
## Files
- `loop.sh` — orchestrator. Tracker detection, preflight, dispatch loop, status output, stop conditions, summary.
- `iterate.md` — the prompt passed to each per-issue headless Claude. Read it; it defines what "shipped" means.
- `SKILL.md` — this file. When the user invokes `/ship-it`, you bootstrap and hand off.
## When the user invokes /ship-it
You (the interactive Claude) do the bootstrap, not the work. Concretely:
1. **Preflight in chat** (catches the obvious failures before the script runs):
- `git status --porcelain` empty?
- On `main` (or `$SHIP_IT_TRUNK`)? Up-to-date with origin?
- `gh auth status` (or tea token) returns 0?
- `gh issue list --state open --label slice | wc -l` ≥ 1?
2. **Show the plan** in one short block: tracker host, trunk branch, count of ready issues, the first 3 issue titles, the log path. Nothing more.
3. **Ask one question:** "Start? Reply `go`." This is the *only* human-in-the-loop checkpoint — kicking off AFK work is a real commitment, deserves an explicit ok.
4. **On `go`:** run the loop in the foreground so the user sees live output:
```
bash .claude/skills/ship-it/loop.sh
```
Do not background it. Do not pipe through anything that buffers. The user can Ctrl-C.
5. **While it runs:** stay silent. Don't interject. Don't "monitor" by re-reading logs in chat — the user has the terminal.
6. **When it exits:** read the final `==== ship-it summary ====` block from the log file, present it once with concrete next steps ("2 issues are `needs-decision` — open them to answer their questions?").
## Following progress
The script logs to stdout AND tees to `.ship-it-logs/run-<RUN_ID>.log`. Tail from another terminal:
```bash
tail -f .ship-it-logs/run-*.log
```
Per-issue detail (everything the headless Claude did for that one issue) is in `.ship-it-logs/iter-<RUN_ID>-<ISSUE>.log` — useful for debugging a failed iteration.
Commits land in git as the loop runs. Watch with:
```bash
watch -n 5 'git log --oneline -10 origin/main'
```
## Config (env vars, override before invoking)
| Var | Default | Purpose |
|---|---|---|
| `SHIP_IT_MAX` | 50 | Hard cap on iterations per run |
| `SHIP_IT_MAX_FAIL` | 3 | Consecutive failures before stop |
| `SHIP_IT_TRUNK` | `main` | Trunk branch name |
| `SHIP_IT_TIMEOUT` | `30m` | Per-issue timeout (kills the headless claude) |
| `SHIP_IT_LOG_DIR` | `<repo>/.ship-it-logs` | Where logs go |
## What each iteration does (per `iterate.md`)
For one issue: read it → branch from trunk → write failing e2e test at the outermost layer → implement layer by layer until the test passes → run the full suite → outermost-layer smoke check → commit (one commit, message ends `Closes #N`) → push → open PR with acceptance-criteria checkboxes + smoke evidence → wait for CI → merge if green and branch protection allows, else leave open for review → return to trunk → emit `ITERATION_RESULT:` line for the loop.
**Commit per issue:** yes, exactly. One commit per slice, referenced to the issue, lands on the branch before the PR opens. The slice scope was made small in `/prd-to-issues` precisely so this is one tight commit, not a series.
## Stop conditions (in priority order)
1. **User Ctrl-C** → trap catches SIGINT, current step finishes cleanly, summary prints, exit 130.
2. **Backlog empty** (no ready issues) → exit 0.
3. **Three consecutive hard failures** → exit 1. Something systemic — bad dependency, branch protection blocking, flaky env. Surfaces for human review.
4. **Precondition violated mid-run** → exit non-zero with reason.
## What "ready" means (the loop's filter)
An issue is `ready` iff:
- State is open
- Has label `slice` (filed by `/prd-to-issues`)
- Does NOT have label `blocked`, `needs-decision`, or `ci-failed`
- Is not a spike (spikes deliver decisions, not code — humans handle those)
Issues are processed in number order — walking-skeleton first, as `/prd-to-issues` ordered them.
## Safety boundaries
The headless Claude is launched with a tool allowlist that excludes destructive operations. It cannot:
- Force-push or rewrite shared history
- Bypass branch protection or skip CI hooks (`--no-verify`, `--admin`)
- Auto-merge red or pending PRs (the iterate prompt forbids it, and CI gates back it up)
- Modify CI/CD config or IaC unless the slice's `Slice — layers touched` line explicitly names that layer
- Close issues without the outermost-layer smoke check passing
- Assign people or change milestones/projects
If something tries to push past these in practice (e.g. a slice "needs" a CI change to pass), it should fail the iteration with `needs-decision` and let a human approve the scope expansion.
## What not to do
- **Don't drive the loop yourself by reading issues and implementing them inline.** The shell is the loop. If you're tempted to "just do this one in chat," stop and run the script.
- **Don't background the script** so the user can keep chatting with you. The output IS the value. The user wants to watch it work.
- **Don't summarize between iterations.** Chatter belongs in the final summary, not after each commit.
- **Don't tag the user in PR/issue comments** during the run. They're not in the loop until the script exits.
- **Don't restart a failed iteration manually.** The loop's `needs-decision` and `ci-failed` labels are how failures stay in the tracker for human triage. Manual restart skips that.
## How this fits the chain
`/grill-me <feature>` (together) → `/prd` (together) → `/prd-to-issues` (mostly together, file step needs `create`) → `/ship-it` (AFK). The four-skill arc takes a vague feature idea to merged code with one human checkpoint per phase boundary.

View File

@@ -0,0 +1,70 @@
# ship-it iterate — one issue, end-to-end
You are running ONE iteration of the ship-it AFK loop. Implement, verify, and ship exactly one issue, then exit. The outer shell loop will pick the next one.
**Mode: AFK.** Do not ask questions. If the issue is genuinely undecidable from its body + linked PRD + grilling notes already in the issue or repo, drop a comment on the issue with the specific question, label it `needs-decision`, and exit with status=needs-decision. Do not guess at user intent.
Variables provided below this prompt: `ISSUE_NUMBER`, `TRACKER_CLI` (`gh` or `tea`), `TRUNK_BRANCH`, `REPO_ROOT`.
## Steps
1. **Read the issue.**
- GitHub: `gh issue view $ISSUE_NUMBER --json number,title,body,labels`
- Gitea: `tea issues $ISSUE_NUMBER --output json`
- Parse: `Slice — layers touched`, `Scope`, `Acceptance criteria`, `Slice check`, `Notes`, linked PRD path.
- If `Acceptance criteria` is missing or non-testable → exit status=needs-decision with reason "acceptance criteria not testable".
2. **Branch from latest trunk.**
`git fetch origin && git switch -c "slice/${ISSUE_NUMBER}-<short-kebab-slug>" "origin/$TRUNK_BRANCH"`
3. **Write the failing e2e test first.** Anchored at the OUTERMOST layer named in `Slice — layers touched` (HTTP endpoint, UI smoke, dashboard query, log assertion — whatever the acceptance criterion observes). Run it. Confirm it fails for the right reason. If you can't write an e2e test for this slice, that's a sign the acceptance criterion isn't really observable end-to-end → exit status=needs-decision.
4. **Implement layer by layer.** Walk the `Slice — layers touched` list. Make the minimal change at each layer to satisfy the slice — do not gold-plate, do not refactor adjacent code, do not "improve" things outside scope. Re-run the e2e test after each layer change.
5. **Run the broader test suite.** Catch regressions caused by the slice. Fix any test that was green before and is now red — do not skip or mark tests. If a test was already red before your changes, leave it (note in PR body).
6. **Outermost-layer smoke check.** The 30-second-demo check: hit the endpoint with curl, query the dashboard, tail the log, load the page. Observe what the acceptance criterion observes. Capture the output (curl response body, log snippet, query result) — you'll paste it into the PR body as evidence.
7. **Commit.** One commit per slice (or a tight series — no WIP commits, no fixup commits, no "address review" before review exists). Read the repo's recent `git log` to match commit style. Message ends with `Closes #${ISSUE_NUMBER}`.
8. **Push and open PR.**
- GitHub: `git push -u origin HEAD && gh pr create --fill`
- Gitea: `git push -u origin HEAD && tea pr create --title "..." --description "..."`
- PR body must include:
- Each acceptance criterion as a checked `- [x]` line.
- The smoke-check evidence (curl output / log snippet / screenshot path) in a fenced block.
- `Closes #${ISSUE_NUMBER}` (so the issue auto-closes on merge).
9. **Wait for CI and decide merge.**
- Poll: `gh pr checks --watch` (or `tea pr status`).
- **All green + branch protection allows direct merge** → `gh pr merge --squash --delete-branch`. Verify the merge commit landed on trunk.
- **All green + branch protection requires human review** → leave PR open. Comment `Ready for review — all acceptance criteria verified, smoke check passed.` on the issue. Exit status=shipped with the PR number.
- **Red CI** → one fix-and-push cycle. Read the failing log, fix the actual cause (do not skip the test). If still red after the second attempt: label issue `ci-failed`, comment with the CI excerpt, leave PR open, exit status=failed with reason "ci-red".
10. **Return to trunk.** `git switch $TRUNK_BRANCH && git pull --ff-only`. If the slice was merged, run the smoke check one more time against integrated trunk. If it fails there → revert the merge, label `regression`, exit status=failed with reason "regression-on-trunk".
## Boundaries
- Never force-push, never rewrite shared history, never delete branches you didn't create.
- Never bypass branch protection (`--admin`) or skip CI hooks (`--no-verify`).
- Never auto-merge a PR whose CI is red or pending.
- Never close an issue without the outermost-layer smoke check passing.
- Never modify CI/CD config, IaC, or production data unless the slice's `layers touched` explicitly names that layer.
- Never invent acceptance criteria. If they're vague, label `needs-decision`.
- Never assign issues or change milestones.
## Final output line
The shell loop greps for this exact line to determine outcome. Print it as the LAST line before exiting, on its own line, no decoration:
```
ITERATION_RESULT: status=<shipped|failed|needs-decision> issue=#<N> pr=<#N|none> reason=<short single-line reason>
```
Examples:
```
ITERATION_RESULT: status=shipped issue=#142 pr=#287 reason=merged-to-main
ITERATION_RESULT: status=shipped issue=#143 pr=#288 reason=open-for-review
ITERATION_RESULT: status=failed issue=#144 pr=#289 reason=ci-red-after-retry
ITERATION_RESULT: status=needs-decision issue=#145 pr=none reason=acceptance-criteria-not-testable
```

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# ship-it AFK loop — works through every ready issue end-to-end.
# See SKILL.md for design. Ctrl-C to stop; partial work is preserved on disk.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "not in a git repo"; exit 1; }
# ---- config (env-overridable) ----
MAX_ITERATIONS="${SHIP_IT_MAX:-50}"
MAX_CONSECUTIVE_FAILURES="${SHIP_IT_MAX_FAIL:-3}"
TRUNK_BRANCH="${SHIP_IT_TRUNK:-main}"
ITERATION_TIMEOUT="${SHIP_IT_TIMEOUT:-30m}" # per-issue cap
LOG_DIR="${SHIP_IT_LOG_DIR:-$REPO_ROOT/.ship-it-logs}"
mkdir -p "$LOG_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
LOG_FILE="$LOG_DIR/run-$RUN_ID.log"
# ---- logging ----
log() {
local ts; ts="$(date -u +%H:%M:%S)"
printf '[%s] %s\n' "$ts" "$*" | tee -a "$LOG_FILE"
}
die() { log "FATAL: $*"; exit 1; }
# ---- graceful interrupt ----
INTERRUPTED=0
on_interrupt() {
INTERRUPTED=1
log ""
log "interrupt received — finishing current step cleanly, then stopping"
}
trap on_interrupt INT
# ---- tracker detection ----
ORIGIN_URL="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)"
if [[ "$ORIGIN_URL" == *"github.com"* ]]; then
TRACKER_CLI="gh"
command -v gh >/dev/null || die "gh CLI not installed"
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
list_ready_issues() {
gh issue list --state open --label slice --limit 100 \
--json number,title,labels \
--jq '[.[] | select(.labels | map(.name) | (contains(["blocked"]) or contains(["needs-decision"]) or contains(["ci-failed"])) | not)] | sort_by(.number)'
}
elif [[ "$ORIGIN_URL" == *"gitea"* ]]; then
TRACKER_CLI="tea"
command -v tea >/dev/null || die "tea CLI not installed (Gitea repo detected) — install tea or switch to a GitHub remote"
list_ready_issues() {
tea issues list --state open --output json 2>/dev/null \
| jq '[.[] | select((.labels // []) | map(.name) | (contains(["blocked"]) or contains(["needs-decision"]) or contains(["ci-failed"])) | not) | select((.labels // []) | map(.name) | contains(["slice"]))] | sort_by(.index)'
}
else
die "unknown tracker for origin: '$ORIGIN_URL' (need github.com or gitea.*)"
fi
# ---- preflight ----
cd "$REPO_ROOT"
[[ -z "$(git status --porcelain)" ]] || die "git tree is dirty — commit or stash before starting"
CURRENT_BRANCH="$(git branch --show-current)"
[[ "$CURRENT_BRANCH" == "$TRUNK_BRANCH" ]] || die "not on $TRUNK_BRANCH (on '$CURRENT_BRANCH')"
git fetch origin "$TRUNK_BRANCH" >/dev/null 2>&1 || die "git fetch failed"
LOCAL_SHA="$(git rev-parse HEAD)"
REMOTE_SHA="$(git rev-parse "origin/$TRUNK_BRANCH")"
[[ "$LOCAL_SHA" == "$REMOTE_SHA" ]] || die "$TRUNK_BRANCH not up-to-date with origin (pull first)"
command -v claude >/dev/null || die "claude CLI not on PATH"
# ---- banner ----
log "ship-it run $RUN_ID"
log " tracker: $TRACKER_CLI ($ORIGIN_URL)"
log " trunk: $TRUNK_BRANCH @ ${LOCAL_SHA:0:8}"
log " log: $LOG_FILE"
log " config: max_iter=$MAX_ITERATIONS, max_fail=$MAX_CONSECUTIVE_FAILURES, timeout=$ITERATION_TIMEOUT"
log ""
ITERATE_PROMPT_TEMPLATE="$(cat "$SCRIPT_DIR/iterate.md")"
SHIPPED=()
FAILED=()
NEEDS_DECISION=()
CONSECUTIVE_FAILURES=0
ITERATION=0
# ---- main loop ----
while (( ITERATION < MAX_ITERATIONS )); do
(( INTERRUPTED )) && break
ITERATION=$((ITERATION + 1))
READY_JSON="$(list_ready_issues 2>/dev/null || echo '[]')"
READY_COUNT="$(echo "$READY_JSON" | jq 'length' 2>/dev/null || echo 0)"
if (( READY_COUNT == 0 )); then
log "backlog empty — stopping"
break
fi
ISSUE_NUM="$(echo "$READY_JSON" | jq -r '.[0].number // .[0].index')"
ISSUE_TITLE="$(echo "$READY_JSON" | jq -r '.[0].title')"
log "─────────────────────────────────────────────────────────────"
log "iter $ITERATION | #$ISSUE_NUM \"$ISSUE_TITLE\" ($READY_COUNT ready) → starting"
ITER_LOG="$LOG_DIR/iter-$RUN_ID-$ISSUE_NUM.log"
PROMPT="$ITERATE_PROMPT_TEMPLATE
## Variables for this iteration
- ISSUE_NUMBER=$ISSUE_NUM
- TRACKER_CLI=$TRACKER_CLI
- TRUNK_BRANCH=$TRUNK_BRANCH
- REPO_ROOT=$REPO_ROOT
Begin."
ITER_START="$(date +%s)"
set +e
timeout "$ITERATION_TIMEOUT" claude -p "$PROMPT" \
--allowed-tools "Bash,Edit,Write,Read,Grep,Glob,WebFetch" \
--output-format text \
>"$ITER_LOG" 2>&1
CLAUDE_EXIT=$?
set -e
ITER_END="$(date +%s)"
ITER_DURATION=$((ITER_END - ITER_START))
RESULT_LINE="$(grep -E '^ITERATION_RESULT:' "$ITER_LOG" | tail -1 || true)"
STATUS="$(echo "$RESULT_LINE" | sed -n 's/.*status=\([^ ]*\).*/\1/p')"
PR_FIELD="$(echo "$RESULT_LINE" | sed -n 's/.*pr=\([^ ]*\).*/\1/p')"
REASON="$(echo "$RESULT_LINE" | sed -n 's/.*reason=\(.*\)/\1/p')"
if (( CLAUDE_EXIT == 124 )); then
STATUS="failed"
REASON="timeout after $ITERATION_TIMEOUT"
fi
case "$STATUS" in
shipped)
log "iter $ITERATION | #$ISSUE_NUM ✓ shipped → PR $PR_FIELD (${ITER_DURATION}s)"
SHIPPED+=("#$ISSUE_NUM$PR_FIELD")
CONSECUTIVE_FAILURES=0
;;
failed)
log "iter $ITERATION | #$ISSUE_NUM ✗ failed: $REASON (${ITER_DURATION}s, see $ITER_LOG)"
FAILED+=("#$ISSUE_NUM ($REASON)")
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
;;
needs-decision)
log "iter $ITERATION | #$ISSUE_NUM ? needs-decision: $REASON (${ITER_DURATION}s)"
NEEDS_DECISION+=("#$ISSUE_NUM ($REASON)")
CONSECUTIVE_FAILURES=0
;;
*)
log "iter $ITERATION | #$ISSUE_NUM ! unknown outcome (claude exit=$CLAUDE_EXIT, ${ITER_DURATION}s) — see $ITER_LOG"
FAILED+=("#$ISSUE_NUM (unknown outcome)")
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
;;
esac
if (( CONSECUTIVE_FAILURES >= MAX_CONSECUTIVE_FAILURES )); then
log "$MAX_CONSECUTIVE_FAILURES consecutive failures — stopping for human review"
break
fi
# back to trunk for next iteration
if [[ "$(git branch --show-current)" != "$TRUNK_BRANCH" ]]; then
git switch "$TRUNK_BRANCH" >/dev/null 2>&1 || log " warn: could not return to $TRUNK_BRANCH"
fi
git pull --ff-only origin "$TRUNK_BRANCH" >/dev/null 2>&1 || log " warn: could not fast-forward $TRUNK_BRANCH"
done
# ---- summary ----
log ""
log "==== ship-it summary ===="
log "iterations: $ITERATION"
log "shipped: ${#SHIPPED[@]} ${SHIPPED[*]:-}"
log "failed: ${#FAILED[@]} ${FAILED[*]:-}"
log "needs-decision: ${#NEEDS_DECISION[@]} ${NEEDS_DECISION[*]:-}"
log "log: $LOG_FILE"
if (( INTERRUPTED )); then
log "stop reason: user-interrupt"
exit 130
elif (( CONSECUTIVE_FAILURES >= MAX_CONSECUTIVE_FAILURES )); then
log "stop reason: consecutive-failures"
exit 1
else
log "stop reason: backlog-empty"
exit 0
fi

View File

@@ -11,7 +11,9 @@ node_modules/
# Agent/Claude metadata (not needed at runtime)
.agents/
.claude/
manuals/
# Documentation (not needed at runtime)
wiki/
# IDE
.vscode/
@@ -23,10 +25,3 @@ manuals/
# OS
.DS_Store
Thumbs.db
# Documentation (not needed at runtime)
third_party/
FUNCTIONAL_ISSUES_BACKLOG.md
AGENTS.md
README.md
LICENSE

41
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main, develop, dev-Rene]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
container:
image: node:20-slim
steps:
- name: Install git
run: apt-get update -qq && apt-get install -y -qq git
- name: Checkout with submodules
uses: actions/checkout@v4
with:
submodules: recursive
- name: Rewrite generalFunctions to local path
run: |
sed -i 's|"generalFunctions": "git+https://[^"]*"|"generalFunctions": "file:./nodes/generalFunctions"|' package.json
- name: Install dependencies
run: npm install --ignore-scripts
- name: Lint
run: npm run lint
- name: Test (Jest)
run: npm test
- name: Test (node:test)
run: npm run test:node
- name: Test (legacy)
run: npm run test:legacy

15
.gitignore vendored
View File

@@ -7,4 +7,17 @@ npm-debug.log*
.env.*
# Build artifacts
*.tgz
*.tgz
# Per-session runtime locks (scheduled_tasks, etc.)
.claude/*.lock
# Local tooling env (developer-specific MCP endpoints/tokens)
tools/.env
# Local-only Claude Code state — memory store, IDE marker, per-machine
# conventions file. Never commit, never publish.
.repo-mem/
.codex
CLAUDE.local.md
.prototypes/

77
.gitmodules vendored
View File

@@ -1,37 +1,40 @@
[submodule "nodes/machineGroupControl"]
path = nodes/machineGroupControl
url = https://gitea.wbd-rd.nl/RnD/machineGroupControl.git
[submodule "nodes/generalFunctions"]
path = nodes/generalFunctions
url = https://gitea.wbd-rd.nl/RnD/generalFunctions.git
[submodule "nodes/valveGroupControl"]
path = nodes/valveGroupControl
url = https://gitea.wbd-rd.nl/RnD/valveGroupControl.git
[submodule "nodes/valve"]
path = nodes/valve
url = https://gitea.wbd-rd.nl/RnD/valve.git
[submodule "nodes/rotatingMachine"]
path = nodes/rotatingMachine
url = https://gitea.wbd-rd.nl/RnD/rotatingMachine.git
[submodule "nodes/monster"]
path = nodes/monster
url = https://gitea.wbd-rd.nl/RnD/monster.git
[submodule "nodes/measurement"]
path = nodes/measurement
url = https://gitea.wbd-rd.nl/RnD/measurement.git
[submodule "nodes/diffuser"]
path = nodes/diffuser
url = https://gitea.wbd-rd.nl/RnD/diffuser.git
[submodule "nodes/dashboardAPI"]
path = nodes/dashboardAPI
url = https://gitea.wbd-rd.nl/RnD/dashboardAPI.git
[submodule "nodes/reactor"]
path = nodes/reactor
url = https://gitea.wbd-rd.nl/RnD/reactor.git
[submodule "nodes/pumpingStation"]
path = nodes/pumpingStation
url = https://gitea.wbd-rd.nl/RnD/pumpingStation
[submodule "nodes/settler"]
path = nodes/settler
url = https://gitea.wbd-rd.nl/RnD/settler.git
[submodule "nodes/machineGroupControl"]
path = nodes/machineGroupControl
url = https://gitea.wbd-rd.nl/RnD/machineGroupControl.git
[submodule "nodes/generalFunctions"]
path = nodes/generalFunctions
url = https://gitea.wbd-rd.nl/RnD/generalFunctions.git
[submodule "nodes/valveGroupControl"]
path = nodes/valveGroupControl
url = https://gitea.wbd-rd.nl/RnD/valveGroupControl.git
[submodule "nodes/valve"]
path = nodes/valve
url = https://gitea.wbd-rd.nl/RnD/valve.git
[submodule "nodes/rotatingMachine"]
path = nodes/rotatingMachine
url = https://gitea.wbd-rd.nl/RnD/rotatingMachine.git
[submodule "nodes/monster"]
path = nodes/monster
url = https://gitea.wbd-rd.nl/RnD/monster.git
[submodule "nodes/measurement"]
path = nodes/measurement
url = https://gitea.wbd-rd.nl/RnD/measurement.git
[submodule "nodes/diffuser"]
path = nodes/diffuser
url = https://gitea.wbd-rd.nl/RnD/diffuser.git
[submodule "nodes/dashboardAPI"]
path = nodes/dashboardAPI
url = https://gitea.wbd-rd.nl/RnD/dashboardAPI.git
[submodule "nodes/reactor"]
path = nodes/reactor
url = https://gitea.wbd-rd.nl/RnD/reactor.git
[submodule "nodes/pumpingStation"]
path = nodes/pumpingStation
url = https://gitea.wbd-rd.nl/RnD/pumpingStation
[submodule "nodes/settler"]
path = nodes/settler
url = https://gitea.wbd-rd.nl/RnD/settler.git
[submodule "nodes/coresync"]
path = nodes/coresync
url = https://gitea.wbd-rd.nl/RnD/coresync.git

View File

@@ -1,2 +1,58 @@
# Ignore test files
node_modules/
# === Mirrors .gitignore — same deny list applies when packing for npm,
# kept verbatim so npm pack doesn't fall back to .gitignore silently
# (warning: gitignore-fallback). ===
node_modules/
package-lock.json
*.tgz
*.log
npm-debug.log*
.env
.env.*
.DS_Store
# === Memory + IDE state — NEVER ships (838 MB of .repo-mem alone) ===
.repo-mem/
.codex
.codex/
.claude/
.agents/
CLAUDE.md
CLAUDE.local.md
# === Repo-level dev tooling ===
tools/
docker/
docker-compose.yml
Dockerfile
.dockerignore
.gitea/
eslint.config.js
jest.config.js
scripts/
test/
# === Repo-level docs not needed by Node-RED consumers ===
wiki/
CONTRACTS.md
.gitmodules
# === Per-submodule dev-only trees ===
# `npm pack` at the parent walks the file tree directly and IGNORES each
# submodule's own .npmignore. So we mirror the per-submodule deny lists
# here explicitly, otherwise the root tarball still bundles every test
# tree, wiki, simulation harness, screen recording, etc. — that's how
# the root pack was 175 MB before this file existed.
nodes/*/test/
nodes/*/wiki/
nodes/*/simulations/
nodes/*/tools/
nodes/*/scripts/
nodes/*/CLAUDE.md
nodes/*/CLAUDE.local.md
nodes/*/.claude/
nodes/*/.codex/
nodes/*/.git
nodes/*/.gitignore
nodes/*/.npmignore
nodes/*/.eslintrc*
nodes/*/eslint.config.js

90
CLAUDE.md Normal file
View File

@@ -0,0 +1,90 @@
# EVOLV - Claude Code Project Guide
> **READ FIRST, BEFORE ANY OTHER WORK:**
> [`CONTRACTS.md`](./CONTRACTS.md) — front-door map: where every contract, rule, and standard lives, and how to find them.
## What This Is
Node-RED custom nodes package for wastewater treatment plant automation. Developed by Waterschap Brabantse Delta R&D team. Follows ISA-88 (S88) batch control standard.
## Architecture
Each node follows a three-layer pattern:
1. **Node-RED wrapper** (`<nodeName>.js`) - registers the node type, sets up HTTP endpoints
2. **Node adapter** (`src/nodeClass.js`) - bridges Node-RED API with domain logic, handles config loading, tick loops, events
3. **Domain logic** (`src/specificClass.js`) - pure business logic, no Node-RED dependencies
## Folder & File Layout (READ BEFORE CREATING NEW FILES)
Every per-node file MUST use the folder name **exactly** (case-sensitive). No
abbreviations. Quick reference:
| Path | Required name |
|---|---|
| Entry file | `nodes/<nodeName>/<nodeName>.js` |
| Editor HTML | `nodes/<nodeName>/<nodeName>.html` |
| Node adapter | `nodes/<nodeName>/src/nodeClass.js` |
| Domain logic | `nodes/<nodeName>/src/specificClass.js` |
| Editor JS modules | `nodes/<nodeName>/src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `nodes/<nodeName>/test/{basic,integration,edge}/*.test.js` |
| Example flows | `nodes/<nodeName>/examples/*.flow.json` |
Full rule + serving recipe for `src/editor/`: `.claude/rules/node-architecture.md`.
**Legacy drift to rename when the file is next touched** (do not introduce new
mismatches in the meantime). When renaming, **keep the Node-RED type id
lowercase** (`registerType('mgc', …)` etc.) so deployed flows continue to load —
only the file paths change. `dashboardAPI` was migrated this way on 2026-05-19.
| Node | Currently | Should be |
|---|---|---|
| `machineGroupControl` | `mgc.{js,html}` | `machineGroupControl.{js,html}` |
| `valveGroupControl` | `vgc.{js,html}` | `valveGroupControl.{js,html}` |
## Key Shared Library: `nodes/generalFunctions/`
- `logger` - structured logging (use this, NOT console.log)
- `MeasurementContainer` - chainable measurement storage (type/variant/position)
- `configManager` - loads JSON configs from `src/configs/`
- `MenuManager` - dynamic UI dropdowns
- `outputUtils` - formats messages for InfluxDB and process outputs
- `childRegistrationUtils` - parent-child node relationships
- `coolprop` - thermodynamic property calculations
## Conventions
- Nodes register under category `'EVOLV'` in Node-RED
- Two color systems (don't confuse):
- **Palette swatch** (Node-RED sidebar, set in `<node>.html`) = domain-hue per node — full table in `.claude/rules/node-red-flow-layout.md` §10.0. Changed 2026-05-21; see `.claude/refactor/OPEN_QUESTIONS.md`.
- **Editor-group rectangle** (flow.json `style.fill`) = S88 level (unchanged): Area=#0f52a5, ProcessCell=#0c99d9, Unit=#50a8d9, Equipment=#86bbdd, ControlModule=#a9daee
- Config JSON files in `generalFunctions/src/configs/` define defaults, types, enums per node
- Tick loop is **opt-in per node** — default cadence 1000 ms, but each node sets `static tickInterval` (or skips it). See `.claude/refactor/OPEN_QUESTIONS.md` (2026-05-10 entry) for the design decision
- Output ports + 3-tier architecture + file-naming + `src/editor/` layout: see `.claude/rules/node-architecture.md`
- **Multi-tab demo flows**: see `.claude/rules/node-red-flow-layout.md` for the tab/link-channel/spacing rule set used by `examples/`
- **Output coverage** (every output, every state, every layer): see `.claude/rules/output-coverage.md` — manifest + populated/degraded tests are mandatory for any change that touches Port 0/1/2 keys, function-node fan-outs, telemetry fields, or dashboard widget sources
## Agents and Skills (use them — don't reinvent)
- **Skills** at `.claude/skills/evolv-*/SKILL.md` (15 domain skills) — auto-discovered, invoke via the `Skill` tool. Load them when you need domain reasoning (rotating equipment, biology, telemetry, security, instrumentation, hydraulics, alarms, OT integration, regulatory, quality, commissioning, frontend, …).
- **Subagents** at `.claude/agents/*.md` (10 Claude Code subagents) — spawnable via `Agent(subagent_type: '<name>')`. Use for independent work: `evolv-orchestrator` (multi-domain decomposition + `team` workflows), `mechanical-process-engineer`, `biological-process-engineer`, `instrumentation-measurement`, `node-red-runtime`, `telemetry-database`, `quality-test-engineer`, `commissioning-compliance`, `ot-security-integration`, `general-functions-library`.
- **Routing table**: [`.agents/AGENTS.md`](./.agents/AGENTS.md) maps task patterns → which skill/subagent to invoke.
- **`team` keyword**: when the user says "team", spawn `evolv-orchestrator` (subagent) — it picks specialists, runs an alignment pass, returns one integrated answer.
## Tooling (Docker-first, local now, central later)
Custom EVOLV tooling lives in `tools/` and is intended to run inside the local Docker compose stack (`tools/docker-compose.yml`). **Always prefer these tools over ad-hoc grep/curl/manual checks** — they encode the rules in `.claude/rules/` and catch regressions the human review would miss:
- `tools/flow-lint/` — validates `examples/*.flow.json` against `.claude/rules/node-red-flow-layout.md`. Run before committing any flow change.
- `tools/output-manifest-verify/` — diffs declared Port 0/1/2 keys vs. runtime emissions. Run on any output-shape change.
- `tools/contract-verify/` — diffs `nodes/<n>/CONTRACT.md` vs. `src/commands/index.js`. Run after touching a command registry.
- `tools/wiki-gen/` — regenerates topic-contract + data-model sections of `nodes/<n>/wiki/`. Run after a CONTRACT change.
- `tools/physics-sanity/` — cross-node mass/hydraulic/energy balance assertions. Run as part of `node --test` for cross-node changes.
- **MCP services** (Node-RED admin, InfluxDB, headless browser) live under `tools/mcp/` as Docker services. **Migration note**: these will move to a central MCP server later; the local stack is interim. The Dockerfile + compose entry stays in this repo as the canonical definition.
- **Why use them**: every tool encodes a rule that we've previously discovered through a bug (η-null crash, ui-chart blank renders, output-key drift). Skipping them re-opens those bugs.
## Sources of truth (the canonical files)
- **Front-door map**: [`CONTRACTS.md`](./CONTRACTS.md) — read first; lists every standard and where it lives
- **Platform API shapes** (BaseDomain, BaseNodeAdapter, commands registry, UnitPolicy, …): `.claude/refactor/CONTRACTS.md`
- **Code conventions** (file/function size, comments, naming): `.claude/refactor/CONVENTIONS.md`
- **Per-node module layout**: `.claude/refactor/MODULE_SPLIT.md`
- **Per-node API contract**: `nodes/<n>/CONTRACT.md` + `nodes/<n>/src/commands/index.js` (source of truth for accepted `msg.topic` values)
- **Shared library API**: `nodes/generalFunctions/CONTRACT.md` (exported classes + utilities)
- **Live decisions log**: `.claude/refactor/OPEN_QUESTIONS.md` — append, don't invent
## Development Notes
- No build step required - pure Node.js
- Install: `npm install` in root
- Submodule URLs were rewritten from `gitea.centraal.wbd-rd.nl` to `gitea.wbd-rd.nl` for external access
- Dependencies: mathjs, generalFunctions (git submodule)

148
CONTRACTS.md Normal file
View File

@@ -0,0 +1,148 @@
# EVOLV — Contracts, Rules, and Standards
> **Front door for humans and agents working in this repo.**
> If you only read one file before touching code, read this one. It maps every
> contract, rule, and standard in the EVOLV stack and tells you where each
> lives. Everything else here is a link to a more specific file.
EVOLV is a Node-RED node library for wastewater treatment plant automation,
built by Waterschap Brabantse Delta R&D. All work happens on the `development`
branch across 12 submodules; promotion to `main` is gated by Docker E2E.
---
## 1. Where everything lives
### Platform-wide (EVOLV root)
| What | Where | Read when |
|---|---|---|
| **This map** | `CONTRACTS.md` (this file) | First time, or when orienting |
| **Agent entry-point instructions** | [`CLAUDE.md`](./CLAUDE.md) | Auto-loaded by Claude Code |
| **Active rules** | [`.claude/rules/`](./.claude/rules/) (7 files) | Triggered by `paths:` frontmatter or referenced from `CLAUDE.md` |
| **Platform API contracts** | [`.claude/refactor/CONTRACTS.md`](./.claude/refactor/CONTRACTS.md) | Before changing `generalFunctions` exports or any base class |
| **Code conventions** | [`.claude/refactor/CONVENTIONS.md`](./.claude/refactor/CONVENTIONS.md) | Before writing or editing any file |
| **Per-node concern layout** | [`.claude/refactor/MODULE_SPLIT.md`](./.claude/refactor/MODULE_SPLIT.md) | When adding files to `nodes/<n>/src/` |
| **Wiki page templates** | [`.claude/refactor/WIKI_TEMPLATE.md`](./.claude/refactor/WIKI_TEMPLATE.md) + [`WIKI_HOME_TEMPLATE.md`](./.claude/refactor/WIKI_HOME_TEMPLATE.md) | When editing a per-node wiki page |
| **Live decisions log** | [`.claude/refactor/OPEN_QUESTIONS.md`](./.claude/refactor/OPEN_QUESTIONS.md) | When you spot an ambiguity — append, don't invent |
| **Top-level wiki** | [`wiki/`](./wiki/) (Home, Architecture, Getting-Started, Telemetry, Topology-Patterns, Topic-Conventions, Glossary, Functional-Overview) | When you need a process-level or architecture-level view |
| **Agent skills** | [`.claude/skills/`](./.claude/skills/) (15 domain skills, auto-discovered, invokable via `Skill` tool) | When you need domain reasoning |
| **Spawnable subagents** | [`.claude/agents/`](./.claude/agents/) (10 Claude Code subagents) | When you want to delegate independent work |
| **Routing table** | [`.agents/AGENTS.md`](./.agents/AGENTS.md) | When deciding which specialist to invoke |
| **Improvements backlog** | [`.agents/improvements/IMPROVEMENTS_BACKLOG.md`](./.agents/improvements/IMPROVEMENTS_BACKLOG.md) | When deferring functional work |
### Per-node (`nodes/<nodeName>/`)
| What | Where | Read when |
|---|---|---|
| Node entry instructions for agents | `nodes/<n>/CLAUDE.md` | Auto-loaded when touching files in that subtree |
| **Node API contract** | `nodes/<n>/CONTRACT.md` | Before changing `msg.topic` inputs/outputs/events |
| **Command registry (source of truth)** | `nodes/<n>/src/commands/index.js` | When adding/removing an accepted topic |
| **Domain logic** | `nodes/<n>/src/specificClass.js` | Pure JS; no `RED.*` allowed |
| **Node-RED adapter** | `nodes/<n>/src/nodeClass.js` | Bridge to runtime; ≤ 25 lines, extends `BaseNodeAdapter` |
| **Per-node wiki** | `nodes/<n>/wiki/``Home.md`, `Reference-{Architecture,Contracts,Limitations,Examples}.md` | Topic-contract + data-model sections autogen via `npm run wiki:all` |
| **Tests** | `nodes/<n>/test/{basic,integration,edge}/` | Required for every change |
| **Example flows** | `nodes/<n>/examples/{basic,integration,edge}.flow.json` | Required artifact per node |
### Shared library (`nodes/generalFunctions/`)
| What | Where |
|---|---|
| **Library API contract** | `nodes/generalFunctions/CONTRACT.md` |
| **Public exports** | `nodes/generalFunctions/index.js` (barrel) |
| **Source** | `nodes/generalFunctions/src/{domain,nodered,measurements,convert,configs,…}/` |
### Archives (don't take as authoritative)
| What | Where | Why kept |
|---|---|---|
| Pre-refactor wiki pages | [`wiki/Archive/`](./wiki/Archive/) (20 files) | Historical reference; each has `⚠️ ARCHIVED — Do not update` |
| Refactor plan artifacts | [`.claude/refactor/Archive/`](./.claude/refactor/Archive/) — `CONTINUE_HERE.md`, `TASKS.md` | The May-2026 refactor plan; phases all done |
| Old priority lists | [`.agents/improvements/Archive/`](./.agents/improvements/Archive/) | Pre-refactor production priorities |
---
## 2. Discovery chain — how a fresh agent finds the rules
1. `CLAUDE.md` auto-loads → points at this file.
2. `.claude/rules/*.md` auto-load by `paths:` frontmatter when editing matching files.
3. `nodes/<n>/CLAUDE.md` auto-loads when working under that submodule.
4. This file (`CONTRACTS.md`) is the human-facing map of everything in step 1-3.
5. **Concept lookup**: use `grep` / `find` or the `Explore` subagent — anchor on the canonical sources listed in §1 (commands registry, CONTRACT.md, base classes in `generalFunctions/`).
---
## 3. The three contracts every node honours
Every EVOLV node is a three-tier sandwich. Each tier has a contract:
| Tier | Class | Contract source | Per-node implementation |
|---|---|---|---|
| 1 — Entry | `RED.nodes.registerType` | [`.claude/rules/node-architecture.md`](./.claude/rules/node-architecture.md) | `nodes/<n>/<n>.js` |
| 2 — Adapter | `BaseNodeAdapter` (from `generalFunctions`) | [`.claude/refactor/CONTRACTS.md §2`](./.claude/refactor/CONTRACTS.md) | `nodes/<n>/src/nodeClass.js` |
| 3 — Domain | `BaseDomain` (from `generalFunctions`) | [`.claude/refactor/CONTRACTS.md §3`](./.claude/refactor/CONTRACTS.md) | `nodes/<n>/src/specificClass.js` |
Plus the **commands registry** (`nodes/<n>/src/commands/index.js`) declares
the `msg.topic` inputs; `BaseNodeAdapter` dispatches by topic lookup. See
[`.claude/refactor/CONTRACTS.md §4`](./.claude/refactor/CONTRACTS.md).
---
## 4. Output and telemetry contract
Three output ports per node. Source of truth: [`.claude/refactor/CONTRACTS.md §10`](./.claude/refactor/CONTRACTS.md) and [`wiki/Telemetry.md`](./wiki/Telemetry.md).
| Port | Carries | Formatter |
|---|---|---|
| 0 | Process data (delta-compressed) | `outputUtils.formatMsg(..., 'process')` |
| 1 | InfluxDB line protocol | `outputUtils.formatMsg(..., 'influxdb')` |
| 2 | Registration / control plumbing | hand-shaped on the parent-child handshake |
Output-coverage testing (manifest + populated + degraded states) is **mandatory**
for any change touching Port 0/1/2 keys, function-node fan-outs, or dashboard widgets.
See [`.claude/rules/output-coverage.md`](./.claude/rules/output-coverage.md).
---
## 5. When a contract changes — the rule
1. Update the source file (`src/commands/index.js`, `src/specificClass.js`, or `generalFunctions/index.js`).
2. Update the per-node `CONTRACT.md` (Inputs table is partially autogenerated; the rest is hand-maintained).
3. Run `npm run wiki:all` inside the submodule to regenerate the topic-contract + data-model sections in `wiki/`.
4. If the change touched a platform shape (a base class or shared utility), update [`.claude/refactor/CONTRACTS.md`](./.claude/refactor/CONTRACTS.md) and `nodes/generalFunctions/CONTRACT.md`.
5. If the change introduced a deprecation, add an alias to `commands/index.js` and a one-line note to the per-node `CONTRACT.md`.
6. Append unresolved questions to [`.claude/refactor/OPEN_QUESTIONS.md`](./.claude/refactor/OPEN_QUESTIONS.md). Don't invent answers.
7. If topic usage in an example flow changed, regenerate or review the per-node `wiki/Reference-Examples.md` and the `examples/*.flow.json` set.
---
## 6. Conventions in one paragraph (the rest is in `CONVENTIONS.md`)
Files ≤ 200 lines (300 hard cap); functions ≤ 30 lines (60 hard cap). Default
to no comments — add one only when *why* is non-obvious. `specificClass`
**never** imports `RED.*`. Logger from `generalFunctions`, never `console.log`.
S88 colour scheme is mandatory in diagrams. Topic prefixes: `set.<noun>` for
idempotent setters, `cmd.<verb>` for triggers, `evt.<noun>` for events.
Tests live in `test/{basic,integration,edge}/`. Submodule commits go in the
submodule first, then the superproject bumps the pin.
**Canonical units** (Pa / m³/s / W / K) apply to every node **except
`reactor`**, which deliberately uses ASM-kinetics literature units
(mg/L, m³/d, °C, 1/h) — documented in `nodes/reactor/CONTRACT.md`.
Conversions happen at the parent/child boundary via `UnitPolicy`.
---
## 7. Verification checklist before merge
- [ ] Per-node tests green (`cd nodes/<n> && node --test test/basic test/integration test/edge`).
- [ ] `CONTRACT.md` updated for any added / removed / renamed topic, port-0 key, or event.
- [ ] `npm run wiki:all` re-run in the touched submodule(s).
- [ ] Output-coverage manifest + tests updated if any output shape changed.
- [ ] Submodule pin bumped in the superproject.
- [ ] Commit message captures *why* — load-bearing decisions go in the commit body and PR description.
---
*Last reviewed: 2026-05-19. If something in this map is wrong, fix this file
in the same PR as the change that made it wrong.*

View File

@@ -0,0 +1,414 @@
# CoreSync FROST Interview Handoff
Date: 2026-05-19
## Continue Here First
Resume the interview at **Question 20**. The last open design topic was the reducer comparison method:
**Q20. Should slope change be compared by angle in degrees or by relative slope delta?**
Recommended direction before pausing:
- Support both eventually.
- Default to angle comparison with normalized time/value axes.
- Compute `dx = deltaTimeMs / timeScaleMs`.
- Compute `dy = deltaValue / valueScale`.
- Compare `atan2(dy, dx)` direction changes against `angleToleranceDeg`.
## Agreed Decisions
- Use FROST/SensorThings instead of direct InfluxDB for the new CoreSync path.
- Keep EVOLV standard outputs:
- `process`
- `dbase`
- `parent`
- Add a `dbase` output format option for `frost`.
- `dbase = frost` emits FROST-ready HTTP request messages.
- The CoreSync node does not post directly to FROST in the first version.
- A normal Node-RED HTTP request node sends the FROST messages.
- HTTP responses feed back into the same CoreSync input with `msg.topic = "frost.response"`.
- All FROST metadata lookup/create/patch requests leave on `dbase`, not `process`.
- `process` is reserved for functional process data and optional functional state.
- The resolver is lazy: streams are resolved only when telemetry arrives.
- Pending queue policy for unresolved/FROST-down streams is keep first + latest, drop middle.
- Observation writes use nested Datastream endpoints:
- `POST /v1.1/Datastreams({datastreamId})/Observations`
- Preserve provenance in Observation `parameters`.
- On angle/slope change, emit the previous point as the knot.
- Do not forward-fill delta-compressed fields.
- Latest values are queried per Datastream:
- `/Datastreams(id)/Observations?$orderby=phenomenonTime desc&$top=1`
## SensorThings Mapping
- EVOLV asset/apparatus/node -> FROST `Thing`
- EVOLV field `type` -> FROST `ObservedProperty`
- EVOLV `variant` (`measured`, `predicted`, `setpoint`) -> FROST `Sensor`
- EVOLV `position` -> stable FROST `FeatureOfInterest`
- EVOLV numeric field -> one FROST `Datastream`
- One reducer-kept knot -> one FROST `Observation`
Stable FOI convention:
```text
{thingId}:upstream
{thingId}:atEquipment
{thingId}:downstream
```
Also copy position into `Datastream.properties.position` for filtering.
## Units
Use EVOLV canonical ingest units. UI conversion happens client-side.
- pressure: `Pa`
- flow: `m3/s`
- power: `W`
- temperature: `K`
- density: `kg/m3`
- level: `m`
- volume: `m3`
- control / percentage / efficiency: normalized ratio `1`
No leading zeros in engineering tags:
```text
P-1
PT-1
FT-9999999
```
Never:
```text
P-001
PT-0001
```
## Identity And Registry
- Node-RED is not the source of truth for asset identity.
- Future central asset registry owns tag allocation and duplicate detection.
- Use one central counter per tag prefix:
- `P`
- `PT`
- `FT`
- `TT`
- etc.
- The central registry, not local Node-RED, performs atomic `+1`.
- For now, assume the central registry is future work.
- First implementation derives identity when possible and allows overrides.
- Keep a boundary like `resolveIdentity(input)` so future registry integration is straightforward.
First-version identity behavior:
```text
Thing tag: configured/derived, e.g. P-1
Sensor tag: configured/derived, e.g. PT-1, MODEL-P-1, CTRL-P-1
Stream key: thingTag:type:variant:position:sensorTag
```
## Shared Collector Model
Use one shared CoreSync per FROST target/stack level.
Many EVOLV nodes can connect their `dbase` output to the CoreSync input, assuming payloads are structured as:
```js
{
measurement: "P-1",
fields: {
"pressure.measured.upstream.PT-1": 12345
},
tags: {
tagcode: "P-1"
},
timestamp: Date
}
```
Also accept arrays of such payloads.
Internal stream key:
```text
thingTag:type:variant:position:sensorTag
```
Per-stream state:
- FROST id cache
- latest FROST `phenomenonTime`
- reducer anchor point
- reducer previous point
- pending latest point
- bounded pending queue
## FROST Request Message Shape
Outgoing request messages should preserve correlation metadata:
```js
{
topic: "frost.metadata.lookup",
requestId: "thing:P-1:lookup",
_coreSync: {
kind: "thing",
action: "lookup",
externalKey: "thing:P-1",
streamKey: "P-1:pressure:measured:upstream:PT-1"
},
method: "GET",
url: "...",
payload: null
}
```
FROST response feedback:
```js
{
topic: "frost.response",
requestId: "...",
statusCode: 200,
payload: {},
_coreSync: {}
}
```
Observation write target:
```http
POST /v1.1/Datastreams({datastreamId})/Observations
```
Observation payload:
```json
{
"phenomenonTime": "2026-05-19T10:15:30.000Z",
"result": 123.4,
"FeatureOfInterest": {
"@iot.id": 7
},
"parameters": {
"reduction": "knot",
"reductionReason": "first|angle-change|max-gap|flush",
"evolvFieldKey": "pressure.measured.upstream.PT-1",
"evolvStreamKey": "P-1:pressure:measured:upstream:PT-1",
"sourceMeasurement": "Pump A"
}
}
```
## Reducer Decisions So Far
- Reducer runs independently per Datastream.
- 2D vector means time on X and numeric field value on Y.
- On direction change, emit the previous point.
- First point of a stream is kept.
- Previous point is kept on angle change.
- Pending latest point is emitted on explicit flush, max gap, or close.
- No forward-fill.
Pending queue during unresolved metadata/FROST downtime:
```text
queue empty -> store observation
queue has 1 -> keep first, append latest
queue has 2 -> keep first, replace second with latest
```
## Open Interview Questions
## Implementation Progress 2026-05-21
First coding pass added:
- New Node-RED node: `nodes/coresync/coresync.js` / `coresync.html`.
- New `frost` dbase formatter in `generalFunctions`.
- Root Node-RED registration: `package.json` -> `coresync`.
- Focused tests: `nodes/coresync/test/basic/coresync.basic.test.js`.
- FROST request builder for lazy lookup/create and nested Observation writes.
- Per-stream normalized-angle reducer, defaulting to:
- `angleToleranceDeg = 5`
- `timeScaleMs = 60000`
- `maxGapMs = 300000`
- keep first + latest pending queue
- Minimal response state machine:
- `GET lookup`
- `POST create if missing`
- cache returned `@iot.id`
- drain pending Observations once Datastream and FOI ids are known
Validation run:
```text
npx jest nodes/coresync/test/basic/coresync.basic.test.js --runInBand
PASS, 4 tests
npx eslint nodes/coresync/**/*.js nodes/generalFunctions/src/helper/formatters/frostFormatter.js nodes/generalFunctions/src/helper/formatters/index.js
PASS
```
Full `npm run lint` still fails on pre-existing unrelated repo issues, mostly browser globals in editor scripts and older lint findings.
Q20 decision implemented: default to normalized angle comparison. Relative slope mode is present as an advanced option in the reducer and editor config.
Q21 decision implemented: first defaults are the candidate defaults from this document, with per-type value-scale defaults in the CoreSync domain and fallback scale `1`.
Q22 decision implemented: explicit `msg.topic = "coresync.flush"`, `maxGapMs`, and close flush are supported. No periodic flush timer was added.
Q23 decision implemented: lazy resolver order is:
```text
Thing
ObservedProperty
Sensor
FeatureOfInterest
Datastream
Observation
```
Each metadata entity uses lookup/create only. PATCH drift correction is not in this pass.
Q24 decision implemented in editor defaults:
```text
frostBaseUrl
serviceVersion
assetTagOverride
sensorTagOverride
comparisonMode
angleToleranceDeg
timeScaleMs
maxGapMs
minDeltaTimeMs
minDeltaValue
maxQueuedObservationsPerStream
diagnosticsEnabled
```
Q25 decision implemented: emitted request messages are plain Node-RED HTTP-compatible messages and preserve `requestId` / `_coreSync` correlation fields.
Q26 decision implemented: id cache is runtime-only.
Q27 partial: failed metadata responses emit a process diagnostic and clear in-flight metadata. Backoff timing is not implemented yet.
Q28 implemented scope: skeleton, normalizer, reducer, FROST request builder, and minimal response state machine.
### Q20. Reducer comparison method
Should slope change be compared by:
- angle in degrees, using normalized axes, or
- relative slope delta?
Recommended: default to normalized angle comparison and keep relative slope as an optional advanced mode.
### Q21. Reducer defaults
What should the first defaults be?
Candidate defaults:
```text
angleToleranceDeg = 5
timeScaleMs = 60000
valueScaleMode = auto
minDeltaTimeMs = 0
minDeltaValue = 0
maxGapMs = 300000
```
Need decide whether `valueScale` is:
- configured per observed property/unit,
- auto-learned per stream,
- fixed to `1`.
Recommended: configured defaults by type, with auto fallback.
### Q22. Flush behavior
When should pending latest points flush?
Options:
- on node close only,
- on explicit `msg.topic = "coresync.flush"`,
- on `maxGapMs`,
- on periodic flush timer.
Recommended: support explicit flush and `maxGapMs`; avoid periodic flush unless needed.
### Q23. Metadata bootstrap order
What exact lazy resolver chain should the first implementation use?
Candidate:
```text
Thing
ObservedProperty
Sensor
FeatureOfInterest
Datastream
Observation
```
Need decide whether each entity is `GET lookup -> POST create if missing`, and whether PATCH metadata drift is included in v1.
Recommended v1: lookup/create only; no PATCH drift correction yet.
### Q24. FROST base URL config
What config fields belong on the CoreSync node?
Candidate:
```text
frostBaseUrl
serviceVersion = v1.1
dbaseFormat = frost
assetTagOverride
sensorTagOverride
angleToleranceDeg
timeScaleMs
maxGapMs
maxQueuedObservationsPerStream
diagnosticsEnabled
```
Need decide which are required for v1 editor UI.
### Q25. HTTP node compatibility
Do we require a wrapper function around Node-RED HTTP request to preserve `_coreSync` and `requestId`, or should CoreSync emit messages exactly in the shape the HTTP node preserves by default?
Recommended: design emitted messages to survive the standard HTTP node, then add a helper/example flow if needed.
### Q26. Local id cache persistence
Should resolved FROST ids be runtime-only, or persisted in Node-RED context?
Recommended v1: runtime-only cache, because metadata lookup is lazy and deterministic. Add persistent context later if lookups become expensive.
### Q27. Error handling policy
For failed FROST responses, should the stream:
- retry immediately,
- back off,
- mark unresolved and keep first/latest pending,
- drop until manual reset?
Recommended: exponential-ish backoff per stream plus keep first/latest pending.
### Q28. First implementation scope
Should the first coding pass create only the node skeleton plus reducer tests, or include the lazy FROST resolver end-to-end?
Recommended: implement skeleton, normalizer, reducer, and FROST request builder together; keep HTTP response state machine minimal but functional.

View File

@@ -6,10 +6,14 @@ FROM nodered/node-red:latest
# Install curl for health checks
USER root
RUN apk add --no-cache curl
USER node-red
# Set working directory to the EVOLV bind mount location
# Set working directory to the EVOLV bind mount location.
# Create + chown explicitly so the unprivileged node-red user can
# write `node_modules` during `npm install` below. Without this the
# WORKDIR is created as root and npm fails with EACCES.
RUN mkdir -p /data/evolv && chown -R node-red:node-red /data/evolv
WORKDIR /data/evolv
USER node-red
# -------------------------------------------------------
# Layer-cache: copy dependency manifests first

View File

@@ -1,6 +0,0 @@
# Functional Issues Backlog (Deprecated Location)
This backlog has moved to:
- `.agents/improvements/IMPROVEMENTS_BACKLOG.md`
Use `.agents/improvements/TOP10_PRODUCTION_PRIORITIES_YYYY-MM-DD.md` for ranked review lists.

224
README.md
View File

@@ -1,147 +1,77 @@
# R&D Bouwblok: EVOLV (Edge-Layer Evolution for Optimized Virtualization)
## Over
Dit bouwblok is ontwikkeld door het R&D-team van Waterschap Brabantse Delta voor gebruik in Node-RED.
> *[Voeg hier een korte toelichting toe over de specifieke functionele werking van dit bouwblok]*
---
## Licentie
Deze software valt onder de **Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)**-licentie.
- Gebruik, aanpassing en verspreiding is toegestaan voor **niet-commerciële doeleinden**, mits duidelijke naamsvermelding naar Waterschap Brabantse Delta.
- Voor **commercieel gebruik** is voorafgaande toestemming vereist.
📧 Contact: [rdlab@brabantsedelta.nl](mailto:rdlab@brabantsedelta.nl)
🔗 Licentie: [https://creativecommons.org/licenses/by-nc/4.0/](https://creativecommons.org/licenses/by-nc/4.0/)
---
## Generieke opbouw van bouwblokken
- Reageren automatisch op inkomende data (bijv. de positie van een object bepaalt de berekening).
- Ondersteunen koppeling van complexe dataketens tussen processen.
- Gestandaardiseerde input/output:
- Output = procesdata
- Opslaginformatie + relatieve positionering t.o.v. andere objecten
- Ontworpen voor combinatie met andere bouwblokken (ook van derden).
- Open source en vrij beschikbaar voor iedereen.
---
## Installatie Alle bouwblokken (via EVOLV)
Alle bouwblokken van het R&D-team zijn gebundeld in de **EVOLV-repository**, waarin gebruik wordt gemaakt van Git submodules.
### Eerste keer klonen:
```bash
git clone --recurse-submodules https://gitea.centraal.wbd-rd.nl/RnD/EVOLV.git
cd EVOLV
```
Of, als je zonder submodules hebt gekloond:
```bash
git submodule init
git submodule update
```
### Submodules updaten:
Om alle submodules te updaten naar de laatste versie van hun eigen repository:
```bash
git submodule update --remote --merge
```
Individuele submodule updaten:
```bash
cd nodes/<bouwblok-naam>
git checkout main
git pull origin main
cd ../..
git add nodes/<bouwblok-naam>
git commit -m "Update submodule <bouwblok-naam>"
```
---
## Installatie Enkel bouwblok
1. Clone de gewenste repository:
```bash
git clone https://gitea.centraal.wbd-rd.nl/<repo-naam>.git
```
2. Kopieer het bouwblok naar je Node-RED map:
```bash
mkdir -p ~/.node-red/nodes
cp -r <pad-naar-geclonede-map> ~/.node-red/nodes/
```
3. Controleer of `settings.js` het volgende bevat:
```js
nodesDir: './nodes',
```
4. Herstart Node-RED:
```bash
node-red-stop
node-red-start
```
---
## Bijdragen (Fork & Pull Request)
Wil je bijdragen aan de R&D bouwblokken? Volg dan dit stappenplan:
1. Fork maken
- Maak een fork van de gewenste R&D repository in Gitea.
- Je krijgt hiermee een eigen kopie van de repository in je account.
2. Wijzigingen aanbrengen
- Clone je fork lokaal en maak een nieuwe branch (bijv. feature/mijn-wijziging).
- Breng je wijzigingen aan, commit en push de branch terug naar je fork.
3. Pull Request indienen
- Ga in Gitea naar je fork en open de branch.
- Klik op New Pull Request.
- Stel de R&D repository in bij samenvoegen met.
- Stel jouw fork/branch in bij trekken van.
4. Beschrijving toevoegen
- Geef een duidelijke titel en beschrijving.
- Verwijs indien van toepassing naar een issue met de notatie #<nummer> (bijv. #42).
5. Code review en merge
- De beheerders van de R&D repository beoordelen je wijziging.
- Na goedkeuring wordt de wijziging opgenomen in de R&D repository.
----
## Contact
📧 rdlab@brabantsedelta.nl
# EVOLV Edge-Layer Evolution for Optimized Virtualization
Node-RED custom nodes package voor de automatisering van afvalwaterzuiveringsinstallaties. Ontwikkeld door het R&D-team van Waterschap Brabantse Delta. Volgt de ISA-88 (S88) batch control standaard.
## Nodes
| Node | Functie | S88-niveau |
|------|---------|------------|
| **rotatingMachine** | Individuele pomp/compressor/blower aansturing | Equipment |
| **machineGroupControl** | Multi-pomp optimalisatie (BEP-Gravitation) | Unit |
| **pumpingStation** | Pompgemaal met hydraulische context | Unit |
| **valve** | Individuele klep modellering | Equipment |
| **valveGroupControl** | Klep groep coordinatie | Unit |
| **reactor** | Biologische reactor (ASM kinetiek) | Unit |
| **settler** | Nabezinker / slibscheiding | Unit |
| **monster** | Multi-parameter biologische monitoring | Equipment |
| **measurement** | Sensor signaalconditionering | Control Module |
| **diffuser** | Beluchting aansturing | Equipment |
| **dashboardAPI** | InfluxDB telemetrie + FlowFuse dashboards | — |
| **generalFunctions** | Gedeelde bibliotheek (predict, PID, convert, etc.) | — |
## Architectuur
Elke node volgt een drie-lagen patroon:
1. **Entry file** (`<naam>.js`) — registratie bij Node-RED, admin endpoints
2. **nodeClass** (`src/nodeClass.js`) — Node-RED adapter (tick loop, routing, status)
3. **specificClass** (`src/specificClass.js`) — pure domeinlogica (fysica, toestandsmachines)
Drie output-poorten per node: **Port 0** = procesdata, **Port 1** = InfluxDB telemetrie, **Port 2** = registratie/besturing.
## Installatie
```bash
git clone --recurse-submodules https://gitea.wbd-rd.nl/RnD/EVOLV.git
cd EVOLV
npm install
```
Submodules updaten:
```bash
git submodule update --remote --merge
```
Enkel bouwblok installeren in Node-RED:
```bash
mkdir -p ~/.node-red/nodes
cp -r nodes/<bouwblok-naam> ~/.node-red/nodes/
```
## Testen
```bash
# Alle nodes
bash scripts/test-all.sh
# Specifieke node
node --test nodes/<nodeName>/test/basic/*.test.js
node --test nodes/<nodeName>/test/integration/*.test.js
node --test nodes/<nodeName>/test/edge/*.test.js
```
## Documentatie
- **`wiki/`** — Projectwiki met architectuur, bevindingen en metrics ([index](wiki/index.md))
- **`CLAUDE.md`** — Claude Code projectgids
- **`manuals/node-red/`** — FlowFuse en Node-RED referentiedocumentatie
- **`.agents/`** — Agent skills, beslissingen en function-anchors
## Licentie
**Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)**
Gebruik, aanpassing en verspreiding is toegestaan voor niet-commerciele doeleinden, mits naamsvermelding naar Waterschap Brabantse Delta. Voor commercieel gebruik is voorafgaande toestemming vereist.
## Contact
rdlab@brabantsedelta.nl

View File

@@ -16,6 +16,10 @@ services:
- .:/data/evolv:cached
# Named volume: overlay node_modules so host doesn't need native deps
- evolv_node_modules:/data/evolv/node_modules
# Persistent Node-RED user dir: flows/projects/sessions survive
# container recreation. Without this, `docker compose down && up`
# wipes the active flow and the entrypoint reseeds demo-flow.json.
- nodered_data:/data
environment:
- TZ=Europe/Amsterdam
- LOCATION_ID=docker-dev
@@ -63,7 +67,10 @@ services:
# Grafana — dashboard visualization
# ---------------------------------------------------------
grafana:
image: grafana/grafana:latest
# Pinned per dashboardAPI v2 PRD: legacy POST /api/dashboards/db is the
# generator target; Grafana 12 K8s-style API is out of scope. Bump
# deliberately, not via `pull --latest`.
image: grafana/grafana:11.3.0
container_name: evolv-grafana
restart: unless-stopped
ports:
@@ -83,6 +90,8 @@ services:
volumes:
evolv_node_modules:
driver: local
nodered_data:
driver: local
influxdb_data:
driver: local
grafana_data:

File diff suppressed because it is too large Load Diff

View File

@@ -63,18 +63,90 @@ npm install --no-save "$EVOLV_DIR" 2>/dev/null || {
echo "[entrypoint] EVOLV nodes installed into Node-RED user dir."
# -------------------------------------------------------
# 4. Deploy demo flow if no user flow exists yet
# 4. Bootstrap Node-RED projects from examples/
#
# Each examples/<name>/ becomes a project under /data/projects/<name>/.
# The Projects feature (settings.js) needs each project to be a Git
# repo, so we git-init each on first copy. After that the projects
# live in the persistent nodered_data volume.
#
# Default project: pumpingstation-complete-example (settable via
# DEFAULT_PROJECT env var).
# -------------------------------------------------------
PROJECTS_DIR="/data/projects"
DEFAULT_PROJECT="${DEFAULT_PROJECT:-pumpingstation-complete-example}"
mkdir -p "$PROJECTS_DIR"
if [ -d "$EVOLV_DIR/examples" ]; then
for src in "$EVOLV_DIR/examples"/*/; do
[ -d "$src" ] || continue
name=$(basename "$src")
dst="$PROJECTS_DIR/$name"
if [ -d "$dst" ]; then
echo "[entrypoint] Project '$name' already exists in /data/projects, skipping bootstrap."
continue
fi
echo "[entrypoint] Bootstrapping project '$name'..."
cp -r "$src" "$dst"
# Synthesize a Node-RED project package.json so the project is
# recognised even when the source folder doesn't have one.
if [ ! -f "$dst/package.json" ]; then
cat > "$dst/package.json" << PKGJSON
{
"name": "$name",
"description": "EVOLV example: $name",
"version": "0.1.0",
"private": true,
"node-red": {
"settings": {
"flowFile": "flow.json",
"credentialsFile": "flow_cred.json"
}
}
}
PKGJSON
fi
# Git init + initial commit (Node-RED projects require Git).
if [ ! -d "$dst/.git" ]; then
(
cd "$dst" && \
git init -q -b main && \
git config user.email "evolv-dev@local" && \
git config user.name "EVOLV Dev" && \
git add . && \
git commit -q -m "Bootstrap project $name from examples/" || true
)
fi
echo "[entrypoint] Project '$name' ready at $dst"
done
fi
# -------------------------------------------------------
# 4b. Set the active project (Node-RED's projects state lives in
# /data/.config.projects.json). Only set on first run; subsequent
# boots respect the operator's last selection in the editor.
# -------------------------------------------------------
PROJ_STATE="/data/.config.projects.json"
if [ ! -f "$PROJ_STATE" ] && [ -d "$PROJECTS_DIR/$DEFAULT_PROJECT" ]; then
echo "[entrypoint] Setting active project = $DEFAULT_PROJECT"
cat > "$PROJ_STATE" << JSON
{
"activeProject": "$DEFAULT_PROJECT",
"projects": {
"$DEFAULT_PROJECT": {}
}
}
JSON
fi
# Legacy demo-flow.json fallback — kept for the no-projects case if a
# user flips projects.enabled = false in settings.js.
DEMO_FLOW="$EVOLV_DIR/docker/demo-flow.json"
FLOW_FILE="/data/flows.json"
if [ -f "$DEMO_FLOW" ]; then
# Deploy demo flow if flows.json is missing or is the default stub
if [ ! -f "$FLOW_FILE" ] || grep -q "WARNING: please check" "$FLOW_FILE" 2>/dev/null; then
echo "[entrypoint] Deploying demo flow..."
cp "$DEMO_FLOW" "$FLOW_FILE"
echo "[entrypoint] Demo flow deployed to $FLOW_FILE"
fi
if [ -f "$DEMO_FLOW" ] && [ ! -f "$FLOW_FILE" ]; then
cp "$DEMO_FLOW" "$FLOW_FILE"
fi
# -------------------------------------------------------

View File

@@ -0,0 +1,546 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": { "limit": 100, "matchAny": false, "tags": [], "type": "dashboard" },
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 50,
"type": "text",
"title": "How to read this dashboard",
"gridPos": { "h": 5, "w": 24, "x": 0, "y": 0 },
"options": {
"mode": "markdown",
"content": "**Each metric below is mentally verifiable. Hover any panel title for its definition.**\n\n| Term | Definition | Where it comes from |\n|---|---|---|\n| **raw** | every numeric sample EVOLV nodes wrote to InfluxDB before CoreSync | `_measurement = FROST Flow Sensor FT-101` (field `mAbs`) and `_measurement = rotatingmachine_cse_rm_pump` (5 named fields) |\n| **knots** | the CoreSync-reduced samples actually kept | `_measurement = coresync_knots`, `_field = knot` |\n| **reductionPct** | `100 × (1 knots/raw)` — % of writes CoreSync skipped (higher is better) | computed in-query |\n| **kept fraction** | `knots / raw` (inverse; lower is better) | computed in-query |\n| **reason** | why CoreSync emitted a knot: `first` (1st sample), `angle-change` (slope direction shifted), `max-gap` (silent too long), `flush` (periodic) | tag on `coresync_knots` |\n\n**Sanity checks:** open the Per-stream table — `raw × (1 reductionPct/100) = knots` should hold to the integer. The headline scoreboard sums all rows. The Knot interarrival panel should never go below ~2 s for streams updating at 1 Hz (if it does, CoreSync is over-emitting → burst-window bug)."
}
},
{
"id": 100,
"type": "row",
"title": "Scoreboard — raw vs knots over the selected time range",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
"collapsed": false,
"panels": []
},
{
"id": 1,
"type": "stat",
"title": "Raw samples written",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "Total raw sample writes from EVOLV nodes into InfluxDB across all known CoreSync-tracked streams (FT-101 flow, P-101 pressures, efficiency, cog, SEC). This is what InfluxDB would store WITHOUT CoreSync compression.",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 6 },
"fieldConfig": {
"defaults": {
"color": { "mode": "fixed", "fixedColor": "#1f6feb" },
"unit": "short",
"decimals": 0,
"mappings": []
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "values": false, "calcs": ["lastNotNull"], "fields": "" },
"textMode": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "raw_ft101 = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"FROST Flow Sensor FT-101\" and r._field == \"mAbs\") |> count() |> keep(columns:[\"_value\"])\nraw_rm = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\") |> filter(fn:(r)=> r._field == \"pressure.measured.downstream.dashboard-sim-downstream\" or r._field == \"pressure.measured.upstream.dashboard-sim-upstream\" or r._field == \"efficiency.predicted.atequipment.cse_rm_pump\" or r._field == \"cog\" or r._field == \"specificEnergyConsumption.predicted.atequipment.cse_rm_pump\") |> group(columns:[\"_field\"]) |> count() |> group() |> keep(columns:[\"_value\"])\nunion(tables:[raw_ft101, raw_rm]) |> sum()"
}
]
},
{
"id": 2,
"type": "stat",
"title": "CoreSync knots kept",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "Total CoreSync knots actually written to InfluxDB. Each knot represents a 'meaningful' sample chosen by the angle-change reducer plus periodic flushes.",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 6 },
"fieldConfig": {
"defaults": {
"color": { "mode": "fixed", "fixedColor": "#2f9e44" },
"unit": "short",
"decimals": 0,
"mappings": []
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "values": false, "calcs": ["lastNotNull"], "fields": "" },
"textMode": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"coresync_knots\" and r._field == \"knot\") |> group() |> count()"
}
]
},
{
"id": 3,
"type": "gauge",
"title": "Reduction % (1 knots / raw)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "Headline compression number. 100% = perfect compression (impossible). 0% = CoreSync is keeping every sample (broken). Sweet spot for the FROST demo: 6095% depending on stream.",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 6 },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"min": 0,
"max": 100,
"unit": "percent",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "#d64545", "value": null },
{ "color": "#e8a23a", "value": 40 },
{ "color": "#2f9e44", "value": 70 }
]
}
},
"overrides": []
},
"options": {
"orientation": "auto",
"reduceOptions": { "values": false, "calcs": ["lastNotNull"], "fields": "" },
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "import \"array\"\nraw_ft101 = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"FROST Flow Sensor FT-101\" and r._field == \"mAbs\") |> count() |> keep(columns:[\"_value\"])\nraw_rm = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\") |> filter(fn:(r)=> r._field == \"pressure.measured.downstream.dashboard-sim-downstream\" or r._field == \"pressure.measured.upstream.dashboard-sim-upstream\" or r._field == \"efficiency.predicted.atequipment.cse_rm_pump\" or r._field == \"cog\" or r._field == \"specificEnergyConsumption.predicted.atequipment.cse_rm_pump\") |> group(columns:[\"_field\"]) |> count() |> group() |> keep(columns:[\"_value\"])\nraw_total = union(tables:[raw_ft101, raw_rm]) |> sum() |> findRecord(fn:(key)=> true, idx:0)\nknot_total = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"coresync_knots\" and r._field == \"knot\") |> group() |> count() |> findRecord(fn:(key)=> true, idx:0)\nrawN = if exists raw_total._value then float(v: raw_total._value) else 0.0\nknotN = if exists knot_total._value then float(v: knot_total._value) else 0.0\narray.from(rows: [{_value: (if rawN > 0.0 then 100.0 * (1.0 - knotN / rawN) else 0.0)}])"
}
]
},
{
"id": 4,
"type": "stat",
"title": "Approx. bytes saved",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "Rough estimate: (raw knots) × 80 bytes per line-protocol record. Order-of-magnitude only; actual savings depend on tag cardinality and retention policy.",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 6 },
"fieldConfig": {
"defaults": {
"color": { "mode": "fixed", "fixedColor": "#a347e1" },
"unit": "decbytes",
"decimals": 0,
"mappings": []
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "values": false, "calcs": ["lastNotNull"], "fields": "" },
"textMode": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "import \"array\"\nraw_ft101 = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"FROST Flow Sensor FT-101\" and r._field == \"mAbs\") |> count() |> keep(columns:[\"_value\"])\nraw_rm = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\") |> filter(fn:(r)=> r._field == \"pressure.measured.downstream.dashboard-sim-downstream\" or r._field == \"pressure.measured.upstream.dashboard-sim-upstream\" or r._field == \"efficiency.predicted.atequipment.cse_rm_pump\" or r._field == \"cog\" or r._field == \"specificEnergyConsumption.predicted.atequipment.cse_rm_pump\") |> group(columns:[\"_field\"]) |> count() |> group() |> keep(columns:[\"_value\"])\nraw_total = union(tables:[raw_ft101, raw_rm]) |> sum() |> findRecord(fn:(key)=> true, idx:0)\nknot_total = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"coresync_knots\" and r._field == \"knot\") |> group() |> count() |> findRecord(fn:(key)=> true, idx:0)\nrawN = if exists raw_total._value then raw_total._value else 0\nknotN = if exists knot_total._value then knot_total._value else 0\narray.from(rows: [{_value: (rawN - knotN) * 80}])"
}
]
},
{
"id": 200,
"type": "row",
"title": "Per-stream verification table — every line is mentally checkable",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 },
"collapsed": false,
"panels": []
},
{
"id": 5,
"type": "table",
"title": "Per-stream raw vs knots vs reduction %",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "One row per CoreSync stream. raw = raw samples written to InfluxDB. knots = CoreSync-kept samples. reductionPct = 100 × (1 knots/raw). Streams with reductionPct < 50 are flagged red. Each cell is line-of-sight to a known Flux query — see the dashboard's 'How to read' panel at top.",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 11 },
"fieldConfig": {
"defaults": {
"custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false },
"color": { "mode": "thresholds" }
},
"overrides": [
{
"matcher": { "id": "byName", "options": "reductionPct" },
"properties": [
{ "id": "unit", "value": "percent" },
{ "id": "decimals", "value": 1 },
{ "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "gradient" } },
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{ "color": "#d64545", "value": null },
{ "color": "#e8a23a", "value": 40 },
{ "color": "#2f9e44", "value": 70 }
]
}
}
]
},
{
"matcher": { "id": "byName", "options": "raw" },
"properties": [{ "id": "unit", "value": "short" }, { "id": "decimals", "value": 0 }]
},
{
"matcher": { "id": "byName", "options": "knots" },
"properties": [{ "id": "unit", "value": "short" }, { "id": "decimals", "value": 0 }]
}
]
},
"options": {
"cellHeight": "sm",
"footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false },
"showHeader": true,
"sortBy": [{ "desc": false, "displayName": "reductionPct" }]
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "import \"join\"\n\nraw_ft101 = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"FROST Flow Sensor FT-101\" and r._field == \"mAbs\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"P-101:flow:measured:upstream:FT-101\", raw:r._value }))\nraw_rm_pdn = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"pressure.measured.downstream.dashboard-sim-downstream\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:pressure:measured:downstream:dashboard-sim-downstream\", raw:r._value }))\nraw_rm_pup = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"pressure.measured.upstream.dashboard-sim-upstream\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:pressure:measured:upstream:dashboard-sim-upstream\", raw:r._value }))\nraw_rm_eff = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"efficiency.predicted.atequipment.cse_rm_pump\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:efficiency:predicted:atequipment:cse_rm_pump\", raw:r._value }))\nraw_rm_cog = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"cog\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:cog:measured:atEquipment:MEASURED-p-101\", raw:r._value }))\nraw_rm_sec = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"specificEnergyConsumption.predicted.atequipment.cse_rm_pump\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:specificenergyconsumption:predicted:atequipment:cse_rm_pump\", raw:r._value }))\n\nraw = union(tables:[raw_ft101, raw_rm_pdn, raw_rm_pup, raw_rm_eff, raw_rm_cog, raw_rm_sec]) |> group(columns:[\"streamKey\"])\n\nknots = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement==\"coresync_knots\" and r._field==\"knot\") |> keep(columns:[\"streamKey\",\"_value\"]) |> group(columns:[\"streamKey\"]) |> count(column:\"_value\") |> rename(columns:{_value:\"knots\"})\n\njoin.left(left: raw, right: knots, on: (l, r) => l.streamKey == r.streamKey, as: (l, r) => ({ streamKey: l.streamKey, raw: l.raw, knots: if exists r.knots then r.knots else 0 }))\n |> map(fn:(r)=> ({ r with reductionPct: if r.raw > 0 then 100.0 * (1.0 - float(v:r.knots) / float(v:r.raw)) else 0.0 }))\n |> group()\n |> sort(columns:[\"reductionPct\"])"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {},
"indexByName": { "streamKey": 0, "raw": 1, "knots": 2, "reductionPct": 3 },
"renameByName": {}
}
}
]
},
{
"id": 300,
"type": "row",
"title": "Signal reconstruction — do the knots faithfully represent the raw signal?",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 19 },
"collapsed": false,
"panels": []
},
{
"id": 6,
"type": "timeseries",
"title": "Flow FT-101 — raw 1 Hz vs CoreSync knots (m³/h)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "FT-101 raw flow values vs the CoreSync knots written for the same stream. If knots reconstruct the signal, big dots sit exactly on the raw line at every direction change. Same Y-axis so they should overlap.",
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 20 },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "m³/h",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 5,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 3,
"scaleDistribution": { "type": "linear" },
"showPoints": "auto",
"spanNulls": true,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "flowm3h"
},
"overrides": [
{
"matcher": { "id": "byName", "options": "knot (m³/h)" },
"properties": [
{ "id": "custom.drawStyle", "value": "points" },
{ "id": "custom.pointSize", "value": 10 },
{ "id": "custom.showPoints", "value": "always" },
{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d64545" } }
]
},
{
"matcher": { "id": "byName", "options": "raw (m³/h)" },
"properties": [
{ "id": "custom.lineWidth", "value": 2 },
{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#1f6feb" } }
]
}
]
},
"options": {
"legend": { "calcs": ["lastNotNull", "count", "min", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "none" }
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "raw = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"FROST Flow Sensor FT-101\" and r._field == \"mAbs\") |> map(fn:(r)=>({ r with _field: \"raw (m³/h)\" }))\nknots = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"coresync_knots\" and r._field == \"result\" and r.streamKey == \"P-101:flow:measured:upstream:FT-101\") |> map(fn:(r)=>({ r with _field: \"knot (m³/h)\" }))\nunion(tables:[raw, knots])"
}
]
},
{
"id": 7,
"type": "timeseries",
"title": "Pressure downstream — raw 0.5 Hz vs CoreSync knots (mbar)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "P-101 simulated downstream pressure raw values vs CoreSync knots for the same stream. Pressure cycles every 2 s; knots should appear at each direction change plus every 15 s flush.",
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 20 },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "mbar",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 5,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 3,
"scaleDistribution": { "type": "linear" },
"showPoints": "auto",
"spanNulls": true,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "pressuremb"
},
"overrides": [
{
"matcher": { "id": "byName", "options": "knot (mbar)" },
"properties": [
{ "id": "custom.drawStyle", "value": "points" },
{ "id": "custom.pointSize", "value": 10 },
{ "id": "custom.showPoints", "value": "always" },
{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d64545" } }
]
},
{
"matcher": { "id": "byName", "options": "raw (mbar)" },
"properties": [
{ "id": "custom.lineWidth", "value": 2 },
{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#1f6feb" } }
]
}
]
},
"options": {
"legend": { "calcs": ["lastNotNull", "count", "min", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "none" }
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "raw = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"pressure.measured.downstream.dashboard-sim-downstream\") |> map(fn:(r)=>({ r with _field: \"raw (mbar)\" }))\nknots = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"coresync_knots\" and r._field == \"result\" and r.streamKey == \"p-101:pressure:measured:downstream:dashboard-sim-downstream\") |> map(fn:(r)=>({ r with _field: \"knot (mbar)\" }))\nunion(tables:[raw, knots])"
}
]
},
{
"id": 400,
"type": "row",
"title": "Diagnostics — why CoreSync chose to emit (or not)",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 30 },
"collapsed": false,
"panels": []
},
{
"id": 8,
"type": "timeseries",
"title": "Knot interarrival time per stream (seconds since previous knot)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "Time between successive knots per stream. A stream emitting a knot every tick (~1 s) is not compressing. A healthy stream shows seconds-to-tens-of-seconds between knots and a hard cap at 15 s (the flush interval).",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "s",
"axisPlacement": "auto",
"drawStyle": "points",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineWidth": 0,
"pointSize": 4,
"scaleDistribution": { "type": "log", "log": 10 },
"showPoints": "always",
"spanNulls": false,
"thresholdsStyle": { "mode": "line+area" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "transparent", "value": null },
{ "color": "rgba(214, 69, 69, 0.15)", "value": 0 },
{ "color": "transparent", "value": 2 }
]
},
"unit": "s",
"min": 0.1
},
"overrides": []
},
"options": {
"legend": { "calcs": ["mean", "min", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "none" }
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"coresync_knots\" and r._field == \"knot\") |> drop(columns:[\"_value\"]) |> group(columns:[\"streamKey\"]) |> sort(columns:[\"_time\"]) |> elapsed(unit:1ms, columnName:\"_value\") |> map(fn:(r)=>({ r with _value: float(v:r._value) / 1000.0 })) |> filter(fn:(r)=> r._value > 0.0)"
}
]
},
{
"id": 9,
"type": "table",
"title": "Compression health — full math per stream (knots ÷ raw = kept; 1 kept = saved)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"description": "Each row shows every number that goes into the compression decision so the math is verifiable in your head. 'kept' is the inverse of the reductionPct in the table above (knots/raw). 'savedPct' equals reductionPct in the per-stream table — same number, different visualization. Verify: kept + savedPct/100 ≈ 1 for every row.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 },
"fieldConfig": {
"defaults": {
"custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false },
"color": { "mode": "thresholds" }
},
"overrides": [
{
"matcher": { "id": "byName", "options": "kept" },
"properties": [
{ "id": "unit", "value": "percentunit" },
{ "id": "decimals", "value": 3 },
{ "id": "min", "value": 0 },
{ "id": "max", "value": 1 },
{ "id": "custom.cellOptions", "value": { "type": "gauge", "mode": "gradient", "valueDisplayMode": "color" } },
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{ "color": "#2f9e44", "value": null },
{ "color": "#e8a23a", "value": 0.30 },
{ "color": "#d64545", "value": 0.50 }
]
}
}
]
},
{
"matcher": { "id": "byName", "options": "savedPct" },
"properties": [
{ "id": "unit", "value": "percent" },
{ "id": "decimals", "value": 1 },
{ "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "gradient" } },
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{ "color": "#d64545", "value": null },
{ "color": "#e8a23a", "value": 50 },
{ "color": "#2f9e44", "value": 70 }
]
}
}
]
},
{
"matcher": { "id": "byName", "options": "raw" },
"properties": [{ "id": "unit", "value": "short" }, { "id": "decimals", "value": 0 }]
},
{
"matcher": { "id": "byName", "options": "knots" },
"properties": [{ "id": "unit", "value": "short" }, { "id": "decimals", "value": 0 }]
}
]
},
"options": {
"cellHeight": "sm",
"footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false },
"showHeader": true,
"sortBy": [{ "desc": true, "displayName": "kept" }]
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "import \"join\"\n\nraw_ft101 = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"FROST Flow Sensor FT-101\" and r._field == \"mAbs\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"P-101:flow:measured:upstream:FT-101\", raw:r._value }))\nraw_rm_pdn = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"pressure.measured.downstream.dashboard-sim-downstream\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:pressure:measured:downstream:dashboard-sim-downstream\", raw:r._value }))\nraw_rm_pup = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"pressure.measured.upstream.dashboard-sim-upstream\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:pressure:measured:upstream:dashboard-sim-upstream\", raw:r._value }))\nraw_rm_eff = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"efficiency.predicted.atequipment.cse_rm_pump\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:efficiency:predicted:atequipment:cse_rm_pump\", raw:r._value }))\nraw_rm_cog = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"cog\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:cog:measured:atEquipment:MEASURED-p-101\", raw:r._value }))\nraw_rm_sec = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement == \"rotatingmachine_cse_rm_pump\" and r._field == \"specificEnergyConsumption.predicted.atequipment.cse_rm_pump\") |> count() |> keep(columns:[\"_value\"]) |> map(fn:(r)=>({ streamKey:\"p-101:specificenergyconsumption:predicted:atequipment:cse_rm_pump\", raw:r._value }))\n\nraw = union(tables:[raw_ft101, raw_rm_pdn, raw_rm_pup, raw_rm_eff, raw_rm_cog, raw_rm_sec]) |> group(columns:[\"streamKey\"])\n\nknots = from(bucket:\"telemetry\") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) |> filter(fn:(r)=> r._measurement==\"coresync_knots\" and r._field==\"knot\") |> keep(columns:[\"streamKey\",\"_value\"]) |> group(columns:[\"streamKey\"]) |> count(column:\"_value\") |> rename(columns:{_value:\"knots\"})\n\njoin.left(left: raw, right: knots, on: (l, r) => l.streamKey == r.streamKey, as: (l, r) => ({ streamKey: l.streamKey, raw: l.raw, knots: if exists r.knots then r.knots else 0 }))\n |> map(fn:(r)=> ({ streamKey: r.streamKey, raw: r.raw, knots: r.knots, kept: if r.raw > 0 then float(v:r.knots) / float(v:r.raw) else 0.0, savedPct: if r.raw > 0 then 100.0 * (1.0 - float(v:r.knots) / float(v:r.raw)) else 0.0 }))\n |> group()\n |> sort(columns:[\"kept\"], desc:true)"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {},
"indexByName": { "streamKey": 0, "raw": 1, "knots": 2, "kept": 3, "savedPct": 4 },
"renameByName": {}
}
}
]
}
],
"refresh": "5s",
"schemaVersion": 39,
"style": "dark",
"tags": ["EVOLV", "CoreSync", "FROST"],
"templating": { "list": [] },
"time": { "from": "now-3m", "to": "now" },
"timepicker": {},
"timezone": "",
"title": "CoreSync FROST Demo",
"uid": "coresync-frost-demo",
"version": 2,
"weekStart": ""
}

View File

@@ -0,0 +1,14 @@
apiVersion: 1
providers:
- name: EVOLV
orgId: 1
folder: EVOLV
type: file
disableDeletion: false
editable: true
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false

View File

@@ -0,0 +1,435 @@
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"refresh": "5s",
"schemaVersion": 39,
"style": "dark",
"tags": ["evolv", "pumping-station"],
"templating": { "list": [] },
"time": { "from": "now-15m", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "EVOLV — Pumping Station (complete)",
"uid": "evolv-ps-complete",
"version": 1,
"weekStart": "",
"panels": [
{
"type": "row",
"id": 100,
"title": "Realtime",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"panels": []
},
{
"type": "gauge",
"id": 1,
"title": "Basin level",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 7, "w": 6, "x": 0, "y": 1 },
"fieldConfig": {
"defaults": {
"unit": "lengthm",
"min": 0,
"max": 4,
"decimals": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "orange", "value": 1 },
{ "color": "blue", "value": 2 },
{ "color": "orange", "value": 3.5 },
{ "color": "red", "value": 3.8 }
]
}
}
},
"options": {
"showThresholdLabels": false,
"showThresholdMarkers": true,
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"level.predicted.atequipment.default\")\n |> last()"
}
]
},
{
"type": "gauge",
"id": 2,
"title": "Basin fill",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 7, "w": 6, "x": 6, "y": 1 },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "orange", "value": 10 },
{ "color": "green", "value": 30 },
{ "color": "orange", "value": 80 },
{ "color": "red", "value": 95 }
]
}
}
},
"options": {
"showThresholdMarkers": true,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"volumePercent.predicted.atequipment.default\")\n |> last()"
}
]
},
{
"type": "stat",
"id": 3,
"title": "Total flow (MGC)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 7, "w": 6, "x": 12, "y": 1 },
"fieldConfig": {
"defaults": {
"unit": "m³/h",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "gray", "value": null },
{ "color": "blue", "value": 50 },
{ "color": "green", "value": 200 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"colorMode": "background",
"graphMode": "area"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"MGC — Pump Group\")\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.atequipment\\./)\n |> last()"
}
]
},
{
"type": "stat",
"id": 4,
"title": "Total power (MGC)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 7, "w": 6, "x": 18, "y": 1 },
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"decimals": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "gray", "value": null },
{ "color": "blue", "value": 1 },
{ "color": "green", "value": 5 },
{ "color": "orange", "value": 20 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"colorMode": "background",
"graphMode": "area"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: -2m)\n |> filter(fn: (r) => r._measurement == \"MGC — Pump Group\")\n |> filter(fn: (r) => r._field =~ /^power\\.predicted\\.atequipment\\./)\n |> last()"
}
]
},
{
"type": "stat",
"id": 5,
"title": "Pump A — state",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 8 },
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } },
{ "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } },
{ "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } },
{ "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } }
],
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] }
}
},
"options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_a\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()"
}
]
},
{
"type": "stat",
"id": 6,
"title": "Pump B — state",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 8 },
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } },
{ "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } },
{ "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } },
{ "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } }
],
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] }
}
},
"options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_b\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()"
}
]
},
{
"type": "stat",
"id": 7,
"title": "Pump C — state",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 8 },
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "operational": { "color": "green", "text": "OPERATIONAL" } } },
{ "type": "value", "options": { "starting": { "color": "blue", "text": "STARTING" } } },
{ "type": "value", "options": { "stopping": { "color": "orange", "text": "STOPPING" } } },
{ "type": "value", "options": { "idle": { "color": "gray", "text": "IDLE" } } }
],
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "gray", "value": null }] }
}
},
"options": { "colorMode": "background", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"rotatingmachine_pump_c\")\n |> filter(fn: (r) => r._field == \"state\")\n |> last()"
}
]
},
{
"type": "row",
"id": 200,
"title": "Historic",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 },
"panels": []
},
{
"type": "timeseries",
"id": 10,
"title": "Basin — level (m) and fill (%)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 13 },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 8,
"spanNulls": true
},
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "level (m)" },
"properties": [{ "id": "unit", "value": "lengthm" }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] },
{ "matcher": { "id": "byName", "options": "fill (%)" },
"properties": [{ "id": "unit", "value": "percent" }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] }
]
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"level.predicted.atequipment.default\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"level (m)\")"
},
{
"refId": "B",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field == \"volumePercent.predicted.atequipment.default\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"fill (%)\")"
}
]
},
{
"type": "timeseries",
"id": 11,
"title": "Inflow / Outflow / Net flow (m³/h)",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 21 },
"fieldConfig": {
"defaults": {
"unit": "m³/h",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 5,
"spanNulls": true
},
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"Pumping Station\")\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.(in|out)\\./ or r._field == \"netFlowRate.predicted.atequipment.default\")\n |> map(fn: (r) => ({ r with _value: r._value * 3600.0 }))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
}
]
},
{
"type": "timeseries",
"id": 12,
"title": "Per-pump flow (m³/h) — predicted",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 },
"fieldConfig": {
"defaults": {
"unit": "m³/h",
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /^rotatingmachine_/)\n |> filter(fn: (r) => r._field =~ /^flow\\.predicted\\.downstream\\./)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
}
]
},
{
"type": "timeseries",
"id": 13,
"title": "Per-pump power (kW) — predicted",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 },
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /^rotatingmachine_/)\n |> filter(fn: (r) => r._field =~ /^power\\.predicted\\.atequipment\\./)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
}
]
},
{
"type": "timeseries",
"id": 14,
"title": "Per-pump pressures (mbar) — sensors",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 37 },
"fieldConfig": {
"defaults": {
"unit": "pressuremmbar",
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 3, "spanNulls": true },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-(Up|Dn)$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
}
]
},
{
"type": "timeseries",
"id": 15,
"title": "Per-pump sensor flow (m³/h) — measured",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 45 },
"fieldConfig": {
"defaults": {
"unit": "m³/h",
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-Flow$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
}
]
},
{
"type": "timeseries",
"id": 16,
"title": "Per-pump sensor power (kW) — measured",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 45 },
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 5, "spanNulls": true },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement =~ /-Pwr$/)\n |> filter(fn: (r) => r._field == \"mAbs\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
}
]
}
]
}

View File

@@ -15,10 +15,22 @@ module.exports = {
// No authentication for dev environment
adminAuth: null,
// Disable projects (we use git directly)
// Projects ON: each example folder under /data/projects is a Node-RED
// project (a small Git repo). Operator switches between them in the
// editor (Projects → Open Project). The entrypoint bootstraps every
// examples/<name>/ into /data/projects/<name>/ on first run; after
// that, edits live in the persistent nodered_data volume. To copy
// edits back into the EVOLV source tree, run:
// docker cp evolv-nodered:/data/projects/<name>/flow.json \
// examples/<name>/flow.json
editorTheme: {
projects: {
enabled: false
enabled: true,
workflow: {
// Manual: editor doesn't auto-commit. Use the Projects UI
// (or `git` from a shell into the container) to commit.
mode: 'manual'
}
}
},

View File

@@ -0,0 +1,82 @@
# dashboardAPI v2 — graph-aware Grafana dashboard generator
_Date: 2026-05-26 · Owner: R&D · Predecessors: `/grill-me` (in-conversation), [`docs/research/dashboardapi-graph-aware-grafana-generator.md`](../research/dashboardapi-graph-aware-grafana-generator.md)_
One `dashboardAPI` node in a Node-RED flow auto-generates one Grafana dashboard by walking its child-registration graph, composing per-node-type panel templates, and pushing the result to Grafana via HTTP on every Node-RED deploy.
## Problem
Every EVOLV example flow today carries a hand-authored Node-RED Dashboard tab — the active `pumpingstation-complete-example` flow has 73 `ui-*` nodes (charts, gauges, text widgets, fan-out function nodes) consuming roughly a third of the flow. Every new example replicates this work, and each one diverges in axis ranges, chart configs, and fan-out logic — so the output side is inconsistent across the 10+ example flows we maintain. The same telemetry already lands in InfluxDB via Port 1 of every node, so Grafana could render it natively, but today each Grafana dashboard is hand-authored JSON (`docker/grafana/provisioning/dashboards/pumping-station.json` is the only one that exists, frozen at one node type). Result: R&D spends disproportionate time on dashboard plumbing, examples drift, and Grafana — the better readout — is underused.
## Goals
1. Dropping a `dashboardAPI` node into a flow and deploying produces a complete Grafana dashboard with no hand-authored JSON.
2. Adding a new EVOLV node *instance* (e.g. a new measurement child) to a flow adds its panels on the next deploy with zero Grafana edits.
3. Adding a new EVOLV node *type* requires only a panel template fragment under `nodes/dashboardAPI/src/templates/<softwareType>.json` — no changes to the layout engine.
4. Cross-example consistency: every example flow's Grafana dashboard uses the same panel set, axis conventions, and dashed-bounds rendering for the same node type.
5. Node-RED Dashboard tab in example flows shrinks to control-only widgets (mode select, operator demand, calibration, signal injection). Target: ≤15 `ui-*` nodes per example flow.
## Non-goals
- Sub-second feedback latency from operator action → Grafana visible state. End-to-end ≤15s is acceptable; faster is not pursued.
- Preserving manual Grafana edits across regenerations. Dashboards are single-source-of-truth from dashboardAPI; manual edits are clobbered on next deploy.
- Per-instance dashboard customization through the Grafana UI. Templates are centralized and code-owned.
- Supporting non-EVOLV (third-party) Node-RED node types as panel sources.
- Live runtime regeneration (no deploy). Regen fires on Node-RED deploy events only.
- Operator (plant-staff) UX. Sole user is R&D until further notice.
- Replacing the InfluxDB write path. dashboardAPI v2 reuses the existing `outputUtils.formatForInflux` + `influxdbFormatter` plumbing unchanged.
## Users & scenarios
Sole user: EVOLV R&D team (Rene, Pim, Janneke, Sjoerd, Dieke, Pieter).
1. **New example flow from scratch.** When R&D builds a new example for `rotatingMachine-complete`, they assemble the node graph (pumpingStation + 3 pumps + measurements), drop in one dashboardAPI, connect each top-level parent to it, and deploy. A Grafana dashboard at the dashboardAPI's UID appears within seconds, with rows per parent and panels per child following the centralized templates.
2. **Adding a measurement to an existing flow.** When R&D wires a new measurement node as a child of an existing pumpingStation in `pumpingstation-complete-example` and redeploys, the corresponding pump panel gains a `measured` series next to its `predicted` series. No Grafana edit.
3. **Adding a new EVOLV node type.** When R&D ships a new node type `mixer`, they author `nodes/dashboardAPI/src/templates/mixer.json` (Grafana panel fragment with `${nodeName}` substitution tokens) and bump dashboardAPI's package version. Existing dashboardAPI instances pick up mixer-typed children on next deploy.
## Requirements
### Functional
1. **F-1.** `dashboardAPI` shall subscribe to `RED.events.on('flows:started')` and, on each event, inspect `payload.diff` to determine whether any of its own subtree (the dashboardAPI node, its registered children, their registered grandchildren) was affected. If yes, regenerate the dashboard. If no, no-op.
2. **F-2.** On regenerate, `dashboardAPI` shall walk its registered children via `ChildRegistrationUtils.getAllChildren()`, recurse one level per registered child to discover grandchildren, and produce an ordered list `[{softwareType, nodeName, position, children: [...]}, ...]`.
3. **F-3.** For each node in the graph, `dashboardAPI` shall load the matching template at `nodes/dashboardAPI/src/templates/${softwareType}.json` and substitute the placeholders `${nodeName}`, `${nodeId}`, `${parentName}`, `${dashboardUid}` and any child-list placeholders into the panel JSON.
4. **F-4.** The layout engine shall compose templates into a single Grafana dashboard JSON with: one row per top-level child of dashboardAPI; nested rows for grandchildren; sequential `gridPos.y` offsets so panels don't overlap.
5. **F-5.** Parent panels shall **not** repeat metrics that any of their children's templates already emit. The template format declares each panel's `emittedFields` so the composer can filter duplicates from the parent's panel set.
6. **F-6.** For each child node of type `rotatingMachine`, the panel set shall include: `%control`, `flow`, `delta P`, any registered measurement child's measured values, and `efficiency`. Where the node config exposes operating bounds (e.g. min/max flow), those bounds shall be rendered as dashed reference lines (`fieldConfig.custom.lineStyle = {fill: "dash", dash: [10,10]}` via a `byName` override) on the same panel as the act value.
7. **F-7.** For each child of type `measurement` registered to a parent that also emits a `predicted` series for the same quantity, the dashboard shall render two panels side by side (predicted left, measured right). If only `predicted` exists, render the predicted panel only. If only `measured` exists, render the measured panel only.
8. **F-8.** `dashboardAPI` shall POST the assembled dashboard to `POST {grafanaUrl}/api/dashboards/db` with body `{dashboard: <json>, overwrite: true, folderUid: <configured>}`, using the configured bearer token in `Authorization: Bearer <token>`. The `dashboard.uid` shall be deterministic from the dashboardAPI node's Node-RED id.
9. **F-9.** On a successful upsert (HTTP 200), `dashboardAPI` shall log the dashboard URL at info level. On failure (non-2xx, timeout, network error), it shall log at error level with the response body and shall **not** retry; the next deploy is the retry mechanism.
10. **F-10.** Each node emitting a value with operating bounds shall write the bounds as additional Influx fields named `<field>.min` and `<field>.max` alongside `<field>` itself. The dashed-line override matches these by suffix.
11. **F-11.** The bearer token shall be stored as a Node-RED encrypted credential, not as a plain `defaults` field. On node startup, if the legacy plain field exists, it is migrated to the credential store and the plain field is cleared, with one info-level log line per migrated instance.
12. **F-12.** `dashboardAPI` shall expose `msg.topic == "regenerate-dashboard"` as a manual trigger that bypasses the diff check and forces a regenerate.
### Non-functional
- **N-1. Performance.** Dashboard composition (graph walk + template merge + JSON build, excluding HTTP roundtrip) shall complete in <500ms for a flow with up to 50 registered children.
- **N-2. Idempotency.** Running the regenerate path twice in a row with no intervening graph change produces a byte-identical dashboard JSON.
- **N-3. Security.** The bearer token shall never appear in any log line, status update, debug output, or admin endpoint response. Token-bearing HTTP requests shall set TLS verification on when the configured Grafana URL is `https://`.
- **N-4. Observability.** Every regenerate emits a structured log line via the `logger` shared utility with fields: `dashboardUid`, `childCount`, `grandchildCount`, `compositionDurationMs`, `httpStatus`, `outcome ∈ {success, http-error, network-error, no-diff}`.
- **N-5. Backward compatibility.** Existing dashboardAPI instances continue to write to InfluxDB exactly as before. The Grafana-push path is additive and disabled if no `grafanaUrl` is configured.
## Constraints & dependencies
- **Grafana version pinned.** `docker-compose.yml` shall pin to `grafana/grafana:11.3.0` (or whatever specific minor exists at first-issue time) instead of `latest`. The legacy `POST /api/dashboards/db` endpoint is the target; the Grafana 12 Kubernetes-style API is out of scope. This resolves research **O-3**.
- **Node-RED runtime events.** Depends on `RED.events.on('flows:started')` firing with a `payload.diff` shape (added/changed/removed arrays) — undocumented but stable in current Node-RED versions. Verified by prototype before first issue ships.
- **InfluxDB write path unchanged.** Reuses existing `outputUtils.formatForInflux` + `influxdbFormatter`. No schema migration to existing telemetry.
- **Tag schema.** Every Influx field used by a panel must be in the existing emission convention (`_measurement = nodeName`, `_field = type.variant.position.childId`).
- **Scaffolding to reuse:** `ChildRegistrationUtils.getAllChildren()` (`nodes/generalFunctions/src/helper/childRegistrationUtils.js:104-106`), `extractChildren()` (`nodes/dashboardAPI/src/specificClass.js:151-163`), `grafanaUpsertUrl()` (`:107-110`, URL builder exists, HTTP send missing), `BaseNodeAdapter` lifecycle pattern.
- **No new npm dependencies** for the HTTP path. Use Node's built-in `https`/`http` modules.
## Success metrics
1. **Hand-authored Grafana JSON in repo = 0.** Measured by counting JSON files in `docker/grafana/provisioning/dashboards/` minus the dynamically-uploaded ones. Current: 2 (pumping-station.json, coresync-frost-demo.json). Target after rollout: 0 file-based, N dynamic.
2. **`ui-*` node count per example flow ≤ 15** (down from 73 in the current `pumpingstation-complete-example`). Measured by grepping `examples/*.flow.json` after migration.
3. **Time-to-first-dashboard for a new example flow ≤ 1 minute of human work** (drop in dashboardAPI, configure URL + token, deploy). Measured by stopwatch on the next example flow that gets built.
4. **Regression coverage:** every example flow's dashboard URL returns HTTP 200 and renders without panel errors. Measured by an integration test that hits the Grafana API after deploying each example.
## Open questions
- **O-1. `flows:started` + `diff` reliability across deploy modes.** Source-readable but needs a spike to confirm `diff` cleanly distinguishes "this dashboardAPI's subtree changed" from "an unrelated flow changed", across `full` / `nodes` / `flows` deploy types. → Resolved by `/prototype` before issue I-3 (the lifecycle hook issue) starts.
- **O-2. Dashed-line `custom.lineStyle` rendering against real Influx series.** Open Grafana bugs [#75259](https://github.com/grafana/grafana/issues/75259) and [#86546](https://github.com/grafana/grafana/issues/86546) may affect us. → Resolved by `/prototype` before issue I-5 (rotatingMachine template) starts.
- **O-5 (new).** Folder UID handling — does dashboardAPI assume a single Grafana folder for all generated dashboards (configured per-instance), or create per-flow folders? Default: per-instance configured folder UID, optional. If empty, dashboards land in the General folder. → Owner: R&D, deadline: before I-4.
## Out of scope (v2 candidates)
- Per-instance panel customization through the Grafana UI with merge-on-regen.
- Operator-facing UX (Grafana role/permission management, embedded dashboards in Node-RED).
- Auto-discovery of measurement units / axis ranges from node config schemas.
- Multi-Grafana-instance fanout (push the same dashboard to staging + prod).
- Grafana alerts / notification policies generated from EVOLV alarm definitions.
- Dashboard versioning / rollback inside Grafana.
- Template fragments living next to their owning node (decentralized template discovery).

View File

@@ -0,0 +1,56 @@
# Research brief: graph-aware Grafana dashboard generator in dashboardAPI
_Date: 2026-05-26_
_Context: follows `/grill-me` session that locked design constraints; feeds into `/prd`._
## Questions
1. Node-RED lifecycle: how does a custom node reliably detect "deploy complete" across deploy types?
2. Prior art: existing Node-RED → Grafana auto-dashboard generators
3. Grafana HTTP API: idempotent dashboard updates by UID, version conflicts, RBAC
4. Dynamic min/max envelope pattern: dashed reference lines that vary over time
5. EVOLV-internal scaffolding already in place
## Design constraints already settled in `/grill-me`
1. dashboardAPI = dashboard **generator**, not just an InfluxDB writer.
2. One dashboardAPI instance = one Grafana dashboard. Multiple instances coexist.
3. Single source of truth: regen on Node-RED deploy **clobbers** manual Grafana edits.
4. Trigger: HTTP API push from dashboardAPI to Grafana, fired on Node-RED deploy.
5. Auth: per-flow Grafana service-account token.
6. Templates centralized in `nodes/dashboardAPI/src/templates/` per node type.
7. Per-instance `_measurement` = node name (already in `influxdbFormatter`).
8. **No data duplication** between parent and child panels (MGC shows group-level only).
9. Predicted-vs-measured = 2 panels side by side; predicted only when no measured registered.
10. Per-pump panel set: %control / flow / delta P / measured-from-children / efficiency / dashed dynamic bounds.
11. Static config bounds → **dashed reference lines** that follow the live operating envelope (top/bottom dashed + act value).
## What's already in this codebase
- **Child registration is fully graph-aware.** `ChildRegistrationUtils` keeps a `Map<id, {child, softwareType, position, registeredAt}>` with type-aware accessors `getAllChildren()`, `getChildById()`, `getChildrenOfType()`. (`nodes/generalFunctions/src/helper/childRegistrationUtils.js:19-106`)
- **dashboardAPI already iterates its children.** `extractChildren()` reads `nodeSource.childRegistrationUtils.registeredChildren.values()`. (`nodes/dashboardAPI/src/specificClass.js:151-163`)
- **Grafana upsert URL is already constructed but not yet dispatched.** `grafanaUpsertUrl()` builds the target URL — the HTTP send is missing. (`nodes/dashboardAPI/src/specificClass.js:107-110`)
- **InfluxDB schema is `measurement: nodeName`, tags from flattened config** (id, softwareType, role, positionVsParent, uuid, tagCode, geoLocation, category, type, model, unit). (`nodes/generalFunctions/src/helper/outputUtils.js:44,99-117`; `formatters/influxdbFormatter.js:12-20`)
- **Lifecycle hooks: only `node.on('close')` and `node.on('input')` are used.** No EVOLV node currently subscribes to `RED.events.on('flows:started')` or similar — net-new wiring. (`nodes/generalFunctions/src/nodered/BaseNodeAdapter.js:164,184`)
- **dashboardAPI's bearer token is stored as a plain `defaults` field, NOT as a Node-RED `credentials:` block** — so it's not encrypted at rest today. (`nodes/dashboardAPI/dashboardAPI.html:15-16`; `src/nodeClass.js:38-42`) **Contradicts the grilling assumption** that "the existing InfluxDB credentials path" is already in place — it isn't.
- **No outbound external HTTPS pattern exists anywhere in EVOLV nodes.** Net-new code path.
## External options
- **Legacy Grafana API (`POST /api/dashboards/db` with `overwrite: true`).** Skips version + uid-uniqueness checks → idempotent. Returns `412 Precondition Failed` on stale version when `overwrite=false`. Minimum RBAC: `dashboards:write` scoped to a folder. ([docs](https://grafana.com/docs/grafana/latest/developers/http_api/dashboard/))
- **Grafana 12 Kubernetes-style API (`/apis/dashboard.grafana.app/v1/...`).** Returns `409 Conflict` instead of `412`. Newer but couples integration to Grafana 12+.
- **`flows:started` runtime event** fires on every deploy (full / nodes / flows) with `{type, diff}` payload. De-dupe by inspecting `diff.added/changed/removed`. Runtime events are undocumented — must read source. (Node-RED `packages/.../runtime/lib/flows/index.js`)
- **`nodes-started` event is deprecated** — use `flows:started`.
- **Dashed-line dynamic bands:** the *only* path that works today is emitting min/max as separate Influx fields + applying `fieldConfig.overrides[].properties[].id = "custom.lineStyle"` with `{fill: "dash", dash: [10,10]}`. Per-series override via `byName` matcher.
- **Grafana thresholds are static-only** (open issue [grafana/grafana#115398](https://github.com/grafana/grafana/issues/115398) — Needs Prioritisation). Dead end for time-varying bands.
## Prior art
- **No relevant prior art found.** Every "node-red + grafana" tutorial puts Influx in the middle and hand-builds dashboards. No npm package pushes Grafana dashboards from Node-RED. Greenfield lane.
- **Grafana Foundation SDK / dashboards-as-code** ([docs](https://grafana.com/docs/grafana/latest/as-code/observability-as-code/foundation-sdk/)) — assumes out-of-band CI generation, not a live Node-RED instance.
- **Operating-envelope plotting in Grafana** — [community thread 57225](https://community.grafana.com/t/how-to-plot-graph-using-upper-and-lower-bound/57225) asks the exact question, no accepted answer.
- **Known Grafana bugs around `custom.lineStyle`:** [#75259](https://github.com/grafana/grafana/issues/75259) (transforms) and [#86546](https://github.com/grafana/grafana/issues/86546) (overlapping dashed → solid).
## Open unknowns
- **(O-1) `flows:started` + `diff` reliability.** Does `diff` cleanly distinguish "this dashboardAPI's flow changed" from "an unrelated flow changed" across all three deploy modes? Source-readable but needs an actual spike to verify edge cases (e.g. a `Modified Nodes` deploy that adds a child measurement to a pumpingStation registered to a dashboardAPI in a different tab). → **Candidate for `/prototype`.**
- **(O-2) Dashed-line rendering against real Influx series.** Two open Grafana bugs ([#75259](https://github.com/grafana/grafana/issues/75259), [#86546](https://github.com/grafana/grafana/issues/86546)) affect `custom.lineStyle`. Untested whether either bites with EVOLV's emission pattern. → **Candidate for `/prototype`.**
- **(O-3) Legacy `/api/dashboards/db` vs v12 K8s API.** Which to commit to? Locks integration to a Grafana version family. Local stack uses `grafana/grafana:latest` — version drifts on `docker compose pull`. → PRD-time decision; pin Grafana image.
- **(O-4) Bearer-token storage migration.** Assumption that "follow existing creds pattern" doesn't hold — dashboardAPI stores it as plain config today. Need to migrate to Node-RED `credentials:` block. Risk: token currently sitting in `flow.json` of users' existing flows. → PRD-time decision; migration step in first issue.
## Recommended next step
`/prd` — commit the design, resolve O-3 and O-4 explicitly, and queue O-1 and O-2 for `/prototype` before the first issue ships.

29
eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
const js = require('@eslint/js');
const globals = require('globals');
module.exports = [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
...globals.node,
...globals.jest,
RED: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-console': 'off',
'no-prototype-builtins': 'warn',
'no-constant-condition': 'warn',
},
},
{
ignores: [
'node_modules/**',
'nodes/generalFunctions/src/coolprop-node/coolprop/**',
],
},
];

Some files were not shown because too many files have changed in this diff Show More