diff --git a/.claude/refactor/CONTRACTS.md b/.claude/refactor/CONTRACTS.md new file mode 100644 index 0000000..54277a1 --- /dev/null +++ b/.claude/refactor/CONTRACTS.md @@ -0,0 +1,509 @@ +# 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. `` and `` +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.` | **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.` | **Imperative action.** Triggers a transition or sequence. Repeating triggers it again (or is rejected). | No | `cmd.startup`, `cmd.shutdown`, `cmd.estop`, `cmd.calibrate` | +| `data.` | **Bulk data input.** Sensor readings, measurement values, raw streams. The node consumes them. | n/a — values flow | `data.measurement`, `data.flow`, `data.pressure` | +| `child.` | **Parent/child plumbing.** Registration handshakes routed via Port 2. | n/a | `child.register`, `child.unregister` | +| `query.` | **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.` | **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/.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. + getStatusBadge() { + return statusBadge.fromState({ + direction: this.flowAggregator.direction, + vol: this.measurements.type('volume').variant('measured').position('atequipment').getCurrentValue('m3'), + maxVol: this.basin.maxVolAtOverflow, + }); + } +} + +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[][]` (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' }, + handler: handlers.setMode, + }, + { + topic: 'cmd.startup', + aliases: ['execSequence:startup'], + payloadSchema: { type: 'object', properties: { source: { type: 'string' } } }, + handler: handlers.startup, + }, + ... +]; +``` + +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` 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.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`. | + +`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); +}); + +// Caller side — never blocks. The latest demand always wins. +this.demandGate.fire(demand); +``` + +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. + +## 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. diff --git a/.claude/refactor/CONVENTIONS.md b/.claude/refactor/CONVENTIONS.md new file mode 100644 index 0000000..bfd9c1d --- /dev/null +++ b/.claude/refactor/CONVENTIONS.md @@ -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 | `..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/.basic.test.js # one module in isolation + integration/.integration.test.js # multiple modules together + edge/.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. diff --git a/.claude/refactor/MODULE_SPLIT.md b/.claude/refactor/MODULE_SPLIT.md new file mode 100644 index 0000000..53e33e5 --- /dev/null +++ b/.claude/refactor/MODULE_SPLIT.md @@ -0,0 +1,206 @@ +# Per-node module split + +Where each concern lives **after** the refactor. All paths are relative +to `nodes//src/`. + +## Generic node template (any node post-refactor) + +``` +nodes// + .js # Node-RED entry: registerType + admin endpoints (≤ 50 lines) + .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 + / # one folder per domain concern (see per-node sections below) + ... + test/ + basic/ + integration/ + edge/ +``` + +## pumpingStation (Process Cell — L5, `#0c99d9`) + +``` +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, `#a9daee`) + +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, `#50a8d9`) + +``` +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, `#86bbdd`) + +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. | + +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. diff --git a/.claude/refactor/OPEN_QUESTIONS.md b/.claude/refactor/OPEN_QUESTIONS.md new file mode 100644 index 0000000..2670695 --- /dev/null +++ b/.claude/refactor/OPEN_QUESTIONS.md @@ -0,0 +1,99 @@ +# 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. + +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. + +--- diff --git a/.claude/refactor/README.md b/.claude/refactor/README.md new file mode 100644 index 0000000..75a3467 --- /dev/null +++ b/.claude/refactor/README.md @@ -0,0 +1,77 @@ +# EVOLV Platform Refactor — Guidelines + +This directory holds the durable plan and conventions for the platform-wide +refactor of the EVOLV Node-RED nodes. Anyone (human or agent) working on +this refactor reads these files first. + +## Goals + +1. **Eliminate boilerplate** — every nodeClass today is ~80% identical. + Move the shared parts into `generalFunctions/`. Each node keeps only + what is genuinely node-specific. +2. **Split big domain classes** — `pumpingStation`, `machineGroupControl`, + and `rotatingMachine` each have ~1000–1800 line monolithic + `specificClass.js` files mixing 6+ concerns. Split each into focused + concern-based modules under `src/`. +3. **Document the contract** — every msg.topic the node accepts and every + message it emits is declared in code (a `commands/` module) and + surfaced in a per-node `CONTRACT.md`. +4. **Standardise naming** — consistent topic names across the platform + (`set.`, `cmd.`, `evt.`). +5. **Keep it readable** — small files, small functions, comments that say + *why* and skip *what*. + +## Constraint: this is the development branch + +All 12 submodules + the parent EVOLV repo are on a `development` branch. +`main` is untouched. We can change anything without breaking deployments +that track `main`. + +The refactor lands on `development`. Promotion to `main` happens once the +whole platform passes its 3-tier tests + Docker E2E. + +## Layered approach + +The refactor is sequenced as **tiers**, not a big bang. + +| Tier | What | Risk | Reversible? | +|---|---|---|---| +| 1 | Add infra in `generalFunctions` (additive only — no breaking changes) | Low | Yes | +| 2 | Pilot one node (pumpingStation) end-to-end on the new infra | Med | Yes | +| 3 | Convert remaining core nodes (measurement, MGC, rotatingMachine) | Med | Yes | +| 4 | Convert remaining nodes (valve, VGC, reactor, settler, monster, diffuser, dashboardAPI) | Low | Yes | +| 5 | Standardise input topic names + deprecation map | Med | Behind feature flag | +| 6 | Promote `development` → `main` once Docker E2E green platform-wide | Low | Yes | +| 7 | Wiki cleanup — visual-first template + Mermaid diagrams per node (post-refactor) | Low | Yes | + +Each tier is a sequence of small PRs on `development`, each with its +existing tests green. + +## Files in this directory + +| File | Purpose | +|---|---| +| `README.md` | This file. | +| `CONVENTIONS.md` | Code style, file size, comments, naming, imports, tests. | +| `CONTRACTS.md` | The exact shapes — `BaseNodeAdapter`, `BaseDomain`, commands registry, child router, unit policy, status badge, output ports. | +| `MODULE_SPLIT.md` | Per-node `src/` layout for the 4 core nodes + a generic template. | +| `TASKS.md` | Phased task list. The `TaskCreate` task tree mirrors this and is the active tracker. | +| `OPEN_QUESTIONS.md` | Decisions deferred to later — collected here so we don't lose them. | + +## Workflow rules for spawned agents + +If you are an agent working on a refactor task: + +1. Read this file, `CONVENTIONS.md`, `CONTRACTS.md`, and the relevant + section of `MODULE_SPLIT.md` before changing code. +2. Stay within the scope of one task. Don't expand scope without flagging. +3. Run the affected node's tests after every meaningful change. Commands: + ``` + cd nodes/ && node --test test/basic test/integration test/edge + ``` +4. Don't change `generalFunctions` exports unless your task is in tier 1. +5. If you discover something unclear, append it to `OPEN_QUESTIONS.md` + with a short note. Do **not** invent a decision. +6. Comments: small, function-level, *why* not *what*. See `CONVENTIONS.md`. +7. When done, summarise: files changed, tests run, anything deferred to + `OPEN_QUESTIONS.md`. diff --git a/.claude/refactor/TASKS.md b/.claude/refactor/TASKS.md new file mode 100644 index 0000000..8404a99 --- /dev/null +++ b/.claude/refactor/TASKS.md @@ -0,0 +1,236 @@ +# Task list + +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` (or the repo-mem rule path) | 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.