Compare commits
172 Commits
dev-rene
...
6d03d4301f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d03d4301f | ||
|
|
d8f14610cd | ||
|
|
473cbb6951 | ||
|
|
3cc8b65b69 | ||
|
|
b04b3bb628 | ||
|
|
ba4e41e640 | ||
|
|
2aafc38968 | ||
|
|
aaf8dd1498 | ||
|
|
d830a6a991 | ||
|
|
cb42740ee1 | ||
|
|
c529819696 | ||
|
|
62ad5605e8 | ||
|
|
2562ed2e9f | ||
|
|
52c091bd92 | ||
| 00a6fc5306 | |||
| d1412bc53c | |||
| d036646060 | |||
| 8224d15d51 | |||
| b3e24175de | |||
| 9a9bfdafc1 | |||
| 52bf827e9d | |||
| f867929634 | |||
| 042a5cc4ba | |||
| a65cdc3562 | |||
| 14140725bc | |||
|
|
3f84b91afb | ||
|
|
8c3d3ac69a | ||
|
|
fcaad8cd9f | ||
|
|
6ff262e96e | ||
|
|
025bdb4c7e | ||
|
|
1a9d0477bf | ||
|
|
f65ecab850 | ||
|
|
056f4a8d3e | ||
|
|
cdf517cba3 | ||
|
|
5e2c01ece3 | ||
|
|
424ad1e293 | ||
|
|
cc8e68a023 | ||
|
|
429d4b01ed | ||
|
|
6e6699c763 | ||
|
|
edef1cecbf | ||
|
|
ecd466f7a3 | ||
|
|
b01b3de741 | ||
|
|
e15f402d47 | ||
|
|
3ff75fcb09 | ||
|
|
d4e72f280e | ||
|
|
b1e0736e8e | ||
|
|
253ac93896 | ||
|
|
560cc2f39a | ||
|
|
4b6579a820 | ||
|
|
1ab913b699 | ||
|
|
18f68aa5da | ||
|
|
9924e66249 | ||
|
|
edc91dd988 | ||
|
|
3b192bec63 | ||
|
|
5eafd83443 | ||
|
|
29c0cdc37c | ||
|
|
123ef6fca3 | ||
|
|
f7ada0fd9d | ||
|
|
5ae8788fd7 | ||
|
|
2ccc8aea9e | ||
|
|
9ab9f6b3e3 | ||
|
|
b8cb889d87 | ||
|
|
14f9104722 | ||
|
|
e2aa6e6937 | ||
|
|
ff804af11c | ||
|
|
f9f1cceb82 | ||
|
|
0ff50a0291 | ||
|
|
44ffae12f7 | ||
|
|
4f970eaa0d | ||
|
|
6bf94f4c8a | ||
|
|
30928ce378 | ||
|
|
e691551ddd | ||
|
|
351e889918 | ||
|
|
c25866e7bc | ||
|
|
23dc23328d | ||
|
|
3bfb9833c0 | ||
|
|
afc304b424 | ||
|
|
3b7acdaa88 | ||
|
|
e03a7a51b7 | ||
|
|
0a890fd0d7 | ||
|
|
ce07cc564f | ||
|
|
1d0dd45d9a | ||
|
|
13da7388ff | ||
|
|
91e4255ef5 | ||
|
|
aec90cc8e7 | ||
|
|
6fef002da1 | ||
|
|
c4d75809cd | ||
|
|
a4617d850a | ||
|
|
44963cfa43 | ||
|
|
15c39f76bb | ||
|
|
3c7d54e9c3 | ||
|
|
21e777797a | ||
|
|
035f03cdee | ||
|
|
9bc6908d05 | ||
|
|
0cab98c196 | ||
|
|
ca0644d689 | ||
|
|
5766ee4d16 | ||
|
|
0466287514 | ||
|
|
21b0bd34c6 | ||
|
|
60c6a647e2 | ||
|
|
48b9335dac | ||
|
|
7c6c6183f7 | ||
|
|
2593458bdf | ||
|
|
36147de6d7 | ||
|
|
b84c59cbe6 | ||
|
|
b873a8fb02 | ||
|
|
7e51bec8f2 | ||
|
|
aa546df6e6 | ||
|
|
c413c0fad5 | ||
|
|
a6ad85ae38 | ||
|
|
33ac527274 | ||
|
|
d22d1cabd1 | ||
|
|
79afe11da8 | ||
|
|
b885f291d4 | ||
|
|
4a9521154b | ||
|
|
732b5a3380 | ||
|
|
c8f149e204 | ||
|
|
b693e0b90c | ||
|
|
2b0c4e89b1 | ||
|
|
faaeb2efd3 | ||
|
|
53b55d81c3 | ||
|
|
eb97670179 | ||
|
|
cc4ee670ea | ||
|
|
a51bc46e26 | ||
|
|
b18c47c07e | ||
|
|
60c8d0ff66 | ||
|
|
658915c53e | ||
|
|
0cbd6a4077 | ||
|
|
bc8138c3dc | ||
|
|
06d81169e8 | ||
|
|
82db2953e9 | ||
|
|
d439b048f2 | ||
|
|
e280d87e6a | ||
|
|
64944aa9d8 | ||
|
|
0d7af6bfff | ||
|
|
7aacee6482 | ||
|
|
d7d106773e | ||
|
|
89f3b5ddc4 | ||
|
|
d0fe4d0583 | ||
|
|
0300a76ae8 | ||
|
|
a1aa44f6ca | ||
|
|
6cf1821161 | ||
|
|
48f790d123 | ||
|
|
bac6c620b1 | ||
|
|
7ded2a4415 | ||
|
|
6d19038784 | ||
|
|
fd9d1679cb | ||
|
|
4336002b77 | ||
|
|
f57343f5e3 | ||
|
|
65ceb696ab | ||
|
|
91a298960c | ||
|
|
35221fc5dd | ||
|
|
93a5b6a90e | ||
|
|
1d98670706 | ||
|
|
a432eea7fe | ||
|
|
9cb3657bae | ||
|
|
bd9432eebb | ||
|
|
c9bacb64c8 | ||
|
|
e580c93c84 | ||
|
|
b02306c42f | ||
|
|
2c76430394 | ||
|
|
49ebd833db | ||
|
|
905a061590 | ||
|
|
80de324b32 | ||
|
|
c8d5ea0fce | ||
|
|
b871b23c24 | ||
|
|
91b681a74d | ||
|
|
76d2008e52 | ||
|
|
3c304f14e5 | ||
|
|
24c443840b | ||
|
|
c4c8629c01 | ||
| 609c72cedc |
@@ -15,12 +15,21 @@ EVOLV is a modular Node-RED package bundling multiple custom control/automation
|
|||||||
- `node_modules/`: Local install output; do not edit.
|
- `node_modules/`: Local install output; do not edit.
|
||||||
|
|
||||||
## Agent Knowledge Base
|
## Agent Knowledge Base
|
||||||
- `.agents/`: Root directory for repository-specific agent definitions and knowledge base content (non-runtime/support assets, not Node-RED production code).
|
- `.claude/skills/`: **EVOLV domain skills** — Claude-Code-native, auto-discovered, invokable via the `Skill` tool. Use for domain reasoning (rotating equipment, biology, telemetry, security, …).
|
||||||
- `.agents/skills/`: EVOLV specialist skills (domain instructions, workflows, and orchestrator logic).
|
- `.claude/agents/`: **Claude Code subagents** — auto-discovered, invokable via the `Agent` tool with `subagent_type:`. Use for spawnable independent work.
|
||||||
- When tasks involve domain reasoning or specialist routing, prefer `.agents/skills/*/SKILL.md` as the primary in-repo source of guidance.
|
- `.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
|
## 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.
|
- Orchestrator decides specialist selection, task decomposition, execution order, and integration checks.
|
||||||
- `team` keyword policy:
|
- `team` keyword policy:
|
||||||
- When the user says `team`, treat the request as orchestrator-led multi-specialist work.
|
- 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
|
- recommended plan
|
||||||
- risks and tradeoffs
|
- risks and tradeoffs
|
||||||
- unresolved disagreements (if any)
|
- 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`.
|
- 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.
|
- 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:
|
- 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.
|
- Breaking `msg.topic`/payload changes are allowed only with explicit migration/deprecation notes.
|
||||||
- Safety posture: `availability-first`
|
- Safety posture: `availability-first`
|
||||||
- Prefer continuity of operation with bounded safeguards over early protective trips.
|
- Prefer continuity of operation with bounded safeguards over early protective trips.
|
||||||
- Decision logging: `required for all decision-gate changes`
|
- 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/`.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Functional/architectural improvements backlog:
|
Functional/architectural improvements backlog:
|
||||||
- Track deferred functional/runtime/architecture improvements in `.agents/improvements/IMPROVEMENTS_BACKLOG.md`.
|
- 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.
|
- 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.
|
- 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
|
## 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 |
|
| Task Pattern | Primary Skill | Path |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Multi-domain feature, ambiguous ownership, or cross-node integration planning | Orchestrator | `.agents/skills/evolv-orchestrator/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 | `.agents/skills/evolv-frontend-node-red/SKILL.md` |
|
| 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 | `.agents/skills/evolv-mechanical-rotating-equipment/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 | `.agents/skills/evolv-instrumentation-assets/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 | `.agents/skills/evolv-process-systems-control/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 | `.agents/skills/evolv-biological-process-engineering/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 | `.agents/skills/evolv-database-influx-architecture/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 | `.agents/skills/evolv-measurement-product-specialist/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 | `.agents/skills/evolv-ot-edge-plc-integration/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 | `.agents/skills/evolv-ot-it-security/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 | `.agents/skills/evolv-alarms-interlocks-permissives/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 | `.agents/skills/evolv-process-hydraulics-mass-balance/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 | `.agents/skills/evolv-telemetry-analytics-dashboards/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 | `.agents/skills/evolv-regulatory-compliance-wastewater/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 | `.agents/skills/evolv-commissioning-validation/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 | `.agents/skills/evolv-quality-technical-debt/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
|
## Shared Engineering Baseline
|
||||||
- Dependencies: prefer `npm ci` at repo root (uses `package-lock.json`). Avoid changing `package.json` without updating the lockfile.
|
- 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.
|
- If legacy nodes are missing these artifacts, treat as technical debt and bring to parity during related work.
|
||||||
|
|
||||||
## Skill Ownership Of Detailed Standards
|
## Skill Ownership Of Detailed Standards
|
||||||
- Node-RED structure, file responsibilities, admin endpoints, and new-node checklist: `.agents/skills/evolv-frontend-node-red/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: `.agents/skills/evolv-process-systems-control/SKILL.md`
|
- Message/port conventions and topic contract behavior: `.claude/skills/evolv-process-systems-control/SKILL.md`
|
||||||
- Biological/kinetic modeling assumptions and plausibility constraints: `.agents/skills/evolv-biological-process-engineering/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: `.agents/skills/evolv-measurement-product-specialist/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: `.agents/skills/evolv-ot-edge-plc-integration/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: `.agents/skills/evolv-alarms-interlocks-permissives/SKILL.md`
|
- Alarm/interlock/permissive design standards: `.claude/skills/evolv-alarms-interlocks-permissives/SKILL.md`
|
||||||
- Hydraulics and mass-balance consistency rules: `.agents/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
|
- Hydraulics and mass-balance consistency rules: `.claude/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
|
||||||
- Telemetry KPI and dashboard/query contract standards: `.agents/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
|
- Telemetry KPI and dashboard/query contract standards: `.claude/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
|
||||||
- Wastewater compliance and auditability constraints: `.agents/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
|
- Wastewater compliance and auditability constraints: `.claude/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
|
||||||
- Commissioning/FAT/SAT validation standards: `.agents/skills/evolv-commissioning-validation/SKILL.md`
|
- Commissioning/FAT/SAT validation standards: `.claude/skills/evolv-commissioning-validation/SKILL.md`
|
||||||
- Test policy depth and quality gates: `.agents/skills/evolv-quality-technical-debt/SKILL.md`
|
- Test policy depth and quality gates: `.claude/skills/evolv-quality-technical-debt/SKILL.md`
|
||||||
- Multi-skill decomposition/integration and interview protocol: `.agents/skills/evolv-orchestrator/SKILL.md`
|
- Multi-skill decomposition/integration and interview protocol: `.claude/agents/evolv-orchestrator.md` (subagent)
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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 node’s 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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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:
|
|
||||||
@@ -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.
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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)
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -41,8 +41,8 @@ You are a biological process engineer specializing in wastewater treatment model
|
|||||||
- `.agents/function-anchors/monster/`
|
- `.agents/function-anchors/monster/`
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-biological-process-engineering/SKILL.md`
|
- `.claude/skills/evolv-biological-process-engineering/SKILL.md`
|
||||||
- `.agents/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
|
- `.claude/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
- [ ] Kinetic rates have correct temperature compensation
|
- [ ] 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
|
- [ ] Retention times consistent with reactor geometry and flow
|
||||||
|
|
||||||
## Reasoning Difficulty: Very High
|
## 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.
|
||||||
|
|||||||
@@ -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
|
- Mode changes (auto→manual, simulation→physical) are compliance-relevant events
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-commissioning-validation/SKILL.md`
|
- `.claude/skills/evolv-commissioning-validation/SKILL.md`
|
||||||
- `.agents/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
|
- `.claude/skills/evolv-regulatory-compliance-wastewater/SKILL.md`
|
||||||
- `.agents/skills/evolv-alarms-interlocks-permissives/SKILL.md`
|
- `.claude/skills/evolv-alarms-interlocks-permissives/SKILL.md`
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
- [ ] Compliance-relevant output fields unchanged (or migration documented)
|
- [ ] 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
|
- [ ] Control-action traceability maintained through the change
|
||||||
|
|
||||||
## Reasoning Difficulty: High
|
## 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.
|
||||||
|
|||||||
@@ -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
|
- Canonical internal units: Pa, m³/s, W, K
|
||||||
|
|
||||||
## Workflow
|
## 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?
|
2. Build an impact map: which nodes, contracts, and shared modules are affected?
|
||||||
3. Identify the minimum set of specialist agents needed
|
3. Identify the minimum set of specialist agents needed
|
||||||
4. Decompose into sequenced subtasks with clear acceptance criteria
|
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
|
- InfluxDB retention/backfill semantics or dashboard query contracts
|
||||||
|
|
||||||
## Reference Files
|
## Reference Files
|
||||||
- `.agents/skills/evolv-orchestrator/SKILL.md` — Full orchestration protocol
|
- `CONTRACTS.md` (EVOLV root) — front-door map: where every contract, rule, and standard lives
|
||||||
- `AGENTS.md` — Agent invocation policy, routing table, decision governance
|
- `.claude/refactor/CONTRACTS.md` — platform API shapes (BaseDomain, BaseNodeAdapter, commands registry, …)
|
||||||
- `.agents/decisions/` — Decision log directory
|
- `.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
|
- `.agents/improvements/IMPROVEMENTS_BACKLOG.md` — Deferred improvements
|
||||||
|
|
||||||
## Decision Governance
|
## 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
|
- Ask at most 3 questions per interview batch
|
||||||
- Owner-approved defaults: compatibility=controlled, safety=availability-first
|
- Owner-approved defaults: compatibility=controlled, safety=availability-first
|
||||||
|
|
||||||
## Reasoning Difficulty: Medium-High
|
## 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.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ dashboardAPI, diffuser, machineGroupControl, measurement, monster, pumpingStatio
|
|||||||
- `nodes/generalFunctions/src/*/` — Individual module directories
|
- `nodes/generalFunctions/src/*/` — Individual module directories
|
||||||
|
|
||||||
## Reference Skills
|
## 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`
|
- predict/interpolation/loadCurve → `evolv-mechanical-rotating-equipment`
|
||||||
- MeasurementContainer/nrmse/convert → `evolv-instrumentation-assets`
|
- MeasurementContainer/nrmse/convert → `evolv-instrumentation-assets`
|
||||||
- outputUtils → `evolv-database-influx-architecture`
|
- 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)
|
- Prefer additive changes (new exports) over breaking changes (renamed/removed exports)
|
||||||
|
|
||||||
## Reasoning Difficulty: Medium-High
|
## 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.
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ You are an instrumentation engineer specializing in sensor measurement, signal c
|
|||||||
- `.agents/function-anchors/measurement/`
|
- `.agents/function-anchors/measurement/`
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-instrumentation-assets/SKILL.md`
|
- `.claude/skills/evolv-instrumentation-assets/SKILL.md`
|
||||||
- `.agents/skills/evolv-measurement-product-specialist/SKILL.md`
|
- `.claude/skills/evolv-measurement-product-specialist/SKILL.md`
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
- [ ] Unit conversions chain correctly (no double-conversion)
|
- [ ] 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
|
- [ ] MeasurementContainer fields populated consistently
|
||||||
|
|
||||||
## Reasoning Difficulty: High
|
## 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.
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ You are a mechanical and process engineer specializing in rotating equipment, hy
|
|||||||
- `.agents/function-anchors/valve/`
|
- `.agents/function-anchors/valve/`
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-mechanical-rotating-equipment/SKILL.md`
|
- `.claude/skills/evolv-mechanical-rotating-equipment/SKILL.md`
|
||||||
- `.agents/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
|
- `.claude/skills/evolv-process-hydraulics-mass-balance/SKILL.md`
|
||||||
- `.agents/skills/evolv-alarms-interlocks-permissives/SKILL.md`
|
- `.claude/skills/evolv-alarms-interlocks-permissives/SKILL.md`
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
- [ ] Unit conversions use canonical system (Pa, m³/s, W, K internally)
|
- [ ] 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
|
- [ ] System curve intersection validated for duty point calculations
|
||||||
|
|
||||||
## Reasoning Difficulty: High
|
## 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.
|
||||||
|
|||||||
@@ -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.)
|
- `nodes/generalFunctions/` — Shared utilities (MenuManager, configManager, logger, etc.)
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-frontend-node-red/SKILL.md` — Detailed Node-RED frontend patterns
|
- `.claude/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-process-systems-control/SKILL.md` — Control architecture and topic contracts
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
- Never put `RED.*` calls in specificClass — that's nodeClass territory
|
- 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
|
- Always check `generalFunctions` MenuManager/configManager when modifying config flows
|
||||||
|
|
||||||
## Reasoning Difficulty: Medium
|
## 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.
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ You are an OT/IT security and edge integration specialist for the EVOLV industri
|
|||||||
- Watchdog timers for connection health monitoring
|
- Watchdog timers for connection health monitoring
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-ot-it-security/SKILL.md`
|
- `.claude/skills/evolv-ot-it-security/SKILL.md`
|
||||||
- `.agents/skills/evolv-ot-edge-plc-integration/SKILL.md`
|
- `.claude/skills/evolv-ot-edge-plc-integration/SKILL.md`
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
- Admin endpoints (`GET /<nodeName>/menu.js`, `GET /<nodeName>/configData.js`)
|
- 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
|
- [ ] Control messages validated before actuator commands are issued
|
||||||
|
|
||||||
## Reasoning Difficulty: High
|
## 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.
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ node --test nodes/<nodeName>/test/edge/*.test.js
|
|||||||
- `nodes/*/examples/` — Example flows
|
- `nodes/*/examples/` — Example flows
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-quality-technical-debt/SKILL.md`
|
- `.claude/skills/evolv-quality-technical-debt/SKILL.md`
|
||||||
- `.agents/skills/evolv-commissioning-validation/SKILL.md`
|
- `.claude/skills/evolv-commissioning-validation/SKILL.md`
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
- [ ] All 3 test tiers present (basic/integration/edge)
|
- [ ] 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)
|
- [ ] Code complexity reasonable (no god functions, clear naming)
|
||||||
|
|
||||||
## Reasoning Difficulty: Medium
|
## 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.
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ You are a telemetry and database specialist for the EVOLV platform, focusing on
|
|||||||
- `.agents/function-anchors/dashboardAPI/`
|
- `.agents/function-anchors/dashboardAPI/`
|
||||||
|
|
||||||
## Reference Skills
|
## Reference Skills
|
||||||
- `.agents/skills/evolv-database-influx-architecture/SKILL.md`
|
- `.claude/skills/evolv-database-influx-architecture/SKILL.md`
|
||||||
- `.agents/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
|
- `.claude/skills/evolv-telemetry-analytics-dashboards/SKILL.md`
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
- [ ] Tags are low-cardinality only (no timestamps, UUIDs, free-text)
|
- [ ] 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
|
- [ ] Retention policy matches data criticality and storage constraints
|
||||||
|
|
||||||
## Reasoning Difficulty: Medium
|
## 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.
|
||||||
|
|||||||
119
.claude/refactor/Archive/CONTINUE_HERE.md
Normal file
119
.claude/refactor/Archive/CONTINUE_HERE.md
Normal 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.
|
||||||
299
.claude/refactor/Archive/TASKS.md
Normal file
299
.claude/refactor/Archive/TASKS.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Task list — ARCHIVED
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **ARCHIVED — Phases 1–11 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 |
|
||||||
635
.claude/refactor/CONTRACTS.md
Normal file
635
.claude/refactor/CONTRACTS.md
Normal 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)} m³`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
175
.claude/refactor/CONVENTIONS.md
Normal file
175
.claude/refactor/CONVENTIONS.md
Normal 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.
|
||||||
208
.claude/refactor/MODULE_SPLIT.md
Normal file
208
.claude/refactor/MODULE_SPLIT.md
Normal 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.
|
||||||
765
.claude/refactor/OPEN_QUESTIONS.md
Normal file
765
.claude/refactor/OPEN_QUESTIONS.md
Normal 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).
|
||||||
42
.claude/refactor/README.md
Normal file
42
.claude/refactor/README.md
Normal 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.
|
||||||
141
.claude/refactor/WIKI_HOME_TEMPLATE.md
Normal file
141
.claude/refactor/WIKI_HOME_TEMPLATE.md
Normal 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 -->
|
||||||
|
```
|
||||||
349
.claude/refactor/WIKI_TEMPLATE.md
Normal file
349
.claude/refactor/WIKI_TEMPLATE.md
Normal 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 1–9 and 11–14 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` | 0–3 | — | `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` | 0–100 | `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 1–4, 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`.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
---
|
---
|
||||||
paths:
|
paths:
|
||||||
|
- "nodes/*/*.js"
|
||||||
|
- "nodes/*/*.html"
|
||||||
- "nodes/*/src/**"
|
- "nodes/*/src/**"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,9 +25,63 @@ Every node follows entry → nodeClass → specificClass:
|
|||||||
- Port 1: InfluxDB telemetry payload
|
- Port 1: InfluxDB telemetry payload
|
||||||
- Port 2: Registration/control plumbing (parent-child handshakes)
|
- 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
|
## Admin Endpoints
|
||||||
- `GET /<nodeName>/menu.js` — Dynamic menu configuration for editor
|
- `GET /<nodeName>/menu.js` — Dynamic menu configuration for editor
|
||||||
- `GET /<nodeName>/configData.js` — Runtime 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
|
## Submodule Awareness
|
||||||
Most `nodes/*` directories are git submodules. Keep edits scoped to the target node's directory.
|
Most `nodes/*` directories are git submodules. Keep edits scoped to the target node's directory.
|
||||||
|
|||||||
535
.claude/rules/node-red-flow-layout.md
Normal file
535
.claude/rules/node-red-flow-layout.md
Normal 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.
|
||||||
205
.claude/rules/output-coverage.md
Normal file
205
.claude/rules/output-coverage.md
Normal 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`.
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
paths:
|
paths:
|
||||||
- "nodes/*/src/nodeClass.js"
|
- "nodes/*/src/nodeClass.js"
|
||||||
|
- "nodes/*/src/specificClass.js"
|
||||||
|
- "nodes/*/src/output/**"
|
||||||
|
- "nodes/generalFunctions/src/outputUtils/**"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Telemetry Rules
|
# Telemetry Rules
|
||||||
|
|
||||||
## Output Port Convention
|
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.
|
||||||
- Port 0: Process data (downstream node consumption)
|
|
||||||
- Port 1: InfluxDB telemetry payload
|
|
||||||
- Port 2: Registration/control plumbing
|
|
||||||
|
|
||||||
## InfluxDB Payload Structure
|
## InfluxDB Payload Structure
|
||||||
Port 1 payloads must follow InfluxDB line protocol conventions:
|
Port 1 payloads must follow InfluxDB line protocol conventions:
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ paths:
|
|||||||
|
|
||||||
# Testing Rules
|
# Testing Rules
|
||||||
|
|
||||||
## 3-Tier Test Structure
|
## Test Structure
|
||||||
Every node must have:
|
Every node has at minimum:
|
||||||
- `test/basic/*.test.js` — Unit tests for individual functions
|
- `test/basic/*.test.js` — Unit tests for individual functions (specificClass domain logic)
|
||||||
- `test/integration/*.test.js` — Node interaction and message passing tests
|
- `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
|
- `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
|
## Test Runner
|
||||||
```bash
|
```bash
|
||||||
node --test nodes/<nodeName>/test/basic/*.test.js
|
node --test nodes/<nodeName>/test/basic/*.test.js
|
||||||
node --test nodes/<nodeName>/test/integration/*.test.js
|
node --test nodes/<nodeName>/test/integration/*.test.js
|
||||||
node --test nodes/<nodeName>/test/edge/*.test.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test Requirements
|
## 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 (`examples/`) must stay in sync with implementation
|
||||||
|
|
||||||
## Example Flows
|
## Example Flows
|
||||||
Each node must maintain:
|
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.
|
||||||
- `examples/README.md`
|
|
||||||
- `examples/basic.flow.json`
|
|
||||||
- `examples/integration.flow.json`
|
|
||||||
- `examples/edge.flow.json`
|
|
||||||
|
|
||||||
## No Node-RED Runtime in Unit Tests
|
## No Node-RED Runtime in Unit Tests
|
||||||
Basic tests should test specificClass domain logic without requiring a running Node-RED instance.
|
Basic tests should test specificClass domain logic without requiring a running Node-RED instance.
|
||||||
|
|||||||
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
217
.claude/skills/README.md
Normal file
217
.claude/skills/README.md
Normal 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
|
||||||
|
# 6–8 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.
|
||||||
43
.claude/skills/grill-me/SKILL.md
Normal file
43
.claude/skills/grill-me/SKILL.md
Normal 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 1–3 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.
|
||||||
|
|
||||||
169
.claude/skills/prd-to-issues/SKILL.md
Normal file
169
.claude/skills/prd-to-issues/SKILL.md
Normal 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=1–2 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**
|
||||||
|
<1–3 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.
|
||||||
59
.claude/skills/prd/SKILL.md
Normal file
59
.claude/skills/prd/SKILL.md
Normal 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** — 2–5 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. 1–3 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.
|
||||||
65
.claude/skills/prototype/SKILL.md
Normal file
65
.claude/skills/prototype/SKILL.md
Normal 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
|
||||||
|
<2–3 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.
|
||||||
70
.claude/skills/research/SKILL.md
Normal file
70
.claude/skills/research/SKILL.md
Normal 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 3–5 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."
|
||||||
115
.claude/skills/ship-it/SKILL.md
Normal file
115
.claude/skills/ship-it/SKILL.md
Normal 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.
|
||||||
70
.claude/skills/ship-it/iterate.md
Normal file
70
.claude/skills/ship-it/iterate.md
Normal 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
|
||||||
|
```
|
||||||
189
.claude/skills/ship-it/loop.sh
Normal file
189
.claude/skills/ship-it/loop.sh
Normal 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
|
||||||
@@ -11,7 +11,9 @@ node_modules/
|
|||||||
# Agent/Claude metadata (not needed at runtime)
|
# Agent/Claude metadata (not needed at runtime)
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
manuals/
|
|
||||||
|
# Documentation (not needed at runtime)
|
||||||
|
wiki/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -23,10 +25,3 @@ manuals/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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
41
.gitea/workflows/ci.yml
Normal 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
15
.gitignore
vendored
@@ -7,4 +7,17 @@ npm-debug.log*
|
|||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
# Build artifacts
|
# 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
77
.gitmodules
vendored
@@ -1,37 +1,40 @@
|
|||||||
|
|
||||||
[submodule "nodes/machineGroupControl"]
|
[submodule "nodes/machineGroupControl"]
|
||||||
path = nodes/machineGroupControl
|
path = nodes/machineGroupControl
|
||||||
url = https://gitea.wbd-rd.nl/RnD/machineGroupControl.git
|
url = https://gitea.wbd-rd.nl/RnD/machineGroupControl.git
|
||||||
[submodule "nodes/generalFunctions"]
|
[submodule "nodes/generalFunctions"]
|
||||||
path = nodes/generalFunctions
|
path = nodes/generalFunctions
|
||||||
url = https://gitea.wbd-rd.nl/RnD/generalFunctions.git
|
url = https://gitea.wbd-rd.nl/RnD/generalFunctions.git
|
||||||
[submodule "nodes/valveGroupControl"]
|
[submodule "nodes/valveGroupControl"]
|
||||||
path = nodes/valveGroupControl
|
path = nodes/valveGroupControl
|
||||||
url = https://gitea.wbd-rd.nl/RnD/valveGroupControl.git
|
url = https://gitea.wbd-rd.nl/RnD/valveGroupControl.git
|
||||||
[submodule "nodes/valve"]
|
[submodule "nodes/valve"]
|
||||||
path = nodes/valve
|
path = nodes/valve
|
||||||
url = https://gitea.wbd-rd.nl/RnD/valve.git
|
url = https://gitea.wbd-rd.nl/RnD/valve.git
|
||||||
[submodule "nodes/rotatingMachine"]
|
[submodule "nodes/rotatingMachine"]
|
||||||
path = nodes/rotatingMachine
|
path = nodes/rotatingMachine
|
||||||
url = https://gitea.wbd-rd.nl/RnD/rotatingMachine.git
|
url = https://gitea.wbd-rd.nl/RnD/rotatingMachine.git
|
||||||
[submodule "nodes/monster"]
|
[submodule "nodes/monster"]
|
||||||
path = nodes/monster
|
path = nodes/monster
|
||||||
url = https://gitea.wbd-rd.nl/RnD/monster.git
|
url = https://gitea.wbd-rd.nl/RnD/monster.git
|
||||||
[submodule "nodes/measurement"]
|
[submodule "nodes/measurement"]
|
||||||
path = nodes/measurement
|
path = nodes/measurement
|
||||||
url = https://gitea.wbd-rd.nl/RnD/measurement.git
|
url = https://gitea.wbd-rd.nl/RnD/measurement.git
|
||||||
[submodule "nodes/diffuser"]
|
[submodule "nodes/diffuser"]
|
||||||
path = nodes/diffuser
|
path = nodes/diffuser
|
||||||
url = https://gitea.wbd-rd.nl/RnD/diffuser.git
|
url = https://gitea.wbd-rd.nl/RnD/diffuser.git
|
||||||
[submodule "nodes/dashboardAPI"]
|
[submodule "nodes/dashboardAPI"]
|
||||||
path = nodes/dashboardAPI
|
path = nodes/dashboardAPI
|
||||||
url = https://gitea.wbd-rd.nl/RnD/dashboardAPI.git
|
url = https://gitea.wbd-rd.nl/RnD/dashboardAPI.git
|
||||||
[submodule "nodes/reactor"]
|
[submodule "nodes/reactor"]
|
||||||
path = nodes/reactor
|
path = nodes/reactor
|
||||||
url = https://gitea.wbd-rd.nl/RnD/reactor.git
|
url = https://gitea.wbd-rd.nl/RnD/reactor.git
|
||||||
[submodule "nodes/pumpingStation"]
|
[submodule "nodes/pumpingStation"]
|
||||||
path = nodes/pumpingStation
|
path = nodes/pumpingStation
|
||||||
url = https://gitea.wbd-rd.nl/RnD/pumpingStation
|
url = https://gitea.wbd-rd.nl/RnD/pumpingStation
|
||||||
[submodule "nodes/settler"]
|
[submodule "nodes/settler"]
|
||||||
path = nodes/settler
|
path = nodes/settler
|
||||||
url = https://gitea.wbd-rd.nl/RnD/settler.git
|
url = https://gitea.wbd-rd.nl/RnD/settler.git
|
||||||
|
[submodule "nodes/coresync"]
|
||||||
|
path = nodes/coresync
|
||||||
|
url = https://gitea.wbd-rd.nl/RnD/coresync.git
|
||||||
|
|||||||
60
.npmignore
60
.npmignore
@@ -1,2 +1,58 @@
|
|||||||
# Ignore test files
|
# === Mirrors .gitignore — same deny list applies when packing for npm,
|
||||||
node_modules/
|
# 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
90
CLAUDE.md
Normal 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
148
CONTRACTS.md
Normal 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.*
|
||||||
414
CORESYNC_FROST_INTERVIEW_HANDOFF.md
Normal file
414
CORESYNC_FROST_INTERVIEW_HANDOFF.md
Normal 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.
|
||||||
@@ -6,10 +6,14 @@ FROM nodered/node-red:latest
|
|||||||
# Install curl for health checks
|
# Install curl for health checks
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache curl
|
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
|
WORKDIR /data/evolv
|
||||||
|
USER node-red
|
||||||
|
|
||||||
# -------------------------------------------------------
|
# -------------------------------------------------------
|
||||||
# Layer-cache: copy dependency manifests first
|
# Layer-cache: copy dependency manifests first
|
||||||
|
|||||||
@@ -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
224
README.md
@@ -1,147 +1,77 @@
|
|||||||
# R&D Bouwblok: EVOLV (Edge-Layer Evolution for Optimized Virtualization)
|
# EVOLV — Edge-Layer Evolution for Optimized Virtualization
|
||||||
|
|
||||||
## Over
|
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.
|
||||||
|
|
||||||
Dit bouwblok is ontwikkeld door het R&D-team van Waterschap Brabantse Delta voor gebruik in Node-RED.
|
## Nodes
|
||||||
|
|
||||||
|
| Node | Functie | S88-niveau |
|
||||||
> *[Voeg hier een korte toelichting toe over de specifieke functionele werking van dit bouwblok]*
|
|------|---------|------------|
|
||||||
|
| **rotatingMachine** | Individuele pomp/compressor/blower aansturing | Equipment |
|
||||||
---
|
| **machineGroupControl** | Multi-pomp optimalisatie (BEP-Gravitation) | Unit |
|
||||||
|
| **pumpingStation** | Pompgemaal met hydraulische context | Unit |
|
||||||
## Licentie
|
| **valve** | Individuele klep modellering | Equipment |
|
||||||
|
| **valveGroupControl** | Klep groep coordinatie | Unit |
|
||||||
Deze software valt onder de **Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)**-licentie.
|
| **reactor** | Biologische reactor (ASM kinetiek) | Unit |
|
||||||
|
| **settler** | Nabezinker / slibscheiding | Unit |
|
||||||
- Gebruik, aanpassing en verspreiding is toegestaan voor **niet-commerciële doeleinden**, mits duidelijke naamsvermelding naar Waterschap Brabantse Delta.
|
| **monster** | Multi-parameter biologische monitoring | Equipment |
|
||||||
- Voor **commercieel gebruik** is voorafgaande toestemming vereist.
|
| **measurement** | Sensor signaalconditionering | Control Module |
|
||||||
|
| **diffuser** | Beluchting aansturing | Equipment |
|
||||||
📧 Contact: [rdlab@brabantsedelta.nl](mailto:rdlab@brabantsedelta.nl)
|
| **dashboardAPI** | InfluxDB telemetrie + FlowFuse dashboards | — |
|
||||||
🔗 Licentie: [https://creativecommons.org/licenses/by-nc/4.0/](https://creativecommons.org/licenses/by-nc/4.0/)
|
| **generalFunctions** | Gedeelde bibliotheek (predict, PID, convert, etc.) | — |
|
||||||
|
|
||||||
---
|
## Architectuur
|
||||||
|
|
||||||
## Generieke opbouw van bouwblokken
|
Elke node volgt een drie-lagen patroon:
|
||||||
|
1. **Entry file** (`<naam>.js`) — registratie bij Node-RED, admin endpoints
|
||||||
- Reageren automatisch op inkomende data (bijv. de positie van een object bepaalt de berekening).
|
2. **nodeClass** (`src/nodeClass.js`) — Node-RED adapter (tick loop, routing, status)
|
||||||
- Ondersteunen koppeling van complexe dataketens tussen processen.
|
3. **specificClass** (`src/specificClass.js`) — pure domeinlogica (fysica, toestandsmachines)
|
||||||
- Gestandaardiseerde input/output:
|
|
||||||
- Output = procesdata
|
Drie output-poorten per node: **Port 0** = procesdata, **Port 1** = InfluxDB telemetrie, **Port 2** = registratie/besturing.
|
||||||
- Opslaginformatie + relatieve positionering t.o.v. andere objecten
|
|
||||||
- Ontworpen voor combinatie met andere bouwblokken (ook van derden).
|
## Installatie
|
||||||
- Open source en vrij beschikbaar voor iedereen.
|
|
||||||
|
```bash
|
||||||
---
|
git clone --recurse-submodules https://gitea.wbd-rd.nl/RnD/EVOLV.git
|
||||||
|
cd EVOLV
|
||||||
## Installatie – Alle bouwblokken (via EVOLV)
|
npm install
|
||||||
|
```
|
||||||
Alle bouwblokken van het R&D-team zijn gebundeld in de **EVOLV-repository**, waarin gebruik wordt gemaakt van Git submodules.
|
|
||||||
|
Submodules updaten:
|
||||||
### Eerste keer klonen:
|
```bash
|
||||||
|
git submodule update --remote --merge
|
||||||
```bash
|
```
|
||||||
git clone --recurse-submodules https://gitea.centraal.wbd-rd.nl/RnD/EVOLV.git
|
|
||||||
cd EVOLV
|
Enkel bouwblok installeren in Node-RED:
|
||||||
```
|
```bash
|
||||||
|
mkdir -p ~/.node-red/nodes
|
||||||
Of, als je zonder submodules hebt gekloond:
|
cp -r nodes/<bouwblok-naam> ~/.node-red/nodes/
|
||||||
|
```
|
||||||
```bash
|
|
||||||
git submodule init
|
## Testen
|
||||||
git submodule update
|
|
||||||
```
|
```bash
|
||||||
|
# Alle nodes
|
||||||
### Submodules updaten:
|
bash scripts/test-all.sh
|
||||||
|
|
||||||
Om alle submodules te updaten naar de laatste versie van hun eigen repository:
|
# Specifieke node
|
||||||
|
node --test nodes/<nodeName>/test/basic/*.test.js
|
||||||
```bash
|
node --test nodes/<nodeName>/test/integration/*.test.js
|
||||||
git submodule update --remote --merge
|
node --test nodes/<nodeName>/test/edge/*.test.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Individuele submodule updaten:
|
## Documentatie
|
||||||
|
|
||||||
```bash
|
- **`wiki/`** — Projectwiki met architectuur, bevindingen en metrics ([index](wiki/index.md))
|
||||||
cd nodes/<bouwblok-naam>
|
- **`CLAUDE.md`** — Claude Code projectgids
|
||||||
git checkout main
|
- **`manuals/node-red/`** — FlowFuse en Node-RED referentiedocumentatie
|
||||||
git pull origin main
|
- **`.agents/`** — Agent skills, beslissingen en function-anchors
|
||||||
cd ../..
|
|
||||||
git add nodes/<bouwblok-naam>
|
## Licentie
|
||||||
git commit -m "Update submodule <bouwblok-naam>"
|
|
||||||
```
|
**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.
|
||||||
|
|
||||||
## Installatie – Enkel bouwblok
|
## Contact
|
||||||
|
|
||||||
1. Clone de gewenste repository:
|
rdlab@brabantsedelta.nl
|
||||||
|
|
||||||
```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
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ services:
|
|||||||
- .:/data/evolv:cached
|
- .:/data/evolv:cached
|
||||||
# Named volume: overlay node_modules so host doesn't need native deps
|
# Named volume: overlay node_modules so host doesn't need native deps
|
||||||
- evolv_node_modules:/data/evolv/node_modules
|
- 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:
|
environment:
|
||||||
- TZ=Europe/Amsterdam
|
- TZ=Europe/Amsterdam
|
||||||
- LOCATION_ID=docker-dev
|
- LOCATION_ID=docker-dev
|
||||||
@@ -63,7 +67,10 @@ services:
|
|||||||
# Grafana — dashboard visualization
|
# Grafana — dashboard visualization
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
grafana:
|
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
|
container_name: evolv-grafana
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -83,6 +90,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
evolv_node_modules:
|
evolv_node_modules:
|
||||||
driver: local
|
driver: local
|
||||||
|
nodered_data:
|
||||||
|
driver: local
|
||||||
influxdb_data:
|
influxdb_data:
|
||||||
driver: local
|
driver: local
|
||||||
grafana_data:
|
grafana_data:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -63,18 +63,90 @@ npm install --no-save "$EVOLV_DIR" 2>/dev/null || {
|
|||||||
echo "[entrypoint] EVOLV nodes installed into Node-RED user dir."
|
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"
|
DEMO_FLOW="$EVOLV_DIR/docker/demo-flow.json"
|
||||||
FLOW_FILE="/data/flows.json"
|
FLOW_FILE="/data/flows.json"
|
||||||
|
if [ -f "$DEMO_FLOW" ] && [ ! -f "$FLOW_FILE" ]; then
|
||||||
if [ -f "$DEMO_FLOW" ]; then
|
cp "$DEMO_FLOW" "$FLOW_FILE"
|
||||||
# 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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -------------------------------------------------------
|
# -------------------------------------------------------
|
||||||
|
|||||||
546
docker/grafana/provisioning/dashboards/coresync-frost-demo.json
Normal file
546
docker/grafana/provisioning/dashboards/coresync-frost-demo.json
Normal 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: 60–95% 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": ""
|
||||||
|
}
|
||||||
14
docker/grafana/provisioning/dashboards/dashboards.yaml
Normal file
14
docker/grafana/provisioning/dashboards/dashboards.yaml
Normal 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
|
||||||
435
docker/grafana/provisioning/dashboards/pumping-station.json
Normal file
435
docker/grafana/provisioning/dashboards/pumping-station.json
Normal 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)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -15,10 +15,22 @@ module.exports = {
|
|||||||
// No authentication for dev environment
|
// No authentication for dev environment
|
||||||
adminAuth: null,
|
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: {
|
editorTheme: {
|
||||||
projects: {
|
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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
82
docs/prd/dashboardapi-graph-aware-grafana-generator.md
Normal file
82
docs/prd/dashboardapi-graph-aware-grafana-generator.md
Normal 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).
|
||||||
56
docs/research/dashboardapi-graph-aware-grafana-generator.md
Normal file
56
docs/research/dashboardapi-graph-aware-grafana-generator.md
Normal 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
29
eslint.config.js
Normal 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
Reference in New Issue
Block a user