Drop tensorflow deps; rule cleanups; repo-mem MCP; bump pumpingStation@6ab585b
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
- package.json: remove @tensorflow/tfjs and @tensorflow/tfjs-node. Monster's TF code was already stripped; the deps were stale and kept pulling a heavy native binary back into every install. - .gitignore: ignore .repo-mem/ regenerable indexes and per-session .claude/*.lock runtime files. - CLAUDE.md: prepend READ-FIRST pointer to .claude/rules/repo-mem.md; collapse the 'three outputs' bullet to a pointer at node-architecture. - .claude/rules/telemetry.md: drop Port 0/1/2 duplication; reference node-architecture.md. - .claude/rules/testing.md: stop requiring a separate test/edge tier and the basic/integration/edge example flow trio. Reflects what nodes actually do. - .claude/rules/repo-mem.md (new): when-to-call-which guide for the per-repo memory MCP, anti-patterns, refresh model. - .mcp.json (new): wire repo-mem stdio server. - docs/DEVELOPER_GUIDE.md (new): step-by-step guide for adding a new EVOLV node under the three-layer pattern. - Bump nodes/pumpingStation to 6ab585b (docs + simulations refresh, spill-flow path renames consistent with d8490aa). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
80
.claude/rules/repo-mem.md
Normal file
80
.claude/rules/repo-mem.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# repo-mem MCP Tools
|
||||||
|
|
||||||
|
This repo has a per-repo memory MCP server (`repo-mem`) wired via `.mcp.json`. It exposes 5 tools backed by a Hopfield substrate trained on EVOLV's source plus a BM25 index over file chunks. **Use them. They are faster and better-targeted than `grep` for concept queries, and they accumulate institutional memory of repairs.**
|
||||||
|
|
||||||
|
If `/mcp` does not list `repo-mem` as Connected, the rest of this file does not apply for this session — fall back to `grep` / `Read`.
|
||||||
|
|
||||||
|
## When to call which tool
|
||||||
|
|
||||||
|
### `repo_search(query, k=8)` — primary lookup tool
|
||||||
|
Use **before** `grep` / `find` / `Explore` agent for any natural-language "where is X handled / find all places that do Y / what code implements Z" question.
|
||||||
|
|
||||||
|
- ✅ "where is the predicted volume integrator?" → `repo_search`
|
||||||
|
- ✅ "find places that emit InfluxDB line protocol" → `repo_search`
|
||||||
|
- ❌ "find every occurrence of `_updatePredictedVolume`" → `grep` (exact symbol — BM25 doesn't beat grep at exact-string lookup)
|
||||||
|
- ❌ "list all `.test.js` files" → `find` / `ls` (no concept query)
|
||||||
|
|
||||||
|
Returns top-K files with `file:line` ranges and snippets. Read the snippet first; only open the file if the snippet doesn't answer the question.
|
||||||
|
|
||||||
|
### `repo_similar_fixes(query, failure?, files?, tags?, k=5)` — start-of-task context
|
||||||
|
Call at the **start** of any non-trivial bug fix or behavioral change. Cheap (BM25 + file overlap + atom cosine), zero downside if it returns nothing useful.
|
||||||
|
|
||||||
|
- Pass the user's task description as `query`.
|
||||||
|
- If there's a failing test or stack trace, pass it as `failure`.
|
||||||
|
- If you already know which files are involved, pass them as `files`.
|
||||||
|
- Skim the returned traces; surface any near-match to the user before starting.
|
||||||
|
|
||||||
|
### `repo_record_fix({task, failure, files, diff_summary, patch, tests, outcome, tags})` — end-of-task persist
|
||||||
|
Call at the **end** of a landed fix or behavioral change, **before** reporting completion to the user. Skip for trivial typo/comment commits. Required fields: `task` and `outcome`. Recommended:
|
||||||
|
- `failure`: the symptom that prompted the work (test output, user description, stack trace).
|
||||||
|
- `files`: the files actually changed.
|
||||||
|
- `diff_summary`: 1–3 sentences on *what* changed and *why*.
|
||||||
|
- `patch`: the unified diff (truncate to the load-bearing hunks if huge).
|
||||||
|
- `tests`: the verification command(s) you ran.
|
||||||
|
- `outcome`: `passed` / `failed` / `partial` / `reverted`.
|
||||||
|
- `tags`: short labels (`overflow-clamp`, `tokenizer`, `migration`, etc.) for retrieval bias.
|
||||||
|
|
||||||
|
Rule of thumb: if the change took more than one read+edit pair, record it.
|
||||||
|
|
||||||
|
### `substrate_score(text, worst_k=5)` — OOD-token check
|
||||||
|
Use **sparingly**. After generating a non-trivial code block (≥ ~30 lines of new logic, not test scaffolding), pass it through `substrate_score` and inspect the worst-confidence positions for typos, wrong identifiers, or out-of-house style. Noisy on small additions — don't use it for one-line tweaks.
|
||||||
|
|
||||||
|
### `substrate_top_next(context, k=10)` — rarely
|
||||||
|
Predicts next BPE-subword tokens in the local style. Mostly useful for autonomous solver loops; in interactive review it's diagnostic only. If you find yourself wanting it, you probably want `repo_search` instead.
|
||||||
|
|
||||||
|
## Workflow shape
|
||||||
|
|
||||||
|
```
|
||||||
|
new task arrives
|
||||||
|
↓
|
||||||
|
repo_similar_fixes(query=user_task) ← cheap, always do this for non-trivial tasks
|
||||||
|
↓
|
||||||
|
repo_search(query=concept) ← when scoping
|
||||||
|
↓
|
||||||
|
[normal Read / Edit / Bash work]
|
||||||
|
↓
|
||||||
|
[after generating non-trivial new code]
|
||||||
|
substrate_score(text=new_block) ← optional, only if block is big
|
||||||
|
↓
|
||||||
|
[verify: tests / build / smoke]
|
||||||
|
↓
|
||||||
|
repo_record_fix({...}) ← before final user-facing summary
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- ❌ Calling `repo_search` when you already know the file path. Just `Read` it.
|
||||||
|
- ❌ Calling `repo_record_fix` after every micro-edit. Only at meaningful task boundaries.
|
||||||
|
- ❌ Treating `substrate_top_next` results as authoritative — they reflect repo style, not correctness.
|
||||||
|
- ❌ Passing the full conversation to `substrate_score` — it's per-snippet, not per-session.
|
||||||
|
|
||||||
|
## Refresh model
|
||||||
|
|
||||||
|
The post-commit hook auto-runs `--quick --lock` (re-ingest + BM25 + chunk re-embed; substrate retrain skipped) so retrieval stays current within ~2 s of any commit. The substrate itself is only retrained when you (or a maintainer) run `--full` manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ~/anchor-net-master/tools/repo-mem/refresh.mjs \
|
||||||
|
--repo . --in .repo-mem --full
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-train when the repo gains substantially new vocabulary (new node, new domain, new dependency surface). Otherwise BM25 + existing atoms keep up.
|
||||||
@@ -5,10 +5,7 @@ paths:
|
|||||||
|
|
||||||
# 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
.gitignore
vendored
5
.gitignore
vendored
@@ -8,3 +8,8 @@ npm-debug.log*
|
|||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.tgz
|
*.tgz
|
||||||
|
# repo-mem regenerable indexes
|
||||||
|
.repo-mem/
|
||||||
|
|
||||||
|
# Per-session runtime locks (scheduled_tasks, etc.)
|
||||||
|
.claude/*.lock
|
||||||
|
|||||||
14
.mcp.json
Normal file
14
.mcp.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"repo-mem": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"/home/znetsixe/anchor-net-master/tools/repo-mem/server.mjs",
|
||||||
|
"--in",
|
||||||
|
"/home/znetsixe/EVOLV/.repo-mem"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# EVOLV - Claude Code Project Guide
|
# EVOLV - Claude Code Project Guide
|
||||||
|
|
||||||
|
> **READ FIRST, BEFORE ANY OTHER WORK:** `.claude/rules/repo-mem.md` — this repo has an MCP server (`repo-mem`) exposing a substrate-trained `repo_search` and a persistent fix-trace store. Use those instead of grep for concept queries, and record completed fixes via `repo_record_fix`. Triggers, anti-patterns, and refresh model are in that rule.
|
||||||
|
|
||||||
## What This Is
|
## 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.
|
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.
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ Each node follows a three-layer pattern:
|
|||||||
- S88 color scheme: Area=#0f52a5, ProcessCell=#0c99d9, Unit=#50a8d9, Equipment=#86bbdd, ControlModule=#a9daee
|
- S88 color scheme: Area=#0f52a5, ProcessCell=#0c99d9, Unit=#50a8d9, Equipment=#86bbdd, ControlModule=#a9daee
|
||||||
- Config JSON files in `generalFunctions/src/configs/` define defaults, types, enums per node
|
- Config JSON files in `generalFunctions/src/configs/` define defaults, types, enums per node
|
||||||
- Tick loop runs at 1000ms intervals for time-based updates
|
- Tick loop runs at 1000ms intervals for time-based updates
|
||||||
- Three outputs per node: [process, dbase, parent]
|
- Output ports + 3-tier architecture: 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/`
|
- **Multi-tab demo flows**: see `.claude/rules/node-red-flow-layout.md` for the tab/link-channel/spacing rule set used by `examples/`
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|||||||
602
docs/DEVELOPER_GUIDE.md
Normal file
602
docs/DEVELOPER_GUIDE.md
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
# EVOLV Developer Guide: Creating a New Node
|
||||||
|
|
||||||
|
This guide walks through creating a new EVOLV node from scratch, following the project's three-layer architecture pattern.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** (v18+)
|
||||||
|
- **Node-RED** installed globally or as a dev dependency
|
||||||
|
- Clone the repo and run `npm install` in the root (no build step required)
|
||||||
|
- The `generalFunctions` submodule must be initialized (`git submodule update --init`)
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Every EVOLV node follows a **three-layer pattern**:
|
||||||
|
|
||||||
|
| Layer | File | Responsibility |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| 1 - Wrapper | `<name>.js` | Registers the node type with Node-RED, sets up HTTP endpoints for menus/config |
|
||||||
|
| 2 - Node Adapter | `src/nodeClass.js` | Bridges Node-RED with domain logic: config loading, tick loop, input routing, lifecycle |
|
||||||
|
| 3 - Domain Logic | `src/specificClass.js` | Pure business logic with no Node-RED dependencies |
|
||||||
|
|
||||||
|
Plus a UI definition: `<name>.html` for the Node-RED editor.
|
||||||
|
|
||||||
|
## Step-by-Step: Creating a New Node
|
||||||
|
|
||||||
|
We will create a hypothetical `flowMeter` node as an example.
|
||||||
|
|
||||||
|
### Step 1: Create Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nodes/flowMeter/
|
||||||
|
flowMeter.js # Layer 1 - wrapper
|
||||||
|
flowMeter.html # UI definition
|
||||||
|
src/
|
||||||
|
nodeClass.js # Layer 2 - node adapter
|
||||||
|
specificClass.js # Layer 3 - domain logic
|
||||||
|
test/
|
||||||
|
specificClass.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Write the Wrapper (`flowMeter.js`)
|
||||||
|
|
||||||
|
The wrapper registers the node type with Node-RED and exposes HTTP endpoints for dynamic menus and config data.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const nameOfNode = 'flowMeter';
|
||||||
|
const nodeClass = require('./src/nodeClass.js');
|
||||||
|
const { MenuManager, configManager } = require('generalFunctions');
|
||||||
|
|
||||||
|
module.exports = function(RED) {
|
||||||
|
// Register the node type
|
||||||
|
RED.nodes.registerType(nameOfNode, function(config) {
|
||||||
|
RED.nodes.createNode(this, config);
|
||||||
|
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu endpoint (dynamic dropdowns in the editor UI)
|
||||||
|
const menuMgr = new MenuManager();
|
||||||
|
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const script = menuMgr.createEndpoint(nameOfNode, ['asset', 'logger', 'position']);
|
||||||
|
res.type('application/javascript').send(script);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).send(`// Error generating menu: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Config data endpoint (exposes JSON config to the editor)
|
||||||
|
const cfgMgr = new configManager();
|
||||||
|
RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const script = cfgMgr.createEndpoint(nameOfNode);
|
||||||
|
res.type('application/javascript').send(script);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).send(`// Error generating configData: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- `nameOfNode` must match the file name, the `registerType` name, and the HTML `data-template-name`.
|
||||||
|
- Menu categories (`['asset', 'logger', 'position']`) control which shared UI sections appear.
|
||||||
|
- The config endpoint is optional if you do not need dynamic config in the editor.
|
||||||
|
|
||||||
|
### Step 3: Write `nodeClass.js` (Node Adapter)
|
||||||
|
|
||||||
|
This class bridges Node-RED's API with your domain logic.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { outputUtils, configManager } = require('generalFunctions');
|
||||||
|
const Specific = require('./specificClass');
|
||||||
|
|
||||||
|
class nodeClass {
|
||||||
|
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||||
|
this.node = nodeInstance;
|
||||||
|
this.RED = RED;
|
||||||
|
this.name = nameOfNode;
|
||||||
|
|
||||||
|
this._loadConfig(uiConfig);
|
||||||
|
this._setupSpecificClass();
|
||||||
|
this._bindEvents();
|
||||||
|
this._registerChild();
|
||||||
|
this._startTickLoop();
|
||||||
|
this._attachInputHandler();
|
||||||
|
this._attachCloseHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadConfig(uiConfig) {
|
||||||
|
const cfgMgr = new configManager();
|
||||||
|
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||||
|
|
||||||
|
// buildConfig merges base sections (general, asset, functionality)
|
||||||
|
// with node-specific domain config from the UI
|
||||||
|
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
||||||
|
// Add domain-specific config sections here:
|
||||||
|
flowSettings: {
|
||||||
|
maxFlow: uiConfig.maxFlow,
|
||||||
|
pipeSize: uiConfig.pipeSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this._output = new outputUtils();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupSpecificClass() {
|
||||||
|
this.source = new Specific(this.config);
|
||||||
|
this.node.source = this.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
// Subscribe to domain events for Node-RED status display
|
||||||
|
this.source.emitter.on('flowUpdate', (val) => {
|
||||||
|
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerChild() {
|
||||||
|
// Delayed to avoid Node-RED startup race conditions
|
||||||
|
setTimeout(() => {
|
||||||
|
this.node.send([
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
topic: 'registerChild',
|
||||||
|
payload: this.node.id,
|
||||||
|
positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment',
|
||||||
|
distance: this.config?.functionality?.distance || null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startTickLoop() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this._tickInterval = setInterval(() => this._tick(), 1000);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
this.source.tick();
|
||||||
|
const raw = this.source.getOutput();
|
||||||
|
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
||||||
|
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
||||||
|
this.node.send([processMsg, influxMsg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachInputHandler() {
|
||||||
|
this.node.on('input', (msg, send, done) => {
|
||||||
|
switch (msg.topic) {
|
||||||
|
case 'measurement':
|
||||||
|
if (typeof msg.payload === 'number') {
|
||||||
|
this.source.inputValue = parseFloat(msg.payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// Add more input topics as needed
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachCloseHandler() {
|
||||||
|
this.node.on('close', (done) => {
|
||||||
|
clearInterval(this._tickInterval);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nodeClass;
|
||||||
|
```
|
||||||
|
|
||||||
|
Essential methods every `nodeClass` must implement:
|
||||||
|
- `_loadConfig()` -- merges default JSON config with UI config via `configManager.buildConfig()`
|
||||||
|
- `_setupSpecificClass()` -- instantiates the domain class
|
||||||
|
- `_registerChild()` -- sends a `registerChild` message on output port 2 (parent)
|
||||||
|
- `_startTickLoop()` -- drives periodic output at 1-second intervals
|
||||||
|
- `_tick()` -- calls `source.getOutput()` and formats via `outputUtils.formatMsg()`
|
||||||
|
- `_attachInputHandler()` -- routes incoming `msg.topic` to domain methods
|
||||||
|
- `_attachCloseHandler()` -- clears timers on node removal
|
||||||
|
|
||||||
|
### Step 4: Write `specificClass.js` (Domain Logic)
|
||||||
|
|
||||||
|
This is pure JavaScript with no Node-RED dependencies.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const { logger, configUtils, configManager, MeasurementContainer } = require('generalFunctions');
|
||||||
|
|
||||||
|
class FlowMeter {
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.emitter = new EventEmitter();
|
||||||
|
this.configManager = new configManager();
|
||||||
|
this.defaultConfig = this.configManager.getConfig('flowMeter');
|
||||||
|
this.configUtils = new configUtils(this.defaultConfig);
|
||||||
|
this.config = this.configUtils.initConfig(config);
|
||||||
|
|
||||||
|
this.logger = new logger(
|
||||||
|
this.config.general.logging.enabled,
|
||||||
|
this.config.general.logging.logLevel,
|
||||||
|
this.config.general.name
|
||||||
|
);
|
||||||
|
|
||||||
|
// MeasurementContainer stores typed/positioned values
|
||||||
|
this.measurements = new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
windowSize: this.config.smoothing?.smoothWindow || 10,
|
||||||
|
});
|
||||||
|
this.measurements.setChildId(this.config.general.id);
|
||||||
|
this.measurements.setChildName(this.config.general.name);
|
||||||
|
|
||||||
|
// Domain state
|
||||||
|
this.currentFlow = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
// Called every 1 second by nodeClass._tick()
|
||||||
|
this.calculateFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateFlow() {
|
||||||
|
// Your domain logic here
|
||||||
|
const flow = this.currentFlow;
|
||||||
|
|
||||||
|
// Store in MeasurementContainer using the chainable API:
|
||||||
|
// .type(measType).variant(variant).position(pos).value(val, timestamp, unit)
|
||||||
|
this.measurements
|
||||||
|
.type(this.config.asset.type)
|
||||||
|
.variant('measured')
|
||||||
|
.position(this.config.functionality.positionVsParent)
|
||||||
|
.value(flow, Date.now(), this.config.asset.unit);
|
||||||
|
|
||||||
|
this.emitter.emit('flowUpdate', flow);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput() {
|
||||||
|
return {
|
||||||
|
flow: this.currentFlow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FlowMeter;
|
||||||
|
```
|
||||||
|
|
||||||
|
Key patterns:
|
||||||
|
- Always create an `emitter` (EventEmitter) -- parents subscribe to child events through it.
|
||||||
|
- Use `MeasurementContainer` for storing measurements. The chainable API follows the pattern: `measurements.type(t).variant(v).position(p).value(val, timestamp, unit)`.
|
||||||
|
- Expose `tick()` and `getOutput()` for the node adapter to call.
|
||||||
|
- Use `logger` instead of `console.log`.
|
||||||
|
|
||||||
|
### Step 5: Write the HTML (UI Definition)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="/flowMeter/menu.js"></script>
|
||||||
|
<script src="/flowMeter/configData.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
RED.nodes.registerType("flowMeter", {
|
||||||
|
category: "EVOLV",
|
||||||
|
color: "#a9daee", // S88 Control Module color
|
||||||
|
defaults: {
|
||||||
|
name: { value: "flowMeter" },
|
||||||
|
maxFlow: { value: 100, required: true },
|
||||||
|
pipeSize: { value: 0.1, required: true },
|
||||||
|
// Standard fields (asset, logger, position)
|
||||||
|
uuid: { value: "" },
|
||||||
|
supplier: { value: "" },
|
||||||
|
category: { value: "" },
|
||||||
|
assetType: { value: "" },
|
||||||
|
model: { value: "" },
|
||||||
|
unit: { value: "" },
|
||||||
|
enableLog: { value: false },
|
||||||
|
logLevel: { value: "error" },
|
||||||
|
positionVsParent: { value: "" },
|
||||||
|
positionIcon: { value: "" },
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 3,
|
||||||
|
outputLabels: ["process", "dbase", "parent"],
|
||||||
|
icon: "font-awesome/fa-tachometer",
|
||||||
|
label: function() {
|
||||||
|
return this.name || "flowMeter";
|
||||||
|
},
|
||||||
|
oneditprepare: function() {
|
||||||
|
// Wait for shared menu system to initialize
|
||||||
|
const waitForMenuData = () => {
|
||||||
|
if (window.EVOLV?.nodes?.flowMeter?.initEditor) {
|
||||||
|
window.EVOLV.nodes.flowMeter.initEditor(this);
|
||||||
|
} else {
|
||||||
|
setTimeout(waitForMenuData, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForMenuData();
|
||||||
|
},
|
||||||
|
oneditsave: function() {
|
||||||
|
if (window.EVOLV?.nodes?.flowMeter?.assetMenu?.saveEditor) {
|
||||||
|
window.EVOLV.nodes.flowMeter.assetMenu.saveEditor(this);
|
||||||
|
}
|
||||||
|
if (window.EVOLV?.nodes?.flowMeter?.loggerMenu?.saveEditor) {
|
||||||
|
window.EVOLV.nodes.flowMeter.loggerMenu.saveEditor(this);
|
||||||
|
}
|
||||||
|
if (window.EVOLV?.nodes?.flowMeter?.positionMenu?.saveEditor) {
|
||||||
|
window.EVOLV.nodes.flowMeter.positionMenu.saveEditor(this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="flowMeter">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-maxFlow"><i class="fa fa-arrows-v"></i> Max Flow</label>
|
||||||
|
<input type="number" id="node-input-maxFlow" placeholder="100" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-pipeSize"><i class="fa fa-circle-o"></i> Pipe Size (m)</label>
|
||||||
|
<input type="number" id="node-input-pipeSize" placeholder="0.1" step="0.01" />
|
||||||
|
</div>
|
||||||
|
<!-- Shared UI sections injected by MenuManager -->
|
||||||
|
<div id="asset-fields-placeholder"></div>
|
||||||
|
<div id="logger-fields-placeholder"></div>
|
||||||
|
<div id="position-fields-placeholder"></div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="flowMeter">
|
||||||
|
<p><b>Flow Meter Node</b>: Measures and processes flow data.</p>
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**S88 color scheme** (pick based on your node's hierarchy level):
|
||||||
|
|
||||||
|
| S88 Level | Color | Text Color |
|
||||||
|
|-----------|-------|-----------|
|
||||||
|
| Area | `#0f52a5` | white |
|
||||||
|
| Process Cell | `#0c99d9` | white |
|
||||||
|
| Unit | `#50a8d9` | black |
|
||||||
|
| Equipment | `#86bbdd` | black |
|
||||||
|
| Control Module | `#a9daee` | black |
|
||||||
|
|
||||||
|
All nodes must have **3 outputs**: `[process, dbase, parent]`.
|
||||||
|
|
||||||
|
### Step 6: Create Config JSON Schema
|
||||||
|
|
||||||
|
Create `nodes/generalFunctions/src/configs/flowMeter.json`. This defines defaults and validation rules for every config property. The `configManager` reads this file by node name.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"general": {
|
||||||
|
"name": {
|
||||||
|
"default": "FlowMeter",
|
||||||
|
"rules": { "type": "string", "description": "Human-readable name." }
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"default": null,
|
||||||
|
"rules": { "type": "string", "nullable": true }
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"default": "m3/h",
|
||||||
|
"rules": { "type": "string" }
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"logLevel": {
|
||||||
|
"default": "info",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "debug" }, { "value": "info" },
|
||||||
|
{ "value": "warn" }, { "value": "error" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabled": { "default": true, "rules": { "type": "boolean" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"functionality": {
|
||||||
|
"softwareType": { "default": "flowMeter", "rules": { "type": "string" } },
|
||||||
|
"role": { "default": "Sensor", "rules": { "type": "string" } },
|
||||||
|
"positionVsParent": {
|
||||||
|
"default": "atEquipment",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "atEquipment" }, { "value": "upstream" }, { "value": "downstream" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"asset": {
|
||||||
|
"supplier": { "default": "Unknown", "rules": { "type": "string" } },
|
||||||
|
"category": { "default": "sensor", "rules": { "type": "string" } },
|
||||||
|
"type": { "default": "flow", "rules": { "type": "string" } },
|
||||||
|
"model": { "default": "Unknown", "rules": { "type": "string" } },
|
||||||
|
"unit": { "default": "m3/h", "rules": { "type": "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each property has a `default` value and a `rules` object specifying the type (`string`, `number`, `boolean`, `enum`, `object`), optional constraints (`min`, `max`, `nullable`), and a description.
|
||||||
|
|
||||||
|
### Step 7: Register with `package.json`
|
||||||
|
|
||||||
|
Add your node to the root `package.json` under `node-red.nodes`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"node-red": {
|
||||||
|
"nodes": {
|
||||||
|
"flowMeter": "nodes/flowMeter/flowMeter.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Node-RED to pick up the new node.
|
||||||
|
|
||||||
|
### Step 8: Add Tests
|
||||||
|
|
||||||
|
Create `nodes/flowMeter/test/specificClass.test.js`. Tests target Layer 3 (domain logic) directly, without Node-RED.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const FlowMeter = require('../src/specificClass');
|
||||||
|
|
||||||
|
function makeConfig(overrides = {}) {
|
||||||
|
const base = {
|
||||||
|
general: { name: 'TestFlow', id: 'test-1', logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
functionality: { softwareType: 'flowMeter', role: 'sensor', positionVsParent: 'atEquipment' },
|
||||||
|
asset: { category: 'sensor', type: 'flow', model: 'test', supplier: 'Test', unit: 'm3/h' },
|
||||||
|
};
|
||||||
|
for (const key of Object.keys(overrides)) {
|
||||||
|
base[key] = typeof overrides[key] === 'object' ? { ...base[key], ...overrides[key] } : overrides[key];
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FlowMeter specificClass', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const fm = new FlowMeter(makeConfig());
|
||||||
|
expect(fm).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return output with expected keys', () => {
|
||||||
|
const fm = new FlowMeter(makeConfig());
|
||||||
|
const out = fm.getOutput();
|
||||||
|
expect(out).toHaveProperty('flow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tick() should not throw', () => {
|
||||||
|
const fm = new FlowMeter(makeConfig());
|
||||||
|
expect(() => fm.tick()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with: `npm test` (uses Jest). The project also supports `node:test` for basic smoke tests.
|
||||||
|
|
||||||
|
**Test organization conventions** (based on existing nodes):
|
||||||
|
- `test/specificClass.test.js` -- unit tests for domain logic
|
||||||
|
- `test/basic/*.test.js` -- structural/smoke tests (module loads, exports exist)
|
||||||
|
- `test/edge/*.test.js` -- edge case and boundary tests
|
||||||
|
- `test/integration/*.test.js` -- multi-component integration tests
|
||||||
|
|
||||||
|
## Key APIs Reference
|
||||||
|
|
||||||
|
### MeasurementContainer
|
||||||
|
|
||||||
|
Chainable storage for typed, positioned measurements. Used by every domain class.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
|
const mc = new MeasurementContainer({ autoConvert: true, windowSize: 10 });
|
||||||
|
mc.setChildId('node-id');
|
||||||
|
mc.setChildName('PT-001');
|
||||||
|
|
||||||
|
// Store a value
|
||||||
|
mc.type('pressure').variant('measured').position('upstream').value(3.5, Date.now(), 'bar');
|
||||||
|
|
||||||
|
// Parents subscribe to events via mc.emitter
|
||||||
|
mc.emitter.on('pressure.measured.upstream', (data) => { /* { value, unit, ... } */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
The event name follows the pattern: `{type}.{variant}.{position}`.
|
||||||
|
|
||||||
|
### configManager.buildConfig()
|
||||||
|
|
||||||
|
Merges the JSON config schema defaults with UI-provided values. Called in `nodeClass._loadConfig()`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { configManager } = require('generalFunctions');
|
||||||
|
const cfgMgr = new configManager();
|
||||||
|
const defaults = cfgMgr.getConfig('myNode'); // loads myNode.json
|
||||||
|
const config = cfgMgr.buildConfig('myNode', uiConfig, nodeId, domainOverrides);
|
||||||
|
```
|
||||||
|
|
||||||
|
### POSITIONS
|
||||||
|
|
||||||
|
Canonical position constants. Use these instead of hardcoded strings.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { POSITIONS } = require('generalFunctions');
|
||||||
|
// POSITIONS.UPSTREAM = 'upstream'
|
||||||
|
// POSITIONS.DOWNSTREAM = 'downstream'
|
||||||
|
// POSITIONS.AT_EQUIPMENT = 'atEquipment'
|
||||||
|
// POSITIONS.DELTA = 'delta'
|
||||||
|
```
|
||||||
|
|
||||||
|
### outputUtils.formatMsg()
|
||||||
|
|
||||||
|
Formats raw output data into either `process` or `influxdb` messages. Only sends changed fields.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { outputUtils } = require('generalFunctions');
|
||||||
|
const out = new outputUtils();
|
||||||
|
const processMsg = out.formatMsg(rawData, config, 'process');
|
||||||
|
const influxMsg = out.formatMsg(rawData, config, 'influxdb');
|
||||||
|
node.send([processMsg, influxMsg]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### childRegistrationUtils
|
||||||
|
|
||||||
|
Manages parent-child node relationships. Parents use this to accept child registrations.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { childRegistrationUtils } = require('generalFunctions');
|
||||||
|
const regUtils = new childRegistrationUtils(this); // 'this' is the parent specificClass
|
||||||
|
// Called when a child's registerChild message arrives:
|
||||||
|
regUtils.registerChild(childSource, positionVsParent, distance);
|
||||||
|
```
|
||||||
|
|
||||||
|
The parent's `registerChild()` method subscribes to the child's `measurements.emitter` events for data propagation.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Parent-Child Registration
|
||||||
|
|
||||||
|
1. Child sends `{ topic: 'registerChild', payload: nodeId, positionVsParent }` on output port 2.
|
||||||
|
2. Parent's `nodeClass._attachInputHandler()` catches `msg.topic === 'registerChild'`.
|
||||||
|
3. Parent calls `childRegistrationUtils.registerChild(child, position)`.
|
||||||
|
4. Parent subscribes to child's `measurements.emitter` events (e.g., `'flow.measured.downstream'`).
|
||||||
|
5. When the child updates a measurement, the parent's listener fires and updates its own state.
|
||||||
|
|
||||||
|
### Tick Loop
|
||||||
|
|
||||||
|
Every node runs a 1-second tick loop that:
|
||||||
|
1. Calls `source.tick()` to advance domain logic.
|
||||||
|
2. Calls `source.getOutput()` for current state.
|
||||||
|
3. Formats into `process` and `influxdb` messages via `outputUtils.formatMsg()`.
|
||||||
|
4. Sends on ports 0 (process) and 1 (dbase).
|
||||||
|
|
||||||
|
The tick loop starts with a 1-second delay to allow child registration to complete.
|
||||||
|
|
||||||
|
### Three-Output Format
|
||||||
|
|
||||||
|
All nodes send on three ports: `node.send([processMsg, influxMsg, parentMsg])`.
|
||||||
|
|
||||||
|
| Port | Purpose | When |
|
||||||
|
|------|---------|------|
|
||||||
|
| 0 | Process data for downstream nodes | Every tick (if changed) |
|
||||||
|
| 1 | InfluxDB line protocol for persistence | Every tick (if changed) |
|
||||||
|
| 2 | Parent registration/control messages | On startup; on parent commands |
|
||||||
|
|
||||||
|
### Event-Driven Communication
|
||||||
|
|
||||||
|
Nodes communicate via `EventEmitter`, not Node-RED wires:
|
||||||
|
- `measurements.emitter` fires `{type}.{variant}.{position}` events.
|
||||||
|
- Parents listen to children's emitters after registration.
|
||||||
|
- The `emitter` on the specificClass itself is used for internal state changes (e.g., updating Node-RED node status display).
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Before submitting a new node, verify:
|
||||||
|
|
||||||
|
- [ ] Three-layer structure: wrapper, nodeClass, specificClass
|
||||||
|
- [ ] Config JSON in `generalFunctions/src/configs/<name>.json`
|
||||||
|
- [ ] Registered in root `package.json` under `node-red.nodes`
|
||||||
|
- [ ] HTML registers under category `'EVOLV'` with correct S88 color
|
||||||
|
- [ ] Three outputs: `[process, dbase, parent]`
|
||||||
|
- [ ] Uses `logger` (not `console.log`)
|
||||||
|
- [ ] Uses `MeasurementContainer` for measurement storage
|
||||||
|
- [ ] Uses `outputUtils.formatMsg()` for output formatting
|
||||||
|
- [ ] Tick loop cleans up in `_attachCloseHandler()`
|
||||||
|
- [ ] Tests exist for specificClass domain logic
|
||||||
|
- [ ] Node-specific UI fields plus shared placeholders (asset, logger, position)
|
||||||
Submodule nodes/pumpingStation updated: d8490aa949...6ab585bcc2
@@ -54,8 +54,6 @@
|
|||||||
"license": "SEE LICENSE",
|
"license": "SEE LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@flowfuse/node-red-dashboard": "^1.30.2",
|
"@flowfuse/node-red-dashboard": "^1.30.2",
|
||||||
"@tensorflow/tfjs": "^4.22.0",
|
|
||||||
"@tensorflow/tfjs-node": "^4.22.0",
|
|
||||||
"generalFunctions": "file:nodes/generalFunctions",
|
"generalFunctions": "file:nodes/generalFunctions",
|
||||||
"mathjs": "^13.2.0"
|
"mathjs": "^13.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user