diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..312913e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitmodules b/.gitmodules index 1c1fe52..5ab80ea 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,37 +1,37 @@ - -[submodule "nodes/machineGroupControl"] - path = nodes/machineGroupControl - url = https://gitea.wbd-rd.nl/RnD/machineGroupControl.git -[submodule "nodes/generalFunctions"] - path = nodes/generalFunctions - url = https://gitea.wbd-rd.nl/RnD/generalFunctions.git -[submodule "nodes/valveGroupControl"] - path = nodes/valveGroupControl - url = https://gitea.wbd-rd.nl/RnD/valveGroupControl.git -[submodule "nodes/valve"] - path = nodes/valve - url = https://gitea.wbd-rd.nl/RnD/valve.git -[submodule "nodes/rotatingMachine"] - path = nodes/rotatingMachine - url = https://gitea.wbd-rd.nl/RnD/rotatingMachine.git -[submodule "nodes/monster"] - path = nodes/monster - url = https://gitea.wbd-rd.nl/RnD/monster.git -[submodule "nodes/measurement"] - path = nodes/measurement - url = https://gitea.wbd-rd.nl/RnD/measurement.git -[submodule "nodes/diffuser"] - path = nodes/diffuser - url = https://gitea.wbd-rd.nl/RnD/diffuser.git -[submodule "nodes/dashboardAPI"] - path = nodes/dashboardAPI - url = https://gitea.wbd-rd.nl/RnD/dashboardAPI.git -[submodule "nodes/reactor"] - path = nodes/reactor - url = https://gitea.wbd-rd.nl/RnD/reactor.git -[submodule "nodes/pumpingStation"] - path = nodes/pumpingStation - url = https://gitea.wbd-rd.nl/RnD/pumpingStation -[submodule "nodes/settler"] - path = nodes/settler - url = https://gitea.wbd-rd.nl/RnD/settler.git + +[submodule "nodes/machineGroupControl"] + path = nodes/machineGroupControl + url = https://gitea.wbd-rd.nl/RnD/machineGroupControl.git +[submodule "nodes/generalFunctions"] + path = nodes/generalFunctions + url = https://gitea.wbd-rd.nl/RnD/generalFunctions.git +[submodule "nodes/valveGroupControl"] + path = nodes/valveGroupControl + url = https://gitea.wbd-rd.nl/RnD/valveGroupControl.git +[submodule "nodes/valve"] + path = nodes/valve + url = https://gitea.wbd-rd.nl/RnD/valve.git +[submodule "nodes/rotatingMachine"] + path = nodes/rotatingMachine + url = https://gitea.wbd-rd.nl/RnD/rotatingMachine.git +[submodule "nodes/monster"] + path = nodes/monster + url = https://gitea.wbd-rd.nl/RnD/monster.git +[submodule "nodes/measurement"] + path = nodes/measurement + url = https://gitea.wbd-rd.nl/RnD/measurement.git +[submodule "nodes/diffuser"] + path = nodes/diffuser + url = https://gitea.wbd-rd.nl/RnD/diffuser.git +[submodule "nodes/dashboardAPI"] + path = nodes/dashboardAPI + url = https://gitea.wbd-rd.nl/RnD/dashboardAPI.git +[submodule "nodes/reactor"] + path = nodes/reactor + url = https://gitea.wbd-rd.nl/RnD/reactor.git +[submodule "nodes/pumpingStation"] + path = nodes/pumpingStation + url = https://gitea.wbd-rd.nl/RnD/pumpingStation +[submodule "nodes/settler"] + path = nodes/settler + url = https://gitea.wbd-rd.nl/RnD/settler.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65375b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# EVOLV - Claude Code Project Guide + +## 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** (`.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 + +## 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 +- 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 +- Tick loop runs at 1000ms intervals for time-based updates +- Three outputs per node: [process, dbase, parent] + +## 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) diff --git a/Dockerfile.e2e b/Dockerfile.e2e new file mode 100644 index 0000000..3d7ff86 --- /dev/null +++ b/Dockerfile.e2e @@ -0,0 +1,29 @@ +FROM nodered/node-red:latest + +# Switch to root for setup +USER root + +# Copy EVOLV directly into where Node-RED looks for custom nodes +COPY package.json /data/node_modules/EVOLV/package.json +COPY nodes/ /data/node_modules/EVOLV/nodes/ + +# Rewrite generalFunctions dependency to local file path (no-op if already local) +RUN sed -i 's|"generalFunctions": "git+https://[^"]*"|"generalFunctions": "file:./nodes/generalFunctions"|' \ + /data/node_modules/EVOLV/package.json + +# Fix ownership for node-red user +RUN chown -R node-red:root /data + +USER node-red + +# Install EVOLV's own dependencies inside the EVOLV package directory +WORKDIR /data/node_modules/EVOLV +RUN npm install --ignore-scripts --production + +# Copy test flows into Node-RED data directory +COPY --chown=node-red:root test/e2e/flows.json /data/flows.json + +# Reset workdir to Node-RED default +WORKDIR /usr/src/node-red + +EXPOSE 1880 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..30d4ed5 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +.PHONY: install lint lint-fix test test-jest test-node test-legacy ci docker-ci docker-test docker-lint e2e e2e-up e2e-down + +install: + @sed -i 's|"generalFunctions": "git+https://[^"]*"|"generalFunctions": "file:./nodes/generalFunctions"|' package.json + npm install + @git checkout -- package.json 2>/dev/null || true + +lint: + npx eslint nodes/ + +lint-fix: + npx eslint nodes/ --fix + +test-jest: + npx jest --forceExit + +test-node: + node --test \ + nodes/valve/test/basic/*.test.js \ + nodes/valve/test/edge/*.test.js \ + nodes/valve/test/integration/*.test.js \ + nodes/valveGroupControl/test/basic/*.test.js \ + nodes/valveGroupControl/test/edge/*.test.js \ + nodes/valveGroupControl/test/integration/*.test.js + +test-legacy: + node nodes/machineGroupControl/src/groupcontrol.test.js + node nodes/generalFunctions/src/nrmse/errorMetric.test.js + +test: test-jest test-node test-legacy + +ci: lint test + +docker-ci: + docker compose run --rm ci + +docker-test: + docker compose run --rm test + +docker-lint: + docker compose run --rm lint + +e2e: + bash test/e2e/run-e2e.sh + +e2e-up: + docker compose -f docker-compose.e2e.yml up -d --build + +e2e-down: + docker compose -f docker-compose.e2e.yml down diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..9d9baad --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,49 @@ +services: + influxdb: + image: influxdb:2.7 + environment: + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_USERNAME=admin + - DOCKER_INFLUXDB_INIT_PASSWORD=adminpassword + - DOCKER_INFLUXDB_INIT_ORG=evolv + - DOCKER_INFLUXDB_INIT_BUCKET=evolv + - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=evolv-e2e-token + ports: + - "8086:8086" + healthcheck: + test: ["CMD", "influx", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + nodered: + build: + context: . + dockerfile: Dockerfile.e2e + ports: + - "1880:1880" + depends_on: + influxdb: + condition: service_healthy + environment: + - INFLUXDB_URL=http://influxdb:8086 + - INFLUXDB_TOKEN=evolv-e2e-token + - INFLUXDB_ORG=evolv + - INFLUXDB_BUCKET=evolv + volumes: + - ./test/e2e/flows.json:/data/flows.json + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:1880/"] + interval: 5s + timeout: 5s + retries: 10 + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_AUTH_ANONYMOUS_ENABLED=true + depends_on: + - influxdb diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..22647a3 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,446 @@ +# generalFunctions API Reference + +Shared library (`nodes/generalFunctions/`) used across all EVOLV Node-RED nodes. + +```js +const { logger, outputUtils, MeasurementContainer, ... } = require('generalFunctions'); +``` + +--- + +## Table of Contents + +1. [Logger](#logger) +2. [OutputUtils](#outpututils) +3. [ValidationUtils](#validationutils) +4. [MeasurementContainer](#measurementcontainer) +5. [ConfigManager](#configmanager) +6. [ChildRegistrationUtils](#childregistrationutils) +7. [MenuUtils](#menuutils) +8. [EndpointUtils](#endpointutils) +9. [Positions](#positions) +10. [AssetLoader / loadCurve](#assetloader--loadcurve) + +--- + +## Logger + +Structured, level-filtered console logger. + +**File:** `src/helper/logger.js` + +### Constructor + +```js +new Logger(logging = true, logLevel = 'debug', nameModule = 'N/A') +``` + +| Param | Type | Default | Description | +|---|---|---|---| +| `logging` | `boolean` | `true` | Enable/disable all output | +| `logLevel` | `string` | `'debug'` | Minimum severity: `'debug'` \| `'info'` \| `'warn'` \| `'error'` | +| `nameModule` | `string` | `'N/A'` | Label prefixed to every message | + +### Methods + +| Method | Signature | Description | +|---|---|---| +| `debug` | `(message: string): void` | Log at DEBUG level | +| `info` | `(message: string): void` | Log at INFO level | +| `warn` | `(message: string): void` | Log at WARN level | +| `error` | `(message: string): void` | Log at ERROR level | +| `setLogLevel` | `(level: string): void` | Change minimum level at runtime | +| `toggleLogging` | `(): void` | Flip logging on/off | + +### Example + +```js +const Logger = require('generalFunctions').logger; +const log = new Logger(true, 'info', 'MyNode'); +log.info('Node started'); // [INFO] -> MyNode: Node started +log.debug('ignored'); // silent (below 'info') +log.setLogLevel('debug'); +log.debug('now visible'); // [DEBUG] -> MyNode: now visible +``` + +--- + +## OutputUtils + +Tracks output state and formats messages for InfluxDB or process outputs. Only emits changed fields. + +**File:** `src/helper/outputUtils.js` + +### Constructor + +```js +new OutputUtils() // no parameters +``` + +### Methods + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `formatMsg` | `(output, config, format)` | `object \| undefined` | Diff against last output; returns formatted msg or `undefined` if nothing changed | +| `checkForChanges` | `(output, format)` | `object` | Returns only the key/value pairs that changed since last call | + +**`format`** must be `'influxdb'` or `'process'`. + +### Example + +```js +const out = new OutputUtils(); +const msg = out.formatMsg( + { temperature: 22.5, pressure: 1013 }, + config, + 'influxdb' +); +// msg = { topic: 'nodeName', payload: { measurement, fields, tags, timestamp } } +``` + +--- + +## ValidationUtils + +Schema-driven config validation with type coercion, range clamping, and nested object support. + +**File:** `src/helper/validationUtils.js` + +### Constructor + +```js +new ValidationUtils(loggerEnabled = true, loggerLevel = 'warn') +``` + +### Methods + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `validateSchema` | `(config, schema, name)` | `object` | Walk the schema, validate every field, return a clean config. Unknown keys are stripped. Missing keys get their schema default. | +| `constrain` | `(value, min, max)` | `number` | Clamp a numeric value to `[min, max]` | +| `removeUnwantedKeys` | `(obj)` | `object` | Strip `rules`/`description` metadata, collapse `default` values | + +**Supported `rules.type` values:** `number`, `integer`, `boolean`, `string`, `enum`, `array`, `set`, `object`, `curve`, `machineCurve`. + +### Example + +```js +const ValidationUtils = require('generalFunctions').validation; +const v = new ValidationUtils(true, 'warn'); + +const schema = { + temperature: { default: 20, rules: { type: 'number', min: -40, max: 100 } }, + unit: { default: 'C', rules: { type: 'enum', values: [{ value: 'C' }, { value: 'F' }] } } +}; + +const validated = v.validateSchema({ temperature: 999 }, schema, 'myNode'); +// validated.temperature === 100 (clamped) +// validated.unit === 'C' (default applied) +``` + +--- + +## MeasurementContainer + +Chainable measurement storage organised by **type / variant / position**. Supports auto unit conversion, windowed statistics, events, and positional difference calculations. + +**File:** `src/measurements/MeasurementContainer.js` + +### Constructor + +```js +new MeasurementContainer(options = {}, logger) +``` + +| Option | Type | Default | Description | +|---|---|---|---| +| `windowSize` | `number` | `10` | Rolling window for statistics | +| `defaultUnits` | `object` | `{ pressure:'mbar', flow:'m3/h', ... }` | Default unit per measurement type | +| `autoConvert` | `boolean` | `true` | Auto-convert values to target unit | +| `preferredUnits` | `object` | `{}` | Per-type unit overrides | + +### Chainable Setters + +All return `this` for chaining. + +```js +container + .type('pressure') + .variant('static') + .position('upstream') + .distance(5) + .unit('bar') + .value(3.2, Date.now(), 'bar'); +``` + +| Method | Signature | Description | +|---|---|---| +| `type` | `(typeName): this` | Set measurement type (e.g. `'pressure'`) | +| `variant` | `(variantName): this` | Set variant (e.g. `'static'`, `'differential'`) | +| `position` | `(positionValue): this` | Set position (e.g. `'upstream'`, `'downstream'`) | +| `distance` | `(distance): this` | Set physical distance from parent | +| `unit` | `(unitName): this` | Set unit on the underlying measurement | +| `value` | `(val, timestamp?, sourceUnit?): this` | Store a value; auto-converts if `sourceUnit` differs from target | + +### Terminal / Query Methods + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `get` | `()` | `Measurement \| null` | Get the raw measurement object | +| `getCurrentValue` | `(requestedUnit?)` | `number \| null` | Latest value, optionally converted | +| `getAverage` | `(requestedUnit?)` | `number \| null` | Windowed average | +| `getMin` | `()` | `number \| null` | Window minimum | +| `getMax` | `()` | `number \| null` | Window maximum | +| `getAllValues` | `()` | `array \| null` | All stored samples | +| `getLaggedValue` | `(lag?, requestedUnit?)` | `number \| null` | Value from `lag` samples ago | +| `getLaggedSample` | `(lag?, requestedUnit?)` | `object \| null` | Full sample `{ value, timestamp, unit }` from `lag` samples ago | +| `exists` | `({ type?, variant?, position?, requireValues? })` | `boolean` | Check if a measurement series exists | +| `difference` | `({ from?, to?, unit? })` | `object \| null` | Compute `{ value, avgDiff, unit }` between two positions | + +### Introspection / Lifecycle + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `getTypes` | `()` | `string[]` | All registered measurement types | +| `getVariants` | `()` | `string[]` | Variants under current type | +| `getPositions` | `()` | `string[]` | Positions under current type+variant | +| `getAvailableUnits` | `(measurementType?)` | `string[]` | Units available for a type | +| `getBestUnit` | `(excludeUnits?)` | `object \| null` | Best human-readable unit for current value | +| `setPreferredUnit` | `(type, unit)` | `this` | Override default unit for a type | +| `setChildId` | `(id)` | `this` | Tag container with a child node ID | +| `setChildName` | `(name)` | `this` | Tag container with a child node name | +| `setParentRef` | `(parent)` | `this` | Store reference to parent node | +| `clear` | `()` | `void` | Reset all measurements and chain state | + +### Events + +The internal `emitter` fires `"type.variant.position"` on every `value()` call with: + +```js +{ value, originalValue, unit, sourceUnit, timestamp, position, distance, variant, type, childId, childName, parentRef } +``` + +### Example + +```js +const { MeasurementContainer } = require('generalFunctions'); +const mc = new MeasurementContainer({ windowSize: 5 }); + +mc.type('pressure').variant('static').position('upstream').value(3.2); +mc.type('pressure').variant('static').position('downstream').value(2.8); + +const diff = mc.type('pressure').variant('static').difference(); +// diff = { value: -0.4, avgDiff: -0.4, unit: 'mbar', from: 'downstream', to: 'upstream' } +``` + +--- + +## ConfigManager + +Loads JSON config files from disk and builds merged runtime configs. + +**File:** `src/configs/index.js` + +### Constructor + +```js +new ConfigManager(relPath = '.') +``` + +`relPath` is resolved relative to the configs directory. + +### Methods + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `getConfig` | `(configName)` | `object` | Load and parse `.json` | +| `getAvailableConfigs` | `()` | `string[]` | List config names (without `.json`) | +| `hasConfig` | `(configName)` | `boolean` | Check existence | +| `getBaseConfig` | `()` | `object` | Shortcut for `getConfig('baseConfig')` | +| `buildConfig` | `(nodeName, uiConfig, nodeId, domainConfig?)` | `object` | Merge base schema + UI overrides into a runtime config | +| `createEndpoint` | `(nodeName)` | `string` | Generate browser JS that injects config into `window.EVOLV.nodes` | + +### Example + +```js +const { configManager } = require('generalFunctions'); +const cfg = configManager.buildConfig('measurement', uiConfig, node.id, { + scaling: { enabled: true, inputMin: 0, inputMax: 100 } +}); +``` + +--- + +## ChildRegistrationUtils + +Manages parent-child node relationships: registration, lookup, and structure storage. + +**File:** `src/helper/childRegistrationUtils.js` + +### Constructor + +```js +new ChildRegistrationUtils(mainClass) +``` + +`mainClass` is the parent node instance (must expose `.logger` and optionally `.registerChild()`). + +### Methods + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `registerChild` | `(child, positionVsParent, distance?)` | `Promise` | Register a child node under the parent. Sets up parent refs, measurement context, and stores by softwareType/category. | +| `getChildrenOfType` | `(softwareType, category?)` | `array` | Get children filtered by software type and optional category | +| `getChildById` | `(childId)` | `object \| null` | Lookup a single child by its ID | +| `getAllChildren` | `()` | `array` | All registered children | +| `logChildStructure` | `()` | `void` | Debug-print the full child tree | + +### Example + +```js +const { childRegistrationUtils: CRU } = require('generalFunctions'); +const cru = new CRU(parentNode); +await cru.registerChild(sensorNode, 'upstream'); +cru.getChildrenOfType('measurement'); // [sensorNode] +``` + +--- + +## MenuUtils + +Browser-side UI helper for Node-RED editor. Methods are mixed in from separate modules: toggles, data fetching, URL utils, dropdown population, and HTML generation. + +**File:** `src/helper/menuUtils.js` + +### Constructor + +```js +new MenuUtils() // no parameters; sets isCloud=false, configData=null +``` + +### Key Methods + +**Toggles** -- control UI element visibility: + +| Method | Signature | Description | +|---|---|---| +| `initBasicToggles` | `(elements)` | Bind log-level row visibility to log checkbox | +| `initMeasurementToggles` | `(elements)` | Bind scaling input rows to scaling checkbox | +| `initTensionToggles` | `(elements, node)` | Show/hide tension row based on interpolation method | + +**Data Fetching:** + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `fetchData` | `(url, fallbackUrl)` | `Promise` | Fetch JSON from primary URL; fall back on failure | +| `fetchProjectData` | `(url)` | `Promise` | Fetch project-level data | +| `apiCall` | `(node)` | `Promise` | POST to asset-register API | + +**URL Construction:** + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `getSpecificConfigUrl` | `(nodeName, cloudAPI)` | `{ cloudConfigURL, localConfigURL }` | Build cloud + local config URLs | +| `constructUrl` | `(base, ...paths)` | `string` | Join URL segments safely | +| `constructCloudURL` | `(base, ...paths)` | `string` | Same as `constructUrl`, for cloud endpoints | + +**Dropdown Population:** + +| Method | Signature | Description | +|---|---|---| +| `fetchAndPopulateDropdowns` | `(configUrls, elements, node)` | Cascading supplier > subType > model > unit dropdowns | +| `populateDropdown` | `(htmlElement, options, node, property, callback?)` | Fill a `