Compare commits

14 Commits

Author SHA1 Message Date
znetsixe
6c88b6464d style: palette swatch → (domain-hue redesign 2026-05-21)
Sidebar swatch now follows function family rather than S88 level, so the
palette is visually identifiable instead of monochromatically blue. Editor-group
rectangles in flow.json still follow S88 — only the registerType color changed.
Full table + rationale: superproject .claude/rules/node-red-flow-layout.md §10.0
and .claude/refactor/OPEN_QUESTIONS.md (2026-05-21 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:57 +02:00
znetsixe
4eb286771e docs(framing): reframe monster as a sampling-cabinet pulse counter
The repo's prior docs (CLAUDE.md, MEMORY notes, superproject overview)
framed monster as a "multi-parameter biological process monitor"
suggesting NH4/NO3/COD/TSS measurement. The source code only emits
volumetric pulse + bucket state — no constituent analysis. The
framing was misleading. Reframed to match the code.

Also updated examples/README.md: removed references to four flow
files that don't exist in the repo (`integration.flow.json`,
`edge.flow.json`, `monster-dashboard.flow.json`,
`monster-api-dashboard.flow.json`) and documented the actual two
files (`basic.flow.json`, `02-integrated-e2e.json`). The missing
flows stay tracked in wiki/Reference-Examples.md as TODOs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:05:52 +02:00
znetsixe
fa4f065104 fix(CONTRACT): add child.register row — was in registry but not the table
The commands registry has `child.register` (with `registerChild` alias);
CONTRACT.md mentioned it only in the Port 2 prose. contract-verify
required it in the canonical Inputs table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:06 +02:00
znetsixe
aff866bd9b docs(wiki): regenerate topic-contract AUTOGEN block via wiki-gen
Replaces the agent-written placeholder inside Reference-Contracts.md with
the authoritative table generated from src/commands/index.js. Both the
BEGIN and END markers are normalized to the canonical form used by
`@evolv/wiki-gen`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:48 +02:00
znetsixe
76951f104d docs(wiki): full 5-page wiki matching the rotatingMachine reference format
Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:42:13 +02:00
znetsixe
cd185dcd82 docs: add Folder & File Layout section per EVOLV convention
Each repo can now be read standalone for the file-naming convention. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:25 +02:00
znetsixe
59ff4d230e examples: replace stale flows with single integrated e2e example
Drop the pre-refactor flows (edge.flow.json, integration.flow.json,
monster-dashboard.flow.json, monster-api-dashboard.flow.json) and ship
one canonical 02-integrated-e2e.json instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:51:25 +02:00
znetsixe
53c25f2d10 docs(wiki): rewrite Home.md to 14-section template — bump banner + fix gaps
- Banner hash updated to 8540328 (was stale 2a6a0bc).
- Section 9: flag constraints.flowmeter not wired in buildDomainConfig
  (effectively always true at runtime); add nominalFlowMin / flowMax /
  maxRainRef rows that were missing from the config table.
- Section 12: add two new debug recipes — measurement child ignored
  (assetType must be "flow", not "flow-electromagnetic") and flowmeter=false
  has no runtime effect.
- Section 14 #3: expand flowmeter limitation to call out buildDomainConfig gap.
- Section 14 #5: add S88 colour cleanup note (#4f8582 teal → #50a8d9 Unit).
- AUTOGEN markers untouched; npm run wiki:all re-ran cleanly before commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:04:58 +02:00
znetsixe
8540328bf5 P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:06 +02:00
znetsixe
133d442b76 P11.5 + B2.1/B2.2: per-command units + description (where applicable)
Adds  to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:41:11 +02:00
znetsixe
0038a8c2c2 B1.4: cooldown-guard ROOT CAUSE — schema strip of constraint keys
Root cause: configUtils.initConfig was silently stripping the four
unknown constraint keys (nominalFlowMin, flowMax, maxRainRef,
minSampleIntervalSec) because they weren't declared in the schema.
With those keys gone, validateFlowBounds saw NaN/NaN and routed every
i_start into the invalid-bounds branch — _beginRun never fired, so
sumPuls stayed 0.

Fix is in generalFunctions/src/configs/monster.json (declared the
four keys with sensible defaults). Plus a 4-line comment at the guard
site documenting the schema dependency. 10/10 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:29:17 +02:00
znetsixe
2aa7f88f03 fix(commands): restore child.register handler (alias registerChild)
The P6.3 refactor dropped the case 'registerChild' branch from the
nodeClass switch. Restored as a canonical child.register descriptor
with the legacy registerChild alias.

Monster accepts measurement children — without this, inbound Port 2
handshakes from a child were silently ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:04:29 +02:00
znetsixe
2a82b7d7dc P9.3: wiki/Home.md following 14-section visual-first template + wiki:* scripts
Auto-generated topic-contract + data-model sections via shared wikiGen
script. Hand-written Mermaid diagrams for position-in-platform, code
map, child registration, lifecycle, configuration, state chart (where
applicable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:42 +02:00
znetsixe
2a6a0bc34b P6: convert monster to BaseDomain + BaseNodeAdapter + concern split
Refactor of monster to use the platform infrastructure (BaseDomain, BaseNodeAdapter,
ChildRouter, commandRegistry, statusBadge). Extracts concerns into
focused modules per .claude/refactor/MODULE_SPLIT.md generic template.
Tests stay green; CONTRACT.md generated; legacy aliases preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:09:25 +02:00
27 changed files with 2922 additions and 2342 deletions

View File

@@ -1,6 +1,6 @@
# monster — Claude Code context # monster — Claude Code context
Multi-parameter biological process monitoring. Sampling-cabinet pulse counter — measures sampled volumes and pulse cadence; does *not* analyze constituents (NH4 / NO3 / COD / TSS). For multi-parameter analyses, wire the output to an upstream/downstream analyzer node.
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform. Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
## S88 classification ## S88 classification
@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically. - Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right). - Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#50a8d9` (Unit). - Wrap in a Node-RED group box coloured `#50a8d9` (Unit).
## Folder & File Layout
Every per-node file MUST use the folder name (`monster`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `monster.js` |
| Editor HTML | `monster.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

50
CONTRACT.md Normal file
View File

@@ -0,0 +1,50 @@
# monster — Contract
Hand-maintained for Phase 6; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `cmd.start` | `i_start` | truthy/falsy | Sets `source.i_start`. On the next tick a sampling run begins if flow bounds validate. |
| `set.schedule` | `monsternametijden` | array of AQUON rows (`SAMPLE_NAME`, `DESCRIPTION`, `SAMPLED_DATE`, `START_DATE`, `END_DATE`) | Stores the schedule and recomputes `nextDate` + `daysPerYear` for the configured `aquonSampleName`. |
| `set.rain` | `rain_data` | per-location rain forecast (Open-Meteo shape) | Aggregates hourly precipitation into `sumRain` / `avgRain`; feeds the rain-scaled flow prediction. |
| `data.flow` | `input_q` | `{ value: number, unit: string }` | Converts to m³/h and pushes into `flow.manual.atequipment`. Blends with measured-child flow in `getEffectiveFlow()`. |
| `set.mode` | `setMode` | string | Delegated to `source.setMode()` if defined. Reserved for future use. |
| `set.model-prediction` | `model_prediction` | numeric | Delegated to `source.setModelPrediction()` if defined. Reserved for future use. |
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils` at the supplied `msg.positionVsParent`. |
Aliases log a one-time deprecation warning the first time they fire.
## Outputs (msg.topic on Port 0/1/2)
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built
by `outputUtils.formatMsg(..., 'process')` from `getOutput()`. Delta-
compressed — only changed fields are emitted. Carries `pulse`, `running`,
`bucketVol`, `sumPuls`, `predFlow`, `m3PerPuls`, `q`, `timeLeft`,
`targetVolumeM3`, `targetProgressPct`, `targetDeltaL`, `predictedRateM3h`,
`sumRain`, `avgRain`, `nextDate`, plus the flat measurements snapshot.
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
`'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
to its parent.
## Events emitted by `source.measurements.emitter`
The `MeasurementContainer` fires `<type>.measured.<position>` whenever a
matching series receives a new value. monster writes:
- `flow.manual.atequipment` — operator-supplied manual flow.
- `flow.measured.<position>` — re-emitted when a child measurement fires
(one of `flow.measured.upstream`, `flow.measured.downstream`,
`flow.measured.atequipment`).
## Children accepted
`measurement` only. The router subscribes to a child's
`flow.measured.<position>` events when the child's `config.asset.type` is
`'flow'` (or missing). Other asset types are ignored. monster has no
position-based filtering — all three positions are wired and the latest
value wins for each.

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,34 @@
# Monster Example Flows # Monster Example Flows
Import-ready Node-RED examples for `monster`. Import-ready Node-RED examples for `monster` — a sampling-cabinet pulse
counter (volumetric, not an analytical multi-parameter monitor; see
the framing note in `CONTRACT.md`).
## Files present in this repo
## Files
- `basic.flow.json` - `basic.flow.json`
- Purpose: quick-start flow with dashboard charts for key monster outputs. - Inject-driven quick-start flow with the dashboard widgets for the
- `integration.flow.json` main monster outputs (`pulse`, `bucketVol`, `m3PerPuls`, `running`,
- Purpose: lightweight integration contract example (`registerChild` path). `predFlow`).
- `edge.flow.json` - `02-integrated-e2e.json`
- Purpose: unknown-topic/edge handling smoke example. - End-to-end orchestration template: wires a flow source, a schedule,
- `monster-dashboard.flow.json` rain-data input, and the dashboard surface. **Not yet validated
- Purpose: richer dashboard-focused visualization of process output. against live Node-RED** — treat as a wiring reference until smoke-
- Includes: tested. Placeholder credentials (`__SET_*__`) need to be replaced
- manual flow input before any real deployment.
- manual start trigger
- seeded `rain_data` and `monsternametijden`
- parsed report fields (`m3Total`, `m3PerPuls`, `pulse`, `running`)
- `monster-api-dashboard.flow.json`
- Purpose: full orchestration template around `monster` with API paths and dashboard output.
- Includes:
- Open-Meteo weather fetch -> `rain_data`
- Aquon SFTP CSV fetch -> `monsternametijden`
- Z-Info token + import payload builder for `m3Total`/`m3PerPuls`
- dashboard API publish template (Grafana)
- placeholder-only credentials/hosts (`__SET_*__`)
## Notes ## Files referenced in earlier docs but not yet shipped
- `basic.flow.json` and `monster-dashboard.flow.json` are intentionally API-free.
- `monster-api-dashboard.flow.json` is the full API template variant and must be hardened with environment-backed secrets before production use. `integration.flow.json`, `edge.flow.json`, `monster-dashboard.flow.json`,
- `ui-chart` uses series by `msg.topic` (`category: "topic"`, `categoryType: "msg"`). and `monster-api-dashboard.flow.json` were mentioned by prior versions
of this README; they do not exist in the repo at this commit. They are
tracked as TODOs in `wiki/Reference-Examples.md`. When generated, follow
the tier convention used by the rotatingMachine examples
(`01-Basic.json`, `02-Integration.json`, `03-Dashboard.json`).
## Conventions
- `ui-chart` uses series by `msg.topic` (`category: "topic"`,
`categoryType: "msg"`).
- API templates (rain fetch / Aquon SFTP / Z-Info import) must be
hardened with environment-backed secrets before production use.

View File

@@ -1,6 +0,0 @@
[
{"id":"monster_edge_tab","type":"tab","label":"monster edge","disabled":false,"info":"monster edge example"},
{"id":"monster_edge_node","type":"monster","z":"monster_edge_tab","name":"monster edge","x":420,"y":180,"wires":[["monster_edge_dbg"]]},
{"id":"monster_edge_inj","type":"inject","z":"monster_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["monster_edge_node"]]},
{"id":"monster_edge_dbg","type":"debug","z":"monster_edge_tab","name":"monster edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

View File

@@ -1,6 +0,0 @@
[
{"id":"monster_int_tab","type":"tab","label":"monster integration","disabled":false,"info":"monster integration example"},
{"id":"monster_int_node","type":"monster","z":"monster_int_tab","name":"monster integration","x":420,"y":180,"wires":[["monster_int_dbg"]]},
{"id":"monster_int_inj","type":"inject","z":"monster_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["monster_int_node"]]},
{"id":"monster_int_dbg","type":"debug","z":"monster_int_tab","name":"monster integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]}
]

View File

@@ -1,743 +0,0 @@
[
{
"id": "monster_api_tab",
"type": "tab",
"label": "Monster API + Dashboard",
"disabled": false,
"info": "Full monster orchestration example with API integrations. Credentials are placeholders."
},
{
"id": "ui_base_monster_api",
"type": "ui-base",
"name": "EVOLV Demo",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default"
},
{
"id": "ui_theme_monster_api",
"type": "ui-theme",
"name": "Monster API Theme",
"colors": {
"surface": "#ffffff",
"primary": "#4f8582",
"bgPage": "#efefef",
"groupBg": "#ffffff",
"groupOutline": "#d8d8d8"
},
"sizes": {
"density": "default",
"pagePadding": "14px",
"groupGap": "14px",
"groupBorderRadius": "6px",
"widgetGap": "12px"
}
},
{
"id": "ui_page_monster_api",
"type": "ui-page",
"name": "Monster API",
"ui": "ui_base_monster_api",
"path": "/monster-api",
"icon": "science",
"layout": "grid",
"theme": "ui_theme_monster_api",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "12"
}
],
"order": 1,
"className": ""
},
{
"id": "ui_group_monster_api_ctrl",
"type": "ui-group",
"name": "Input",
"page": "ui_page_monster_api",
"width": "6",
"height": "1",
"order": 1,
"showTitle": true,
"className": ""
},
{
"id": "ui_group_monster_api_obs",
"type": "ui-group",
"name": "Output",
"page": "ui_page_monster_api",
"width": "12",
"height": "1",
"order": 2,
"showTitle": true,
"className": ""
},
{
"id": "monster_api_node",
"type": "monster",
"z": "monster_api_tab",
"name": "Monster API",
"samplingtime": "24",
"minvolume": "5",
"maxweight": "23",
"nominalFlowMin": "1000",
"flowMax": "6000",
"maxRainRef": "10",
"minSampleIntervalSec": "60",
"emptyWeightBucket": "8.3",
"aquon_sample_name": "112150",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "⊥",
"hasDistance": false,
"distance": "",
"x": 980,
"y": 320,
"wires": [
[
"monster_api_parse_output",
"monster_api_zinfo_prepare"
],
[
"monster_api_dbg_influx"
],
[
"monster_api_dbg_parent"
]
]
},
{
"id": "monster_api_info",
"type": "comment",
"z": "monster_api_tab",
"name": "Template only: set credentials/URLs before production",
"info": "All secrets in this flow are placeholders. Replace with env vars or credential nodes.",
"x": 260,
"y": 80,
"wires": []
},
{
"id": "monster_api_inj_flow",
"type": "inject",
"z": "monster_api_tab",
"group": "ui_group_monster_api_ctrl",
"name": "Flow 1800 m3/h",
"props": [
{
"p": "payload"
}
],
"repeat": "5",
"crontab": "",
"once": true,
"onceDelay": "1",
"topic": "",
"payload": "1800",
"payloadType": "num",
"x": 170,
"y": 180,
"wires": [
[
"monster_api_build_flow"
]
]
},
{
"id": "monster_api_build_flow",
"type": "function",
"z": "monster_api_tab",
"name": "Build input_q",
"func": "msg.topic='input_q';\nmsg.payload={value:Number(msg.payload),unit:'m3/h'};\nreturn Number.isFinite(msg.payload.value)?msg:null;",
"outputs": 1,
"noerr": 0,
"x": 390,
"y": 180,
"wires": [
[
"monster_api_node"
]
]
},
{
"id": "monster_api_inj_start",
"type": "inject",
"z": "monster_api_tab",
"group": "ui_group_monster_api_ctrl",
"name": "Manual Start",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "0.1",
"topic": "i_start",
"payload": "true",
"payloadType": "bool",
"x": 160,
"y": 240,
"wires": [
[
"monster_api_node"
]
]
},
{
"id": "monster_api_weather_trigger",
"type": "inject",
"z": "monster_api_tab",
"name": "Weather fetch (daily)",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "55 07 * * *",
"once": false,
"onceDelay": "",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 190,
"y": 420,
"wires": [
[
"monster_api_weather_http"
]
]
},
{
"id": "monster_api_weather_http",
"type": "http request",
"z": "monster_api_tab",
"name": "Open-Meteo",
"method": "GET",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://api.open-meteo.com/v1/forecast?latitude=51.71&longitude=4.81&hourly=precipitation,precipitation_probability&timezone=Europe%2FBerlin&past_days=1&forecast_days=2",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 380,
"y": 420,
"wires": [
[
"monster_api_weather_json"
]
]
},
{
"id": "monster_api_weather_json",
"type": "json",
"z": "monster_api_tab",
"name": "rain_data",
"property": "payload",
"action": "",
"pretty": false,
"x": 550,
"y": 420,
"wires": [
[
"monster_api_weather_topic"
]
]
},
{
"id": "monster_api_weather_topic",
"type": "change",
"z": "monster_api_tab",
"name": "topic rain_data",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "rain_data",
"tot": "str"
}
],
"x": 720,
"y": 420,
"wires": [
[
"monster_api_node"
]
]
},
{
"id": "monster_api_aquon_trigger",
"type": "inject",
"z": "monster_api_tab",
"name": "Aquon fetch (daily)",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "15 07 * * *",
"once": false,
"onceDelay": "",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 180,
"y": 500,
"wires": [
[
"monster_api_sftp_get"
]
]
},
{
"id": "monster_api_sftp_get",
"type": "sftp in",
"z": "monster_api_tab",
"sftp": "monster_api_sftp_cfg",
"operation": "get",
"filename": "wsBD_MONSTERNAMETIJDEN.csv",
"localFilename": "./.node-red/node_modules/typicals/monster/config/monsternametijden.csv",
"name": "Aquon schedule",
"x": 380,
"y": 500,
"wires": [
[
"monster_api_file_in"
]
]
},
{
"id": "monster_api_file_in",
"type": "file in",
"z": "monster_api_tab",
"name": "read monsternametijden",
"filename": "./.node-red/node_modules/typicals/monster/config/monsternametijden.csv",
"filenameType": "str",
"format": "utf8",
"chunk": false,
"sendError": false,
"encoding": "none",
"allProps": false,
"x": 590,
"y": 500,
"wires": [
[
"monster_api_csv"
]
]
},
{
"id": "monster_api_csv",
"type": "csv",
"z": "monster_api_tab",
"name": "monsternametijden",
"sep": ",",
"hdrin": true,
"hdrout": "all",
"multi": "mult",
"ret": "\\n",
"temp": "SAMPLE_NAME,DESCRIPTION,SAMPLED_DATE,START_DATE,END_DATE",
"skip": "0",
"strings": true,
"include_empty_strings": "",
"include_null_values": "",
"x": 780,
"y": 500,
"wires": [
[
"monster_api_schedule_topic"
]
]
},
{
"id": "monster_api_schedule_topic",
"type": "change",
"z": "monster_api_tab",
"name": "topic monsternametijden",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "monsternametijden",
"tot": "str"
}
],
"x": 990,
"y": 500,
"wires": [
[
"monster_api_node"
]
]
},
{
"id": "monster_api_zinfo_prepare",
"type": "function",
"z": "monster_api_tab",
"name": "Z-Info prepare on run stop",
"func": "const p=(msg&&msg.payload&&typeof msg.payload==='object')?msg.payload:{};\nconst runningNow=Boolean(p.running);\nconst runningPrev=Boolean(context.get('runningPrev'));\ncontext.set('runningPrev',runningNow);\nif(!(runningPrev && !runningNow)){\n return null;\n}\nconst today=new Date();\nconst day=String(today.getDate()).padStart(2,'0');\nconst month=String(today.getMonth()+1).padStart(2,'0');\nconst year=today.getFullYear();\nconst yesterdayDate=new Date(today.getTime()-24*3600*1000);\nconst yDay=String(yesterdayDate.getDate()).padStart(2,'0');\nconst yMonth=String(yesterdayDate.getMonth()+1).padStart(2,'0');\nconst yYear=yesterdayDate.getFullYear();\nmsg.zinfoDateFrom=`${yYear}-${yMonth}-${yDay}`;\nmsg.zinfoDateUntil=`${year}-${month}-${day}`;\nmsg.zinfoData={\n m3Total:Number(p.m3Total||0),\n pulse:Math.max(0,Math.floor(Number(p.m3PerPuls||p.m3PerPulse||0)))\n};\nmsg.payload='grant_type=password&username=__SET_ZINFO_USERNAME__&password=__SET_ZINFO_PASSWORD__&client_id=__SET_ZINFO_CLIENT_ID__&client_secret=__SET_ZINFO_CLIENT_SECRET__';\nmsg.headers=msg.headers||{};\nmsg.headers['content-type']='application/x-www-form-urlencoded';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 1260,
"y": 320,
"wires": [
[
"monster_api_zinfo_token"
]
]
},
{
"id": "monster_api_zinfo_token",
"type": "http request",
"z": "monster_api_tab",
"name": "Z-Info token",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://__SET_ZINFO_HOST__/WSR/zi_wsr.svc/token",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 1450,
"y": 320,
"wires": [
[
"monster_api_zinfo_token_json"
]
]
},
{
"id": "monster_api_zinfo_token_json",
"type": "json",
"z": "monster_api_tab",
"name": "token json",
"property": "payload",
"action": "",
"pretty": false,
"x": 1630,
"y": 320,
"wires": [
[
"monster_api_zinfo_import_builder"
]
]
},
{
"id": "monster_api_zinfo_import_builder",
"type": "function",
"z": "monster_api_tab",
"name": "Build Z-Info import",
"func": "const token=msg.payload&&msg.payload.access_token;\nconst z=msg.zinfoData||{};\nconst from=msg.zinfoDateFrom;\nconst until=msg.zinfoDateUntil;\nconst ns='__SET_ZINFO_NAMESPACE__';\nmsg.payload={\n import:{\n algemeen:{\n AanleverendeOrganisatie:'NL.25',\n Versie:'IMm2018',\n Batchid:`ZI_PA_NL.25_${Date.now()}.json`,\n Systeembron:'WBD/NEERSG',\n Systeemdoel:'HWH/Z-info',\n Opmerking:'template'\n },\n data:[{\n Meetwaarden:[\n {mepid:`${ns}.F021.m3`,dbmDtm:from,dbmTijd:'06:00',demDtm:until,demTijd:'06:00',mwdWaarde:`${Number(z.m3Total||0)}`,mwdWaardeAN:'',nMwd:'',mwdOpmerk:'template'},\n {mepid:`${ns}.Q000.PULS`,dbmDtm:from,dbmTijd:'06:00',demDtm:until,demTijd:'06:00',mwdWaarde:`${Number(z.pulse||0)}`,mwdWaardeAN:'',nMwd:'',mwdOpmerk:'template'}\n ]\n }]\n }\n};\nmsg.headers=msg.headers||{};\nif(token){msg.headers.authorization='Bearer '+token;}\nmsg.headers['content-type']='application/json';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 1830,
"y": 320,
"wires": [
[
"monster_api_zinfo_import_put"
]
]
},
{
"id": "monster_api_zinfo_import_put",
"type": "http request",
"z": "monster_api_tab",
"name": "Z-Info import PUT",
"method": "PUT",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://__SET_ZINFO_HOST__/WSR/zi_wsr.svc/json/NL.25/importmwd/pa/?gebruiker=__SET_ZINFO_USER__",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 2040,
"y": 320,
"wires": [
[
"monster_api_dbg_zinfo"
]
]
},
{
"id": "monster_api_dbg_zinfo",
"type": "debug",
"z": "monster_api_tab",
"name": "Z-Info response",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "false",
"targetType": "full",
"x": 2250,
"y": 320,
"wires": []
},
{
"id": "monster_api_parse_output",
"type": "function",
"z": "monster_api_tab",
"name": "Parse output for dashboard",
"func": "const p=(msg&&msg.payload&&typeof msg.payload==='object')?msg.payload:{};\nconst now=Date.now();\nconst m3PerPuls=Number(p.m3PerPuls||p.m3PerPulse);\nreturn [\n Number.isFinite(Number(p.q))?{topic:'q_m3h',payload:Number(p.q),timestamp:now}:null,\n Number.isFinite(Number(p.m3Total))?{topic:'m3_total',payload:Number(p.m3Total),timestamp:now}:null,\n Number.isFinite(Number(p.bucketVol))?{topic:'bucket_l',payload:Number(p.bucketVol),timestamp:now}:null,\n Number.isFinite(m3PerPuls)?{topic:'m3_per_pulse',payload:m3PerPuls,timestamp:now}:null,\n {topic:'status',payload:`running=${Boolean(p.running)} | pulse=${Boolean(p.pulse)} | m3PerPuls=${Number.isFinite(m3PerPuls)?m3PerPuls:'n/a'} | missed=${Number(p.missedSamples||0)}`}\n];",
"outputs": 5,
"noerr": 0,
"x": 1240,
"y": 220,
"wires": [
[
"monster_api_chart_q"
],
[
"monster_api_chart_total"
],
[
"monster_api_chart_bucket"
],
[
"monster_api_chart_pulse"
],
[
"monster_api_text_status"
]
]
},
{
"id": "monster_api_chart_q",
"type": "ui-chart",
"z": "monster_api_tab",
"group": "ui_group_monster_api_obs",
"name": "q",
"label": "Flow q (m3/h)",
"order": 1,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1470,
"y": 120,
"wires": []
},
{
"id": "monster_api_chart_total",
"type": "ui-chart",
"z": "monster_api_tab",
"group": "ui_group_monster_api_obs",
"name": "m3Total",
"label": "m3Total",
"order": 2,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1480,
"y": 180,
"wires": []
},
{
"id": "monster_api_chart_bucket",
"type": "ui-chart",
"z": "monster_api_tab",
"group": "ui_group_monster_api_obs",
"name": "bucket",
"label": "Bucket (L)",
"order": 3,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1480,
"y": 240,
"wires": []
},
{
"id": "monster_api_chart_pulse",
"type": "ui-chart",
"z": "monster_api_tab",
"group": "ui_group_monster_api_obs",
"name": "m3PerPuls",
"label": "m3PerPuls",
"order": 4,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1490,
"y": 300,
"wires": []
},
{
"id": "monster_api_text_status",
"type": "ui-text",
"z": "monster_api_tab",
"group": "ui_group_monster_api_obs",
"name": "status",
"label": "Status",
"order": 5,
"width": 12,
"height": 1,
"format": "{{msg.payload}}",
"layout": "row-spread",
"x": 1460,
"y": 360,
"wires": []
},
{
"id": "monster_api_dashboardapi",
"type": "dashboardapi",
"z": "monster_api_tab",
"name": "dashboard template",
"x": 1430,
"y": 420,
"wires": [
[
"monster_api_grafana_post"
]
]
},
{
"id": "monster_api_grafana_post",
"type": "http request",
"z": "monster_api_tab",
"name": "Grafana dashboard API",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://__SET_GRAFANA_HOST__/api/dashboards/db",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 1650,
"y": 420,
"wires": [
[
"monster_api_dbg_dashboard"
]
]
},
{
"id": "monster_api_dbg_dashboard",
"type": "debug",
"z": "monster_api_tab",
"name": "dashboard API response",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "false",
"targetType": "full",
"x": 1870,
"y": 420,
"wires": []
},
{
"id": "monster_api_dbg_influx",
"type": "debug",
"z": "monster_api_tab",
"name": "influx output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1240,
"y": 460,
"wires": []
},
{
"id": "monster_api_dbg_parent",
"type": "debug",
"z": "monster_api_tab",
"name": "parent output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1230,
"y": 500,
"wires": []
},
{
"id": "monster_api_sftp_cfg",
"type": "sftp",
"host": "__SET_AQUON_SFTP_HOST__",
"port": "22",
"username": "__SET_AQUON_SFTP_USERNAME__",
"password": "__SET_AQUON_SFTP_PASSWORD__",
"hmac": [],
"cipher": []
}
]

View File

@@ -1,483 +0,0 @@
[
{
"id": "monster_tab_demo",
"type": "tab",
"label": "Monster Dashboard Demo",
"disabled": false,
"info": "Dashboard-focused example for monster output visualization"
},
{
"id": "ui_base_monster_demo",
"type": "ui-base",
"name": "EVOLV Demo",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default"
},
{
"id": "ui_theme_monster_demo",
"type": "ui-theme",
"name": "EVOLV Monster Theme",
"colors": {
"surface": "#ffffff",
"primary": "#4f8582",
"bgPage": "#efefef",
"groupBg": "#ffffff",
"groupOutline": "#d8d8d8"
},
"sizes": {
"density": "default",
"pagePadding": "14px",
"groupGap": "14px",
"groupBorderRadius": "6px",
"widgetGap": "12px"
}
},
{
"id": "ui_page_monster_demo",
"type": "ui-page",
"name": "Monster Demo",
"ui": "ui_base_monster_demo",
"path": "/monster-demo",
"icon": "science",
"layout": "grid",
"theme": "ui_theme_monster_demo",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "12"
}
],
"order": 1,
"className": ""
},
{
"id": "ui_group_monster_ctrl",
"type": "ui-group",
"name": "Monster Inputs",
"page": "ui_page_monster_demo",
"width": "6",
"height": "1",
"order": 1,
"showTitle": true,
"className": ""
},
{
"id": "ui_group_monster_obs",
"type": "ui-group",
"name": "Monster Output",
"page": "ui_page_monster_demo",
"width": "12",
"height": "1",
"order": 2,
"showTitle": true,
"className": ""
},
{
"id": "monster_node_demo",
"type": "monster",
"z": "monster_tab_demo",
"name": "Monster Demo",
"samplingtime": "24",
"minvolume": "5",
"maxweight": "23",
"nominalFlowMin": "1000",
"flowMax": "6000",
"maxRainRef": "10",
"minSampleIntervalSec": "60",
"emptyWeightBucket": "8.3",
"aquon_sample_name": "112150",
"uuid": "",
"supplier": "monster",
"category": "monster",
"assetType": "sampling-cabinet",
"model": "monster-standard",
"unit": "m3/h",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "⊥",
"hasDistance": false,
"distance": "",
"x": 900,
"y": 260,
"wires": [
[
"monster_parse_output"
],
[
"monster_debug_influx"
],
[
"monster_debug_parent"
]
]
},
{
"id": "monster_flow_inject",
"type": "inject",
"z": "monster_tab_demo",
"group": "ui_group_monster_ctrl",
"name": "Flow 1800 m3/h",
"props": [
{
"p": "payload"
}
],
"repeat": "5",
"crontab": "",
"once": true,
"onceDelay": "1",
"topic": "",
"payload": "1800",
"payloadType": "num",
"x": 170,
"y": 180,
"wires": [
[
"monster_build_flow"
]
]
},
{
"id": "monster_build_flow",
"type": "function",
"z": "monster_tab_demo",
"name": "Build input_q",
"func": "msg.topic = 'input_q';\nmsg.payload = { value: Number(msg.payload), unit: 'm3/h' };\nreturn Number.isFinite(msg.payload.value) ? msg : null;",
"outputs": 1,
"noerr": 0,
"x": 380,
"y": 180,
"wires": [
[
"monster_node_demo"
]
]
},
{
"id": "monster_start_inject",
"type": "inject",
"z": "monster_tab_demo",
"group": "ui_group_monster_ctrl",
"name": "Manual Start",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "0.1",
"topic": "i_start",
"payload": "true",
"payloadType": "bool",
"x": 160,
"y": 240,
"wires": [
[
"monster_node_demo"
]
]
},
{
"id": "monster_rain_inject",
"type": "inject",
"z": "monster_tab_demo",
"group": "ui_group_monster_ctrl",
"name": "Seed rain_data",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "2",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 160,
"y": 300,
"wires": [
[
"monster_build_rain"
]
]
},
{
"id": "monster_build_rain",
"type": "function",
"z": "monster_tab_demo",
"name": "Build rain_data",
"func": "const now = new Date();\nconst mk = (offset, rain, prob) => {\n const d = new Date(now.getTime() + offset * 3600 * 1000);\n return { t: d.toISOString().slice(0, 13) + ':00', rain, prob };\n};\nconst rows = [mk(-1, 0.2, 20), mk(0, 0.8, 40), mk(1, 1.1, 60), mk(2, 0.5, 30)];\nmsg.topic = 'rain_data';\nmsg.payload = [\n {\n latitude: 51.71,\n longitude: 4.81,\n hourly: {\n time: rows.map(r => r.t),\n precipitation: rows.map(r => r.rain),\n precipitation_probability: rows.map(r => r.prob)\n }\n }\n];\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 380,
"y": 300,
"wires": [
[
"monster_node_demo"
]
]
},
{
"id": "monster_schedule_inject",
"type": "inject",
"z": "monster_tab_demo",
"group": "ui_group_monster_ctrl",
"name": "Seed monsternametijden",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "3",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 190,
"y": 360,
"wires": [
[
"monster_build_schedule"
]
]
},
{
"id": "monster_build_schedule",
"type": "function",
"z": "monster_tab_demo",
"name": "Build monsternametijden",
"func": "const now = new Date();\nconst next = new Date(now.getTime() + 24 * 3600 * 1000);\nconst end = new Date(next.getTime() + 24 * 3600 * 1000);\nmsg.topic = 'monsternametijden';\nmsg.payload = [\n {\n SAMPLE_NAME: '112150',\n DESCRIPTION: 'demo schedule',\n SAMPLED_DATE: next.toISOString().slice(0, 19).replace('T', ' '),\n START_DATE: next.toISOString().slice(0, 19).replace('T', ' '),\n END_DATE: end.toISOString().slice(0, 19).replace('T', ' ')\n }\n];\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 410,
"y": 360,
"wires": [
[
"monster_node_demo"
]
]
},
{
"id": "monster_parse_output",
"type": "function",
"z": "monster_tab_demo",
"name": "Parse monster output",
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst now = Date.now();\nconst q = Number(p.q);\nconst total = Number(p.m3Total);\nconst bucket = Number(p.bucketVol);\nconst rem = Number(p.pulsesRemaining);\nconst m3PerPulse = Number(p.m3PerPuls || p.m3PerPulse);\nconst status = `running=${Boolean(p.running)} | pulse=${Boolean(p.pulse)} | m3PerPuls=${Number.isFinite(m3PerPulse) ? m3PerPulse : 'n/a'} | missed=${Number(p.missedSamples || 0)}`;\nreturn [\n Number.isFinite(q) ? { topic: 'q_m3h', payload: q, timestamp: now } : null,\n Number.isFinite(total) ? { topic: 'm3_total', payload: total, timestamp: now } : null,\n Number.isFinite(bucket) ? { topic: 'bucket_l', payload: bucket, timestamp: now } : null,\n Number.isFinite(rem) ? { topic: 'pulses_remaining', payload: rem, timestamp: now } : null,\n Number.isFinite(m3PerPulse) ? { topic: 'm3_per_pulse', payload: m3PerPulse, timestamp: now } : null,\n { topic: 'status', payload: status }\n];",
"outputs": 6,
"noerr": 0,
"x": 1130,
"y": 260,
"wires": [
[
"monster_chart_q"
],
[
"monster_chart_m3total"
],
[
"monster_chart_bucket"
],
[
"monster_chart_remaining"
],
[
"monster_chart_m3pulse"
],
[
"monster_text_status"
]
]
},
{
"id": "monster_chart_q",
"type": "ui-chart",
"z": "monster_tab_demo",
"group": "ui_group_monster_obs",
"name": "Flow q",
"label": "Flow q (m3/h)",
"order": 1,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisPropertyType": "msg",
"yAxisProperty": "payload",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1370,
"y": 120,
"wires": []
},
{
"id": "monster_chart_m3total",
"type": "ui-chart",
"z": "monster_tab_demo",
"group": "ui_group_monster_obs",
"name": "m3 Total",
"label": "m3Total (m3)",
"order": 2,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisPropertyType": "msg",
"yAxisProperty": "payload",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1380,
"y": 180,
"wires": []
},
{
"id": "monster_chart_bucket",
"type": "ui-chart",
"z": "monster_tab_demo",
"group": "ui_group_monster_obs",
"name": "Bucket Volume",
"label": "Bucket (L)",
"order": 3,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisPropertyType": "msg",
"yAxisProperty": "payload",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1380,
"y": 240,
"wires": []
},
{
"id": "monster_chart_remaining",
"type": "ui-chart",
"z": "monster_tab_demo",
"group": "ui_group_monster_obs",
"name": "Pulses Remaining",
"label": "Pulses Remaining",
"order": 4,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisPropertyType": "msg",
"yAxisProperty": "payload",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1400,
"y": 300,
"wires": []
},
{
"id": "monster_chart_m3pulse",
"type": "ui-chart",
"z": "monster_tab_demo",
"group": "ui_group_monster_obs",
"name": "m3 per pulse",
"label": "m3PerPuls",
"order": 5,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisType": "time",
"xAxisPropertyType": "timestamp",
"yAxisPropertyType": "msg",
"yAxisProperty": "payload",
"removeOlder": "30",
"removeOlderUnit": "60",
"showLegend": false,
"action": "append",
"x": 1390,
"y": 360,
"wires": []
},
{
"id": "monster_text_status",
"type": "ui-text",
"z": "monster_tab_demo",
"group": "ui_group_monster_obs",
"name": "Sampling status",
"label": "Status",
"order": 6,
"width": 12,
"height": 1,
"format": "{{msg.payload}}",
"layout": "row-spread",
"x": 1380,
"y": 420,
"wires": []
},
{
"id": "monster_debug_influx",
"type": "debug",
"z": "monster_tab_demo",
"name": "Influx output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1130,
"y": 320,
"wires": []
},
{
"id": "monster_debug_parent",
"type": "debug",
"z": "monster_tab_demo",
"name": "Parent output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1130,
"y": 360,
"wires": []
}
]

View File

@@ -5,7 +5,7 @@
<script> <script>
RED.nodes.registerType("monster", { RED.nodes.registerType("monster", {
category: "EVOLV", category: "EVOLV",
color: "#4f8582", color: "#9C5BB0",
defaults: { defaults: {
// Define default properties // Define default properties

View File

@@ -4,7 +4,10 @@
"description": "Control module Monsternamekast", "description": "Control module Monsternamekast",
"main": "monster.js", "main": "monster.js",
"scripts": { "scripts": {
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js" "test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

54
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict';
// Handlers for monster input topics. Each is a pure function over the
// domain (source). Unit conversion for incoming flow happens in the
// handler (the legacy nodeClass did it inline) — anything else inbound
// is passed straight through to source.handleInput.
const { convert } = require('generalFunctions');
exports.cmdStart = (source, msg) => {
source.handleInput('i_start', Boolean(msg.payload));
};
exports.setSchedule = (source, msg) => {
source.handleInput('monsternametijden', msg.payload);
};
exports.setRain = (source, msg) => {
source.handleInput('rain_data', msg.payload);
};
exports.dataFlow = (source, msg, ctx) => {
const log = ctx?.logger || source.logger;
const value = Number(msg.payload?.value);
const unit = msg.payload?.unit;
if (!Number.isFinite(value) || !unit) {
log?.warn?.('data.flow payload must include numeric value and unit.');
return;
}
let converted = value;
try { converted = convert(value).from(unit).to('m3/h'); }
catch (err) { log?.warn?.(`data.flow unit conversion failed: ${err.message}`); return; }
source.handleInput('input_q', { value: converted, unit: 'm3/h' });
};
exports.setMode = (source, msg) => {
if (typeof source.setMode === 'function') source.setMode(msg.payload);
};
exports.setModelPrediction = (source, msg) => {
if (typeof source.setModelPrediction === 'function') source.setModelPrediction(msg.payload);
};
// Inbound child registration from a measurement (or other) child node.
// Ported from the legacy `case 'registerChild'` branch in nodeClass.
exports.childRegister = (source, msg, ctx) => {
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj?.source) {
(ctx?.logger || source.logger)?.warn?.(`child.register skipped: missing child/source for id=${childId}`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent, msg.distance);
};

63
src/commands/index.js Normal file
View File

@@ -0,0 +1,63 @@
'use strict';
// monster command registry. Canonical names follow CONTRACTS.md §1.
// Legacy names (i_start, monsternametijden, rain_data, input_q, setMode,
// model_prediction) are surfaced as aliases — they log a one-time
// deprecation warning on first use and are removed in Phase 7.
const handlers = require('./handlers');
module.exports = [
{
topic: 'cmd.start',
aliases: ['i_start'],
payloadSchema: { type: 'any' },
description: 'Trigger / release the sampler start gate.',
handler: handlers.cmdStart,
},
{
topic: 'set.schedule',
aliases: ['monsternametijden'],
payloadSchema: { type: 'any' },
description: 'Replace the sampling-times schedule.',
handler: handlers.setSchedule,
},
{
topic: 'set.rain',
aliases: ['rain_data'],
payloadSchema: { type: 'any' },
description: 'Push current rain-event data into the sampler logic.',
handler: handlers.setRain,
},
{
topic: 'data.flow',
aliases: ['input_q'],
payloadSchema: { type: 'object' },
// Compound payload `{value, unit}` — handler converts internally to m3/h.
// Registry-level normalisation is skipped (the handler reads payload.value /
// payload.unit directly; flattening would break it).
description: 'Push the upstream flow measurement (payload: {value, unit}).',
handler: handlers.dataFlow,
},
{
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'any' },
description: 'Switch the monster between auto / manual modes.',
handler: handlers.setMode,
},
{
topic: 'set.model-prediction',
aliases: ['model_prediction'],
payloadSchema: { type: 'any' },
description: 'Push the upstream rain-prediction snapshot used by the sampler.',
handler: handlers.setModelPrediction,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'string' },
description: 'Register a child node (typically a measurement) with this monster.',
handler: handlers.childRegister,
},
];

59
src/flow/flowTracker.js Normal file
View File

@@ -0,0 +1,59 @@
'use strict';
// Flow tracking — manual override + measured-child fan-in + effective
// blend (mean of the two when both present). Wraps the
// MeasurementContainer so the domain stays read-only on flow state.
const POSITIONS = ['upstream', 'downstream', 'atequipment'];
class FlowTracker {
constructor({ measurements, logger }) {
this.measurements = measurements;
this.logger = logger;
this.manualFlow = null;
}
updateManualFlow(payload = {}) {
const value = Number(payload.value);
if (!Number.isFinite(value)) return;
const unit = payload.unit || 'm3/h';
this.manualFlow = value;
this.measurements.type('flow').variant('manual').position('atequipment')
.value(value, Date.now(), unit);
}
handleMeasuredFlow(eventData) {
const value = Number(eventData?.value);
if (!Number.isFinite(value)) return;
const position = String(eventData.position || 'atequipment').toLowerCase();
const unit = eventData.unit || 'm3/h';
this.measurements.type('flow').variant('measured').position(position)
.value(value, eventData.timestamp || Date.now(), unit);
}
getMeasuredFlow() {
const values = [];
for (const pos of POSITIONS) {
const v = this.measurements.type('flow').variant('measured').position(pos).getCurrentValue();
if (Number.isFinite(v)) values.push(v);
}
if (!values.length) return null;
return values.reduce((s, c) => s + c, 0) / values.length;
}
getManualFlow() {
const v = this.measurements.type('flow').variant('manual').position('atequipment').getCurrentValue();
return Number.isFinite(v) ? v : null;
}
getEffectiveFlow() {
const measured = this.getMeasuredFlow();
const manual = this.getManualFlow();
if (measured != null && manual != null) return (measured + manual) / 2;
if (measured != null) return measured;
if (manual != null) return manual;
return 0;
}
}
module.exports = FlowTracker;

70
src/io/output.js Normal file
View File

@@ -0,0 +1,70 @@
'use strict';
// Output formatter — assembles the snapshot shape getOutput returns each
// tick. Heavy on derived fields (timeToNextPulse, targetDelta, ...) but
// every value is read-only on the domain, so this can stay a pure function.
const params = require('../parameters/parameters');
function buildOutput(m) {
const output = m.measurements.getFlattenedOutput();
const flowRate = Number(m.q) || 0;
const m3PerPulse = Number(m.m3PerPuls) || 0;
const pulseFraction = Number(m.temp_pulse) || 0;
const targetVolumeL = Number(m.targetVolume) > 0 ? m.targetVolume : 0;
const targetVolumeM3 = targetVolumeL > 0 ? targetVolumeL / 1000 : 0;
const flowToNextPulseM3 = m3PerPulse > 0 ? Math.max(0, (1 - pulseFraction) * m3PerPulse) : 0;
const timeToNextPulseSec = flowRate > 0 && flowToNextPulseM3 > 0
? Math.round((flowToNextPulseM3 / (flowRate / 3600)) * 100) / 100
: 0;
const targetProgressPct = targetVolumeL > 0
? Math.round((m.bucketVol / targetVolumeL) * 10000) / 100
: 0;
const targetDeltaL = targetVolumeL > 0
? Math.round((m.bucketVol - targetVolumeL) * 100) / 100
: 0;
const targetDeltaM3 = targetVolumeL > 0
? Math.round((targetDeltaL / 1000) * 10000) / 10000
: 0;
Object.assign(output, {
pulse: m.pulse,
running: m.running,
bucketVol: m.bucketVol,
bucketWeight: m.bucketWeight,
sumPuls: m.sumPuls,
predFlow: m.predFlow,
predM3PerSec: m.predM3PerSec,
timePassed: m.timePassed,
timeLeft: m.timeLeft,
m3Total: m.m3Total,
q: m.q,
nominalFlowMin: m.nominalFlowMin,
flowMax: m.flowMax,
invalidFlowBounds: m.invalidFlowBounds,
minSampleIntervalSec: m.minSampleIntervalSec,
missedSamples: m.missedSamples,
sampleCooldownMs: params.getSampleCooldownMs(m),
maxVolume: m.maxVolume,
minVolume: m.minVolume,
nextDate: m.nextDate,
daysPerYear: m.daysPerYear,
m3PerPuls: m.m3PerPuls,
m3PerPulse: m.m3PerPuls,
pulsesRemaining: Math.max(0, (m.targetPuls || 0) - (m.sumPuls || 0)),
pulseFraction,
flowToNextPulseM3,
timeToNextPulseSec,
targetVolumeM3,
targetProgressPct,
targetDeltaL,
targetDeltaM3,
predictedRateM3h: params.getPredictedFlowRate(m),
sumRain: m.rainAggregator?.sumRain ?? 0,
avgRain: m.rainAggregator?.avgRain ?? 0,
});
return output;
}
module.exports = { buildOutput };

28
src/io/statusBadge.js Normal file
View File

@@ -0,0 +1,28 @@
'use strict';
// Status-badge composition. Three states the editor cares about:
// - red ring : config error (flow bounds invalid)
// - yellow ring: sampling but cooldown is gating the next pulse
// - green dot : sampling normally
// - grey ring : idle
// Shape mirrors the legacy nodeClass._updateNodeStatus output verbatim.
const { statusBadge } = require('generalFunctions');
const params = require('../parameters/parameters');
function buildStatusBadge(m) {
if (m.invalidFlowBounds) {
return statusBadge.error(`Config error: nominalFlowMin (${m.nominalFlowMin}) >= flowMax (${m.flowMax})`);
}
if (m.running) {
const levelText = `${m.bucketVol}/${m.maxVolume} L`;
const cooldownMs = params.getSampleCooldownMs(m);
if (cooldownMs > 0) {
return statusBadge.compose([`SAMPLING (${Math.ceil(cooldownMs / 1000)}s)`, levelText], { fill: 'yellow', shape: 'ring' });
}
return statusBadge.compose([`AI: RUNNING`, levelText], { fill: 'green', shape: 'dot' });
}
return statusBadge.idle('AI: IDLE');
}
module.exports = { buildStatusBadge };

View File

@@ -1,51 +1,19 @@
/** 'use strict';
* node class.js
*
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
*/
const { outputUtils, configManager, convert } = require('generalFunctions');
const Specific = require('./specificClass');
class nodeClass { const { BaseNodeAdapter } = require('generalFunctions');
/** const Monster = require('./specificClass');
* Create a Node. const commands = require('./commands');
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
* @param {object} nodeInstance - The Node-RED node instance.
* @param {string} nameOfNode - The name of the node.
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this.source = null;
this.config = null;
// Load default & UI config class nodeClass extends BaseNodeAdapter {
this._loadConfig(uiConfig); static DomainClass = Monster;
static commands = commands;
// Tick-driven: sampling integrator (m3PerTick → temp_pulse) needs
// wall-clock delta-time once per second.
static tickInterval = 1000;
static statusInterval = 1000;
// Instantiate core class buildDomainConfig(uiConfig) {
this._setupSpecificClass(uiConfig); return {
// Wire up event and lifecycle handlers
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
/**
* Load and merge default config with user-defined settings.
* Uses ConfigManager.buildConfig() for base sections, then adds monster-specific domain config.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_loadConfig(uiConfig) {
const cfgMgr = new configManager();
// Build config: base sections + monster-specific domain config
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
constraints: { constraints: {
samplingtime: Number(uiConfig.samplingtime) || 0, samplingtime: Number(uiConfig.samplingtime) || 0,
minVolume: Number(uiConfig.minvolume ?? uiConfig.minVolume) || 5, minVolume: Number(uiConfig.minvolume ?? uiConfig.minVolume) || 5,
@@ -55,186 +23,14 @@ class nodeClass {
maxRainRef: Number(uiConfig.maxRainRef) || 10, maxRainRef: Number(uiConfig.maxRainRef) || 10,
minSampleIntervalSec: Number(uiConfig.minSampleIntervalSec) || 60, minSampleIntervalSec: Number(uiConfig.minSampleIntervalSec) || 60,
}, },
}); functionality: { role: 'samplingCabinet', aquonSampleName: uiConfig.aquon_sample_name || undefined },
asset: { emptyWeightBucket: Number(uiConfig.emptyWeightBucket) || 3 },
this.config.functionality = {
...this.config.functionality,
role: 'samplingCabinet',
aquonSampleName: uiConfig.aquon_sample_name || undefined,
}; };
this.config.asset = {
...this.config.asset,
emptyWeightBucket: Number(uiConfig.emptyWeightBucket) || 3,
};
// Utility for formatting outputs
this._output = new outputUtils();
} }
/** extraSetup() {
* Instantiate the core logic and store as source. const uiSampleName = this.config?.functionality?.aquonSampleName;
*/ if (uiSampleName) this.source.aquonSampleName = uiSampleName;
_setupSpecificClass(uiConfig) {
this.source = new Specific(this.config);
if (uiConfig?.aquon_sample_name) {
this.source.aquonSampleName = uiConfig.aquon_sample_name;
}
this.node.source = this.source;
}
/**
* Bind events to Node-RED status updates.
*/
_bindEvents() {}
_updateNodeStatus() {
const m = this.source;
try {
const bucketVol = m.bucketVol;
const maxVolume = m.maxVolume;
const state = m.running;
const mode = 'AI';
const flowMin = m.nominalFlowMin;
const flowMax = m.flowMax;
if (m.invalidFlowBounds) {
return {
fill: 'red',
shape: 'ring',
text: `Config error: nominalFlowMin (${flowMin}) >= flowMax (${flowMax})`,
};
}
if (state) {
const levelText = `${bucketVol}/${maxVolume} L`;
const cooldownMs = typeof m.getSampleCooldownMs === 'function'
? m.getSampleCooldownMs()
: 0;
if (cooldownMs > 0) {
const cooldownSec = Math.ceil(cooldownMs / 1000);
return { fill: 'yellow', shape: 'ring', text: `SAMPLING (${cooldownSec}s) ${levelText}` };
}
return { fill: 'green', shape: 'dot', text: `${mode}: RUNNING ${levelText}` };
}
return { fill: 'grey', shape: 'ring', text: `${mode}: IDLE` };
} catch (error) {
this.node.error(`Error in updateNodeStatus: ${error.message}`);
return { fill: 'red', shape: 'ring', text: 'Status Error' };
}
}
/**
* Register this node as a child upstream and downstream.
* Delayed to avoid Node-RED startup race conditions.
*/
_registerChild() {
setTimeout(() => {
this.node.send([
null,
null,
{ topic: 'registerChild', payload: this.config.general.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
]);
}, 100);
}
/**
* Start the periodic tick loop.
*/
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
this._statusInterval = setInterval(() => {
this.node.status(this._updateNodeStatus());
}, 1000);
}, 1000);
}
/**
* Execute a single tick: update measurement, format and send outputs.
*/
_tick() {
this.source.tick();
const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
this.node.send([processMsg, influxMsg]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
const m = this.source;
try {
switch (msg.topic) {
case 'input_q': {
const value = Number(msg.payload?.value);
const unit = msg.payload?.unit;
if (!Number.isFinite(value) || !unit) {
this.node.warn('input_q payload must include numeric value and unit.');
break;
}
let converted = value;
try {
converted = convert(value).from(unit).to('m3/h');
} catch (error) {
this.node.warn(`input_q unit conversion failed: ${error.message}`);
break;
}
m.handleInput('input_q', { value: converted, unit: 'm3/h' });
break;
}
case 'i_start':
case 'monsternametijden':
case 'rain_data':
m.handleInput(msg.topic, msg.payload);
break;
case 'registerChild': {
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
if (childObj?.source) {
m.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
}
break;
}
case 'setMode':
m.setMode(msg.payload);
break;
case 'model_prediction':
if (typeof m.setModelPrediction === 'function') {
m.setModelPrediction(msg.payload);
}
break;
default:
m.logger?.warn(`Unknown topic: ${msg.topic}`);
break;
}
} catch (error) {
this.node.error(`Error handling input (${msg?.topic}): ${error?.message || error}`);
} finally {
if (typeof done === 'function') done();
}
});
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
if (typeof done === 'function') done();
});
} }
} }

View File

@@ -0,0 +1,60 @@
'use strict';
// Sampling-cabinet boundary + target math + rain-scaled flow prediction.
// All operations are pure given a domain handle — the domain owns the
// mutable fields (maxVolume, targetPuls, …) so legacy tests that read
// `monster.maxVolume` keep working.
const RAIN_STALE_MS = 2 * 60 * 60 * 1000;
function applyBoundsAndTargets(m) {
m.maxVolume = m.maxWeight - m.emptyWeightBucket;
m.minPuls = Math.round(m.minVolume / m.volume_pulse);
m.maxPuls = Math.round(m.maxVolume / m.volume_pulse);
m.absMaxPuls = Math.round(m.cap_volume / m.volume_pulse);
m.targetVolume = m.minVolume * Math.sqrt(m.maxVolume / m.minVolume);
m.targetPuls = Math.round(m.targetVolume / m.volume_pulse);
}
function validateFlowBounds(m) {
// nominalFlowMin / flowMax must be declared in monster.json's `constraints`
// section. If they aren't, configUtils strips them as unknown keys and the
// bounds collapse to undefined → NaN → invalid here, which silently blocks
// every sampling run. (Fixed 2026-05-11.)
const min = Number(m.nominalFlowMin);
const max = Number(m.flowMax);
const valid = Number.isFinite(min) && Number.isFinite(max) && min >= 0 && max > 0 && min < max;
m.invalidFlowBounds = !valid;
if (!valid) m.logger.warn(`Invalid flow bounds. nominalFlowMin=${m.nominalFlowMin}, flowMax=${m.flowMax}`);
return valid;
}
function getRainIndex(m) {
if (!m.lastRainUpdate) return 0;
if (Date.now() - m.lastRainUpdate > RAIN_STALE_MS) return 0;
return Number.isFinite(m.avgRain) ? m.avgRain : 0;
}
function getPredictedFlowRate(m) {
const min = Number(m.nominalFlowMin);
const max = Number(m.flowMax);
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max <= 0 || min >= max) return 0;
const rainIndex = getRainIndex(m);
const scale = Math.max(0, Math.min(1, m.rainMaxRef > 0 ? rainIndex / m.rainMaxRef : 0));
return min + (max - min) * scale;
}
function getSampleCooldownMs(m) {
if (!m.lastSampleTime) return 0;
const remaining = (m.minSampleIntervalSec * 1000) - (Date.now() - m.lastSampleTime);
return Math.max(0, remaining);
}
module.exports = {
applyBoundsAndTargets,
validateFlowBounds,
getRainIndex,
getPredictedFlowRate,
getSampleCooldownMs,
RAIN_STALE_MS,
};

View File

@@ -0,0 +1,58 @@
'use strict';
// Rain-data aggregator — sums per-location hourly precipitation, weighted
// by per-hour probability, and stores both the raw and probability-weighted
// values keyed by timestamp. sumRain/avgRain feed parameters.getRainIndex
// which scales the predicted flow rate between nominalFlowMin and flowMax.
class RainAggregator {
constructor({ logger } = {}) {
this.logger = logger;
this.aggregatedOutput = {};
this.sumRain = 0;
this.avgRain = 0;
}
// Returns the aggregated per-location object so callers can chain.
// Mutates this.aggregatedOutput / sumRain / avgRain in place.
update(value) {
if (!value) return this.aggregatedOutput;
const totalRaw = {};
const totalProb = {};
let numberOfLocations = 0;
Object.entries(value).forEach(([locationKey, location]) => {
numberOfLocations++;
const slot = (this.aggregatedOutput[locationKey] = {
tag: { latitude: location.latitude, longitude: location.longitude },
precipationRaw: {},
precipationProb: {},
});
Object.entries(location.hourly.time).forEach(([key, time]) => {
const currTimestamp = new Date(time).getTime();
let probability = 100;
if (typeof location.hourly.precipitation_probability !== 'undefined') {
probability = location.hourly.precipitation_probability[key];
}
if (probability > 0) probability /= 100;
if (totalRaw[currTimestamp] === undefined) totalRaw[currTimestamp] = 0;
if (totalProb[currTimestamp] === undefined) totalProb[currTimestamp] = 0;
totalRaw[currTimestamp] += location.hourly.precipitation[key];
totalProb[currTimestamp] += location.hourly.precipitation[key] * probability;
slot.precipationRaw[key] = { val: location.hourly.precipitation[key], time: currTimestamp };
slot.precipationProb[key] = { val: probability, time: currTimestamp };
});
});
this.sumRain = Object.values(totalProb).reduce((s, v) => s + v, 0);
this.avgRain = numberOfLocations > 0 ? this.sumRain / numberOfLocations : 0;
return this.aggregatedOutput;
}
}
module.exports = RainAggregator;

View File

@@ -0,0 +1,113 @@
'use strict';
// Sampling program — the time-driven core. Each tick:
// 1. on i_start or scheduled date → init a sampling run (m3PerPuls, stop_time)
// 2. while running: integrate m3PerTick into temp_pulse; emit a pulse when
// it crosses 1 unless the cooldown guard blocks it
// 3. after stop_time: clear running state.
// flowCalc derives m3PerTick from the latest q (m3/h) and the wall-clock
// delta since the last call — runs once per tick before sampling_program.
const params = require('../parameters/parameters');
const { regNextDate } = require('../schedule/schedule');
function getModelPrediction(m) {
const samplingHours = Number(m.sampling_time) || 0;
const predictedRate = params.getPredictedFlowRate(m);
const fallbackRate = m.flowTracker.getEffectiveFlow();
const flowM3PerHour = predictedRate > 0 ? predictedRate : fallbackRate;
m.predFlow = Math.max(0, flowM3PerHour * samplingHours);
return m.predFlow;
}
function flowCalc(m) {
const timePassed = m.flowTime > 0 ? (Date.now() - m.flowTime) / 1000 : 0;
m.m3PerTick = (m.q / 60 / 60) * timePassed;
m.flowTime = Date.now();
}
function _beginRun(m) {
m.running = true;
m.temp_pulse = 0;
m.pulse = false;
m.updateBucketVol(0);
m.sumPuls = 0;
m.m3Total = 0;
m.timePassed = 0;
m.timeLeft = 0;
m.predM3PerSec = 0;
getModelPrediction(m);
m.m3PerPuls = Math.round(m.predFlow / m.targetPuls);
m.predM3PerSec = m.predFlow / m.sampling_time / 60 / 60;
m.start_time = Date.now();
m.stop_time = Date.now() + (m.sampling_time * 60 * 60 * 1000);
regNextDate(m, m.monsternametijden);
m.i_start = false;
}
function _endRun(m) {
m.m3PerPuls = 0;
m.temp_pulse = 0;
m.pulse = false;
m.updateBucketVol(0);
m.sumPuls = 0;
m.timePassed = 0;
m.timeLeft = 0;
m.predFlow = 0;
m.predM3PerSec = 0;
m.m3Total = 0;
m.running = false;
}
function _maybeEmitPulse(m) {
if (!(m.temp_pulse >= 1 && m.sumPuls < m.absMaxPuls)) {
if (m.pulse) m.pulse = false;
return;
}
const now = Date.now();
const cooldownMs = m.minSampleIntervalSec * 1000;
const blocked = m.lastSampleTime && (now - m.lastSampleTime) < cooldownMs;
if (blocked) {
m.missedSamples++;
m.pulse = false;
m.temp_pulse = Math.min(m.temp_pulse, 1);
if (!m.lastSampleWarnTime || (now - m.lastSampleWarnTime) > cooldownMs) {
m.lastSampleWarnTime = now;
m.logger.warn(`Sampling too fast. Cooldown active for ${Math.ceil((cooldownMs - (now - m.lastSampleTime)) / 1000)}s.`);
}
return;
}
m.temp_pulse -= 1;
m.pulse = true;
m.lastSampleTime = now;
m.sumPuls++;
m.updateBucketVol(Math.round(m.sumPuls * m.volume_pulse * 100) / 100);
}
function samplingProgram(m) {
if (((m.i_start) || (Date.now() >= m.nextDate)) && !m.running) {
if (!params.validateFlowBounds(m)) {
m.running = false;
m.i_start = false;
return;
}
_beginRun(m);
}
if (m.stop_time > Date.now()) {
m.timePassed = Math.round((Date.now() - m.start_time) / 1000);
m.timeLeft = Math.round((m.stop_time - Date.now()) / 1000);
m.temp_pulse += m.m3PerTick / m.m3PerPuls;
m.m3Total += m.m3PerTick;
_maybeEmitPulse(m);
} else if (m.running) {
_endRun(m);
}
}
module.exports = { samplingProgram, flowCalc, getModelPrediction };

42
src/schedule/schedule.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict';
// AQUON sample schedule helpers. updateMonsternametijden validates the
// row shape before storing; regNextDate walks the rows to find the next
// future START_DATE for the configured aquonSampleName and counts how
// many of those fall in the current calendar year.
function updateMonsternametijden(m, value) {
if (!m.init || !value || Object.keys(value).length === 0) return;
if (
typeof value[0]?.SAMPLE_NAME !== 'undefined' &&
typeof value[0]?.DESCRIPTION !== 'undefined' &&
typeof value[0]?.SAMPLED_DATE !== 'undefined' &&
typeof value[0]?.START_DATE !== 'undefined' &&
typeof value[0]?.END_DATE !== 'undefined'
) {
m.monsternametijden = value;
regNextDate(m, value);
}
}
function regNextDate(m, monsternametijden) {
let next_date = new Date(new Date().setFullYear(new Date().getFullYear() + 1));
let n_days_remaining = 0;
if (typeof monsternametijden !== 'undefined') {
Object.values(monsternametijden).forEach((line) => {
if (line.START_DATE === 'NULL') return;
const curr_date_conv = new Date(line.START_DATE);
const curr_date = curr_date_conv.getTime();
if (line.SAMPLE_NAME === m.aquonSampleName && curr_date > Date.now()) {
if (curr_date < next_date) next_date = curr_date;
if (new Date().getFullYear() === curr_date_conv.getFullYear()) n_days_remaining++;
}
});
}
m.daysPerYear = n_days_remaining;
m.nextDate = next_date;
}
module.exports = { updateMonsternametijden, regNextDate };

File diff suppressed because it is too large Load Diff

172
wiki/Home.md Normal file
View File

@@ -0,0 +1,172 @@
# monster
![code-ref](https://img.shields.io/badge/code--ref-cd185dc-blue) ![s88](https://img.shields.io/badge/S88-Unit-50a8d9) ![status](https://img.shields.io/badge/status-stub--tier-orange)
A `monster` models the physical "monsternamekast" (composite-sampling cabinet) on a wastewater treatment plant. It runs an AQUON-scheduled, flow-proportional sampling program: aggregates measured + manual flow, blends in a rain-scaled prediction, integrates volume, and emits a `pulse` event whenever the integrated volume crosses `m³ per pulse`. Used downstream of `reactor` / `settler` for compliance + process diagnostics — but note that the node only **integrates flow** in the current implementation; multi-constituent reporting (NH4, NO3, COD, TSS, …) is a downstream consumer concern, not produced by this node.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | One composite-sampling cabinet running AQUON-scheduled, flow-proportional sampling |
| S88 level | Unit |
| Use it when | You need scheduled composite samples with flow-proportional pulses for a benchtop analyser / lab pickup |
| Don't use it for | Generic flow totalising (use `measurement`), ad-hoc grab samples (fire `cmd.start` once instead), or analyser results — monster emits pulses, not analyte values |
| Children it accepts | `measurement` with `asset.type='flow'` (or unset) |
| Parents it talks to | Any node that issues `cmd.start` / `set.schedule` / `set.rain` / `data.flow` (typically a Process Cell coordinator) |
---
## How it fits
```mermaid
flowchart LR
parent[plant parent<br/>Process Cell]:::pc
monster[monster<br/>Unit]:::unit
flow_up[measurement<br/>type=flow<br/>position=upstream]:::ctrl
flow_at[measurement<br/>type=flow<br/>position=atequipment]:::ctrl
flow_dn[measurement<br/>type=flow<br/>position=downstream]:::ctrl
op[(operator / AQUON)]
weather[(Open-Meteo)]
flow_up -->|flow.measured.upstream| monster
flow_at -->|flow.measured.atequipment| monster
flow_dn -->|flow.measured.downstream| monster
op -->|set.schedule / data.flow / cmd.start| monster
weather -->|set.rain| monster
monster -->|child.register| parent
monster -.evt output (pulse / bucketVol).-> parent
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`. The editor node tile in `monster.html` is currently `#4f8582` (teal) and pending cleanup — Section 16 of the layout rules tracks it.
---
## Try it &mdash; 3-minute demo
Import the basic example flow, deploy, and drive the sampler through a single run.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/monster/examples/basic.flow.json \
http://localhost:1880/flow
```
What to click after deploy (the inject buttons map to the topics in [Reference &mdash; Contracts](Reference-Contracts#topic-contract)):
1. `set.rain` &mdash; push an Open-Meteo precipitation snapshot so `sumRain` / `avgRain` populate (otherwise the rain-scaled prediction stays at `nominalFlowMin`).
2. `set.schedule` &mdash; push an AQUON row array so `nextDate` arms.
3. `data.flow = {value: 240, unit: 'm3/h'}` &mdash; supply a manual flow value (or wire a `measurement` child for the measured path).
4. `cmd.start = true` &mdash; releases the start gate. On the next 1000 ms tick the sampling program checks `validateFlowBounds`; if `nominalFlowMin < flowMax`, `_beginRun` fires, `running` flips to `true`, and the bucket integrator starts.
5. Watch Port 0: `pulse` blips to `true` for one tick whenever `temp_pulse` crosses 1; `bucketVol` rises in 50 mL steps (the hard-coded `subSampleVolume`); `sumPuls` increments.
6. After `constraints.samplingtime` hours `_endRun` fires and `running` flips to `false`.
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;6 with the live status panel. Save as `wiki/_partial-gifs/monster/basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
---
## The seven things you'll send
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `cmd.start` | `i_start` | truthy / falsy | Sets `source.i_start`. On the next tick a sampling run begins if `validateFlowBounds` passes. |
| `set.schedule` | `monsternametijden` | array of AQUON rows (`SAMPLE_NAME`, `DESCRIPTION`, `SAMPLED_DATE`, `START_DATE`, `END_DATE`) | Stores the schedule and recomputes `nextDate` + `daysPerYear` for the configured `aquonSampleName`. |
| `set.rain` | `rain_data` | per-location rain forecast (Open-Meteo shape) | Aggregates hourly precipitation into `sumRain` / `avgRain`; feeds the rain-scaled flow prediction. Ignored while `running=true`. |
| `data.flow` | `input_q` | `{ value: number, unit: string }` | Converts to m³/h and pushes into `flow.manual.atequipment`. Blends with measured-child flow in `getEffectiveFlow()`. |
| `set.mode` | `setMode` | string | Reserved &mdash; handler delegates to `source.setMode()` which is currently undefined. No-op today. |
| `set.model-prediction` | `model_prediction` | numeric | Reserved &mdash; handler delegates to `source.setModelPrediction()` which is currently undefined. No-op today. |
| `child.register` | `registerChild` | string (child node id) | Register a `measurement` child. Port 2 wiring does this automatically. |
There is no `query.*` topic on monster (unlike `rotatingMachine`'s `query.curves` / `query.cog`). All state is on Port 0.
---
## What you'll see come out
Sample Port 0 message (mid-run snapshot, delta-compressed):
```json
{
"topic": "monster#cabinet_1",
"payload": {
"running": true,
"pulse": false,
"bucketVol": 1.25,
"bucketWeight": 4.25,
"sumPuls": 25,
"m3PerPuls": 4,
"m3Total": 100.0,
"q": 215.4,
"predFlow": 240.0,
"predM3PerSec": 0.067,
"timeLeft": 12340,
"timePassed": 600,
"targetVolumeM3": 0.005,
"targetProgressPct": 41.6,
"targetDeltaL": -2.93,
"predictedRateM3h": 240.0,
"sumRain": 3.2,
"avgRain": 0.13,
"nextDate": 1746940800000,
"daysPerYear": 12,
"missedSamples": 0,
"sampleCooldownMs": 0,
"invalidFlowBounds": false
}
}
```
| Field | Meaning |
|:---|:---|
| `running` | Run/idle flag &mdash; monster has no formal FSM, just this boolean and the timer fields below. |
| `pulse` | `true` for the tick on which a pulse is emitted; falls back to `false` on the next tick. |
| `bucketVol` / `bucketWeight` | Accumulated composite volume (L) and total bucket mass (kg = vol + `emptyWeightBucket`). |
| `sumPuls` / `pulsesRemaining` | Pulses fired this run vs target ceiling. |
| `m3PerPuls` | Volume per pulse, set at `_beginRun` from `predFlow / targetPuls`. |
| `m3Total` | Total integrated flow this run (m³). |
| `q` | Effective flow (m³/h) &mdash; `flowTracker.getEffectiveFlow()` blends measured + manual. |
| `predFlow` / `predM3PerSec` | Predicted total volume over the run window (m³) and its average rate. |
| `timeLeft` / `timePassed` | Run-window seconds remaining / elapsed. |
| `targetVolumeM3` / `targetProgressPct` / `targetDeltaL` | Target composite volume (m³), % of target accumulated, signed L delta against target. |
| `predictedRateM3h` | Rain-scaled flow prediction (m³/h) &mdash; between `nominalFlowMin` and `flowMax`. |
| `sumRain` / `avgRain` | Probability-weighted hourly precipitation, summed / averaged across locations. |
| `nextDate` / `daysPerYear` | Next scheduled run epoch ms; count of remaining runs this calendar year. |
| `missedSamples` / `sampleCooldownMs` | Cooldown-blocked pulse count; ms remaining on the active cooldown. |
| `invalidFlowBounds` | True when `nominalFlowMin >= flowMax` &mdash; gates `_beginRun`. |
Full Port-0 key list: see [Reference &mdash; Contracts &mdash; Data model](Reference-Contracts#data-model--getoutput-shape).
Key shape is **flat scalars on the snapshot** plus a `MeasurementContainer.getFlattenedOutput()` flush of `flow.<variant>.<position>` entries. monster does **not** use the four-segment `<type>.<variant>.<position>.<childId>` shape that `rotatingMachine` emits — no `childId` is appended because monster fans incoming children into the same three positions and the latest value wins.
---
## Status badge
| State | Badge | Fill |
|:---|:---|:---|
| `invalidFlowBounds=true` | `Config error: nominalFlowMin (…) >= flowMax (…)` | red |
| `running=true` + cooldown active | `SAMPLING (Ns) · <bucketVol>/<maxVolume> L` | yellow ring |
| `running=true` + normal | `AI: RUNNING · <bucketVol>/<maxVolume> L` | green dot |
| idle | `AI: IDLE` | grey ring |
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, sampling-program loop, prediction + cooldown pipeline |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

@@ -0,0 +1,245 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-cd185dc-blue)
> [!NOTE]
> Code structure for `monster`: the three-tier sandwich, the `src/` layout, the sampling-program loop, the rain-scaled flow prediction, the cooldown guard, the lifecycle, and the output-port pipeline. For an intuitive overview, return to [Home](Home).
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
---
## Three-tier code layout
```
nodes/monster/
|
+-- monster.js entry: RED.nodes.registerType('monster', NodeClass)
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (orchestration only)
| |
| +-- commands/
| | index.js topic descriptors (cmd.start / set.schedule / …)
| | handlers.js pure handler functions
| |
| +-- parameters/
| | parameters.js applyBoundsAndTargets / validateFlowBounds /
| | getRainIndex / getPredictedFlowRate /
| | getSampleCooldownMs
| |
| +-- flow/
| | flowTracker.js measured-child latch + manual flow blend
| |
| +-- rain/
| | rainAggregator.js Open-Meteo precipitation fold (sumRain / avgRain)
| |
| +-- schedule/
| | schedule.js AQUON next-date parser + daysPerYear count
| |
| +-- sampling/
| | samplingProgram.js _beginRun / _endRun / _maybeEmitPulse /
| | flowCalc / samplingProgram / getModelPrediction
| |
| +-- io/
| output.js buildOutput() — Port 0 snapshot
| statusBadge.js buildStatusBadge() — editor badge composer
```
### Tier responsibilities
| Tier | File | What it owns | Touches `RED.*` |
|:---|:---|:---|:---:|
| entry | `monster.js` | Type registration; `/monster/menu.js` + `/monster/configData.js` HTTP endpoints | Yes |
| nodeClass | `src/nodeClass.js` | `tickInterval = 1000` (sampling integrator needs wall-clock delta), `statusInterval = 1000`, `buildDomainConfig` slice, `extraSetup` propagates `aquonSampleName` | Yes |
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; expose the public surface legacy tests call (`monster.bucketVol`, `monster.q`, `monster.sampling_program()`, …); delegate everything else | No |
`specificClass` is orchestration. Real work lives in the concern modules: pure math in `parameters/`, schedule walking in `schedule/`, aggregation in `rain/`, the sampling integrator in `sampling/`.
---
## Sampling program &mdash; the time-driven core
monster has **no formal FSM**. The `running` boolean toggles when `_beginRun` / `_endRun` fire. The closest analogue to a state diagram is the per-tick decision tree:
```mermaid
flowchart TB
tick[tick&#40;&#41; — every 1000 ms]
tick --> q[q = flowTracker.getEffectiveFlow&#40;&#41;]
q --> fc[flowCalc&#40;&#41;<br/>m3PerTick = q/3600 * dt]
fc --> sp[samplingProgram&#40;&#41;]
sp --> trig{i_start OR<br/>now ≥ nextDate?<br/>AND NOT running}
trig -- yes --> vb{validateFlowBounds?}
vb -- no --> done[return — running stays false]
vb -- yes --> begin[_beginRun<br/>m3PerPuls = predFlow / targetPuls<br/>stop_time = now + samplingtime·h]
trig -- no --> active{stop_time &gt; now?}
begin --> active
active -- yes --> integ[temp_pulse += m3PerTick / m3PerPuls<br/>m3Total += m3PerTick]
integ --> pulse[_maybeEmitPulse]
pulse --> emit{temp_pulse ≥ 1<br/>AND sumPuls &lt; absMaxPuls?}
emit -- no --> notify[notifyOutputChanged]
emit -- yes --> cd{cooldown<br/>blocked?}
cd -- yes --> miss[missedSamples++<br/>warn one-shot]
miss --> notify
cd -- no --> fire[temp_pulse -= 1<br/>pulse = true<br/>sumPuls++<br/>bucketVol += 50 mL]
fire --> notify
active -- no, running --> endR[_endRun — running = false]
endR --> notify
```
Key invariants:
- One pulse per integrated `m³ per pulse`. The cooldown guard suppresses pulses inside `minSampleIntervalSec` and increments `missedSamples` (without rolling the integrator back &mdash; `temp_pulse` is clamped to `1` so subsequent ticks land on the same threshold once the cooldown clears).
- `_beginRun` rounds `m3PerPuls = round(predFlow / targetPuls)`. With low `predFlow` this can round to 0; the integrator then divides by zero and `temp_pulse` becomes `Infinity`. Tracked &mdash; see [Limitations](Reference-Limitations#mperpuls-can-round-to-zero).
- `subSampleVolume` is hard-coded at 50 mL via `volume_pulse = 0.05`. The schema enforces `min=max=50` so the field is informational only.
- `set.rain` updates are **skipped while `running=true`** &mdash; the rain band is only re-evaluated between runs.
---
## Rain-scaled flow prediction
`parameters.getPredictedFlowRate` linearly scales between `nominalFlowMin` and `flowMax`:
```
scale = clamp(avgRain / maxRainRef, 0, 1)
predicted = nominalFlowMin + (flowMax - nominalFlowMin) * scale
```
with `avgRain` zeroed after `RAIN_STALE_MS = 2 hours` since the last `set.rain` update.
`rainAggregator.update` folds the Open-Meteo per-location payload by timestamp, multiplying each hour's `precipitation` by its `precipitation_probability/100` and summing across locations. `avgRain` is the per-location mean of the probability-weighted sum.
`getModelPrediction` (called inside `_beginRun`) computes the run's expected volume:
```
predFlow = max(0, predictedRate · samplingtime)
// falls back to getEffectiveFlow when predictedRate is 0
```
So `predFlow` is total m³ over the run window, and `m3PerPuls = round(predFlow / targetPuls)`.
---
## Flow blending
`flowTracker` owns three writers:
| Source | Where it writes |
|:---|:---|
| `data.flow` (operator / parent) | `flow.manual.atequipment` |
| Child `flow.measured.upstream` | `flow.measured.upstream` |
| Child `flow.measured.atequipment` | `flow.measured.atequipment` |
| Child `flow.measured.downstream` | `flow.measured.downstream` |
`getEffectiveFlow()` blends:
```
measured = mean(flow.measured.upstream/atequipment/downstream where present)
manual = flow.manual.atequipment
effective = (measured + manual) / 2 if both present
= measured if only measured present
= manual if only manual present
= 0 otherwise
```
There is no source-priority / preference policy &mdash; both sources contribute equally when both are present. Contrast with `rotatingMachine`'s `pressureSelector` which prefers real over virtual children.
---
## Lifecycle &mdash; what one event does
```mermaid
sequenceDiagram
autonumber
participant child as measurement child
participant ops as operator / AQUON
participant monster as monster
participant ft as flowTracker
participant ra as rainAggregator
participant sp as samplingProgram
participant out as Port 0 / 1
child->>monster: flow.measured.<position>
monster->>ft: handleMeasuredFlow(eventData)
ops->>monster: set.schedule / cmd.start / data.flow / set.rain
Note over monster: every 1000 ms tick
monster->>ft: getEffectiveFlow() → q
monster->>sp: flowCalc → m3PerTick
monster->>sp: samplingProgram
alt i_start OR now ≥ nextDate
sp->>sp: validateFlowBounds → _beginRun
end
alt running AND stop_time > now
sp->>sp: integrate temp_pulse
sp->>sp: _maybeEmitPulse (cooldown-guarded)
else stop_time elapsed
sp->>sp: _endRun
end
monster->>out: notifyOutputChanged (Port 0/1 delta)
```
### Tick interval
`static tickInterval = 1000` (ms). Set on `nodeClass`. Required by the integrator &mdash; `flowCalc` derives `m3PerTick` from the wall-clock delta since the last tick, so the loop must run at a stable cadence.
### Status interval
`static statusInterval = 1000` (ms). The status badge re-renders at the same cadence as the tick.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot &mdash; `pulse`, `running`, `bucketVol`, `sumPuls`, `m3PerPuls`, `q`, `predFlow`, `timeLeft`, target deltas, rain summary, `nextDate` | `{topic, payload: {running, pulse, bucketVol, ...}}` |
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `monster,id=cabinet_1 running=true,pulse=false,bucketVol=1.25,m3PerPuls=4,...` |
| 2 (register / control) | `child.register` upward at init | `{topic: 'child.register', payload: {ref, softwareType, config, positionVsParent, distance}}` |
monster does **not** include a `<childId>` segment on flattened measurement keys (it has no per-child key disambiguation &mdash; the latest value per position wins).
See [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
---
## Event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| Child measurement emitter | `child.measurements.emitter` on `flow.measured.<upstream/atequipment/downstream>` | `flowTracker.handleMeasuredFlow` |
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch → handler |
| `setInterval(tickInterval = 1000)` | `BaseNodeAdapter` | `tick()``flowCalc``samplingProgram``notifyOutputChanged` |
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
There is no per-state emitter (no `stateChange` / `positionChange`) &mdash; monster is purely tick-driven.
---
## Where to start reading
| If you're changing... | Read first |
|:---|:---|
| Sampling-run init / pulse emission / cooldown | `src/sampling/samplingProgram.js` |
| Bounds + targets math, rain index, flow prediction | `src/parameters/parameters.js` |
| Flow source priority, dead-band, manual blend | `src/flow/flowTracker.js` |
| Open-Meteo aggregation, forecast horizon | `src/rain/rainAggregator.js` |
| Schedule arming, sample-name filtering, daysPerYear | `src/schedule/schedule.js` |
| Topic registration, payload validation, alias deprecation | `src/commands/{index, handlers}.js` |
| Port-0 payload shape, derived fields | `src/io/output.js` |
| Status badge composition | `src/io/statusBadge.js` |
| Node-RED config slice, tick interval | `src/nodeClass.js` `buildDomainConfig` |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

204
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,204 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-cd185dc-blue)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration filters for `monster`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/monster.json`.
>
> For an intuitive overview, return to the [Home](Home).
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
---
## Topic contract
The registry lives in `src/commands/index.js`. Each descriptor maps a canonical `msg.topic` to its handler; aliases emit a one-time deprecation warning the first time they fire.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `cmd.start` | `i_start` | any | — | Trigger / release the sampler start gate. |
| `set.schedule` | `monsternametijden` | any | — | Replace the sampling-times schedule. |
| `set.rain` | `rain_data` | any | — | Push current rain-event data into the sampler logic. |
| `data.flow` | `input_q` | `object` | — | Push the upstream flow measurement (payload: {value, unit}). |
| `set.mode` | `setMode` | any | — | Switch the monster between auto / manual modes. |
| `set.model-prediction` | `model_prediction` | any | — | Push the upstream rain-prediction snapshot used by the sampler. |
| `child.register` | `registerChild` | `string` | — | Register a child node (typically a measurement) with this monster. |
<!-- END AUTOGEN: topic-contract -->
### Mode / source / action allow-lists
monster has **no allow-list enforcement**. There is no `flowController.handle` equivalent and no `mode.allowedActions` / `mode.allowedSources` config slice. The `set.mode` handler is a placeholder. Compare `rotatingMachine`, which gates every topic through the mode matrix &mdash; on monster, every topic dispatches unconditionally.
---
## Data model &mdash; `getOutput()` shape
Composed each tick by `src/io/output.js` `buildOutput()`. Delta-compressed: consumers see only the keys that changed.
### Flat measurement keys
For every `(type, variant, position)` stored in MeasurementContainer, `getFlattenedOutput()` emits the three-segment key (note: monster does **not** add a `<childId>` segment, unlike `rotatingMachine`):
| Key | Type | Unit | Notes |
|:---|:---|:---|:---|
| `flow.manual.atequipment` | number | m³/h | Last `data.flow` value (after conversion). |
| `flow.measured.upstream` | number | m³/h | Last measured-child reading at this position. |
| `flow.measured.atequipment` | number | m³/h | Same. |
| `flow.measured.downstream` | number | m³/h | Same. |
### Scalar keys
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
| Key | Type | Source | Notes |
|:---|:---|:---|:---|
| `running` | boolean | `m.running` | True between `_beginRun` and `_endRun`. |
| `pulse` | boolean | `m.pulse` | True only on the tick a pulse is emitted; false otherwise. |
| `bucketVol` | number (L) | `m.bucketVol` | Composite volume accumulated this run. |
| `bucketWeight` | number (kg) | `m.bucketWeight` | `bucketVol + emptyWeightBucket`. |
| `sumPuls` | number | `m.sumPuls` | Pulses emitted this run. |
| `pulsesRemaining` | number | `targetPuls - sumPuls` | Clamped to ≥ 0. |
| `m3PerPuls` | number (m³) | `m.m3PerPuls` | Volume per pulse; set in `_beginRun` from `predFlow / targetPuls`. |
| `m3PerPulse` | number (m³) | (alias of `m3PerPuls`) | Both keys emitted; kept for legacy consumers. |
| `m3Total` | number (m³) | `m.m3Total` | Integrated total flow this run. |
| `q` | number (m³/h) | `m.q` | Effective flow (`getEffectiveFlow`). |
| `predFlow` | number (m³) | `m.predFlow` | Predicted total volume over the run window. |
| `predM3PerSec` | number (m³/s) | `m.predM3PerSec` | Predicted average rate during the run. |
| `predictedRateM3h` | number (m³/h) | `params.getPredictedFlowRate` | Rain-scaled flow band between `nominalFlowMin` and `flowMax`. |
| `timePassed` | number (s) | `m.timePassed` | Seconds since `start_time`. |
| `timeLeft` | number (s) | `m.timeLeft` | Seconds remaining until `stop_time`. |
| `pulseFraction` | number | `m.temp_pulse` | Sub-pulse integrator value (0..1+). |
| `flowToNextPulseM3` | number (m³) | derived | Volume left to integrate before the next pulse trigger. |
| `timeToNextPulseSec` | number (s) | derived | ETA to next pulse at current `q`; 0 when `q=0`. |
| `targetVolumeM3` | number (m³) | derived from `targetVolume` (L) | Target composite volume converted to m³. |
| `targetProgressPct` | number | derived | `bucketVol / targetVolume × 100`, 2-dp. |
| `targetDeltaL` | number (L) | derived | Signed L difference vs `targetVolume`. |
| `targetDeltaM3` | number (m³) | derived | Same in m³, 4-dp. |
| `nextDate` | number (epoch ms) | `m.nextDate` | Next scheduled START_DATE for `aquonSampleName`. `null` if never set. |
| `daysPerYear` | number | `m.daysPerYear` | Count of remaining scheduled runs this calendar year. |
| `sumRain` | number | `m.rainAggregator.sumRain` | Probability-weighted hourly precipitation sum. |
| `avgRain` | number | `m.rainAggregator.avgRain` | `sumRain / numberOfLocations`. |
| `nominalFlowMin` | number (m³/h) | config | Lower band for prediction. |
| `flowMax` | number (m³/h) | config | Upper band for prediction. |
| `minVolume` | number (L) | config | Lower bucket bound. |
| `maxVolume` | number (L) | derived | `maxWeight - emptyWeightBucket`. |
| `invalidFlowBounds` | boolean | derived | True when `nominalFlowMin >= flowMax`. |
| `missedSamples` | number | `m.missedSamples` | Pulse attempts blocked by cooldown. |
| `sampleCooldownMs` | number (ms) | derived | Ms remaining on the active cooldown; 0 when none. |
| `minSampleIntervalSec` | number (s) | config | Cooldown window. |
<!-- END AUTOGEN -->
### Status badge
`buildStatusBadge` in `io/statusBadge.js`:
| Condition | Badge | Fill | Shape |
|:---|:---|:---|:---|
| `invalidFlowBounds=true` | `Config error: nominalFlowMin (…) >= flowMax (…)` | red | (error preset) |
| `running=true` + `sampleCooldownMs > 0` | `SAMPLING (Ns) · <bucketVol>/<maxVolume> L` | yellow | ring |
| `running=true` + cooldown clear | `AI: RUNNING · <bucketVol>/<maxVolume> L` | green | dot |
| idle | `AI: IDLE` | grey | (idle preset) |
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/monster.json` plus `nodeClass.buildDomainConfig`.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `"Monster Configuration"` | Free-text. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
| Default unit | `general.unit` | `unitless` | Not used by the sampling program. |
| Enable logging | `general.logging.enabled` | `true` | Master switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| (hidden) | `functionality.softwareType` | `monster` | Constant. |
| (hidden) | `functionality.role` | `samplingCabinet` | Constant. |
| AQUON sample name | `functionality.aquonSampleName` | _unset_ | Forwarded to `source.aquonSampleName` in `extraSetup`. Falls back to `'112100'` in `_initState`. |
### Asset (`config.asset`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Asset UUID | `asset.uuid` | `null` | Globally-unique identifier. |
| Geolocation | `asset.geoLocation` | `{x:0, y:0, z:0}` | |
| Supplier | `asset.supplier` | `"Unknown"` | Schema only &mdash; not used by the sampling program. |
| Type | `asset.type` | `"sensor"` (enum) | Schema only. |
| SubType | `asset.subType` | `"pressure"` | Schema only; misleading default for a sampling cabinet (flag). |
| Model | `asset.model` | `"Unknown"` | Schema only. |
| Empty bucket weight (kg) | `asset.emptyWeightBucket` | `3` | Used in `bucketWeight = bucketVol + emptyWeightBucket` and in `maxVolume = maxWeight - emptyWeightBucket`. |
### Constraints (`config.constraints`)
| Form field | Config key | Default | Range | Notes |
|:---|:---|:---|:---|:---|
| Sampling time (hr) | `constraints.samplingtime` | `0` | ≥ 0 | Run length. Used as `samplingtime · 3600 · 1000` ms for `stop_time`. |
| Sampling period (hr) | `constraints.samplingperiod` | `24` | ≥ 1 | Documented as the fixed composite-collection period; not enforced by `samplingProgram` &mdash; the AQUON schedule arms the run. |
| Min volume (L) | `constraints.minVolume` | `5` | ≥ 5 | Used in `targetVolume = minVolume · √(maxVolume / minVolume)`. |
| Max weight (kg) | `constraints.maxWeight` | `23` | ≤ 23 | Bucket-overload bound; `maxVolume = maxWeight - emptyWeightBucket`. |
| Sub-sample volume (mL) | `constraints.subSampleVolume` | `50` | fixed | Schema enforces `min=max=50`. Hard-coded as `volume_pulse = 0.05` in domain. |
| Storage temperature (°C) | `constraints.storageTemperature.min/max` | `{1, 5}` | per-leg | Schema only &mdash; informational. |
| Flowmeter present | `constraints.flowmeter` | `true` | bool | ⚠️ In schema but **not** wired in `buildDomainConfig`. Effectively always `true`. |
| Closed system | `constraints.closedSystem` | `false` | bool | Schema only. |
| Intake speed (m/s) | `constraints.intakeSpeed` | `0.3` | ≥ 0 | Schema only &mdash; informational. |
| Intake diameter (mm) | `constraints.intakeDiameter` | `12` | ≥ 0 | Schema only &mdash; informational. |
| Nominal flow min (m³/h) | `constraints.nominalFlowMin` | `0` | ≥ 0 | Lower bound of rain-driven flow prediction. |
| Flow max (m³/h) | `constraints.flowMax` | `0` | ≥ 0 | Upper bound of rain-driven flow prediction. |
| Max rain reference | `constraints.maxRainRef` | `10` | > 0 | Rain index that maps to `flowMax`. |
| Min sample interval (s) | `constraints.minSampleIntervalSec` | `60` | ≥ 0 | Cooldown guard. |
> [!WARNING]
> **Default `flowMax = 0` blocks every run.** `validateFlowBounds` requires `0 ≤ nominalFlowMin < flowMax`. Out of the box the bounds are invalid and `_beginRun` never fires. Set `flowMax` to a realistic upper bound before deploying.
### Unit policy
monster has **no `requireUnitForTypes` policy** declared in `specificClass`. Conversions happen at the boundary:
| Quantity | Canonical (internal) | Carried as | Notes |
|:---|:---|:---|:---|
| Flow (`data.flow`) | m³/h | m³/h | `handlers.dataFlow` converts inbound via `convert(value).from(unit).to('m3/h')`. |
| Flow (measured-child) | as supplied | as supplied | `flowTracker.handleMeasuredFlow` defaults to `'m3/h'` when `unit` is missing; **does not convert**. Wire children that emit in m³/h. |
| Volume (`bucketVol`) | L | L | Output also exposes m³ derivations (`targetVolumeM3`, `targetDeltaM3`). |
| Weight | kg | kg | |
| Time | s (timers) / ms (timestamps) | mixed | `timePassed` / `timeLeft` in s, `nextDate` in epoch ms. |
---
## Child registration
Source: `src/specificClass.js` `_wireMeasurementChild`. The registrar subscribes to all three `flow.measured.<position>` events on the child's measurement emitter as long as the child's `config.asset.type` is `'flow'` or unset.
| Software type | Filter | Wired to | Side-effect |
|:---|:---|:---|:---|
| `measurement` | `asset.type='flow'` (or missing) | `flowTracker.handleMeasuredFlow` (handles all three positions) | Latches latest measured flow per position; `getEffectiveFlow` blends across positions and with `manualFlow`. |
| `measurement` | `asset.type` anything else | _ignored_ | The branch returns early; no listener is attached. |
monster has **no position-based filtering**. Unlike `rotatingMachine` (which routes upstream pressure separately from downstream), all three flow positions are wired to the same handler and the latest value per position wins.
There are **no auto-registered virtual children** (no `dashboard-sim-*` equivalent). Inject simulated flow via `data.flow` instead.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, sampling-program loop, prediction + cooldown pipeline |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

156
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,156 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-cd185dc-blue)
> [!NOTE]
> Every example flow shipped under `nodes/monster/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/monster/examples/`.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md`, `examples/README.md`, and current source only. Example flows have not been validated end-to-end on a live Docker instance.
---
## Shipped examples
| File | Tier | Dependencies | What it shows |
|:---|:---:|:---|:---|
| `basic.flow.json` | 1 | EVOLV only | Quick-start flow &mdash; manual flow inject, manual start, seeded `rain_data` and `monsternametijden`, debug taps on the three ports. **Status: pending validation.** |
| `02-integrated-e2e.json` | 2 | EVOLV only | End-to-end integration &mdash; measurement child wired in, schedule arming, sampling-run completion. **Status: pending validation.** |
Per `examples/README.md`, the following files are also referenced but **not currently in this directory** at `cd185dc`:
| Referenced filename | Status |
|:---|:---|
| `integration.flow.json` | Referenced in README; not present in `examples/` &mdash; possibly renamed to `02-integrated-e2e.json`. Flag for review. |
| `edge.flow.json` | Referenced in README; not present. Flag for review. |
| `monster-dashboard.flow.json` | Referenced in README + top-level `README.md`; not present. Flag for review. |
| `monster-api-dashboard.flow.json` | Referenced in README + top-level `README.md`; not present. Flag for review. The README warns that production use requires hardening of placeholder credentials (`__SET_*__`). |
> [!IMPORTANT]
> Reconcile the example flow inventory: either restore the four referenced flows from history, or update `examples/README.md` + the top-level `README.md` to remove dangling references. Tracked.
---
## Loading a flow
### Via the editor
1. Open the Node-RED editor at `http://localhost:1880`.
2. Menu &rarr; Import &rarr; drag the JSON file.
3. Click Deploy.
### Via the Admin API
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @"nodes/monster/examples/basic.flow.json" \
http://localhost:1880/flows
```
---
## Example 01 &mdash; Basic (`basic.flow.json`)
Single-cabinet flow with the minimum inputs to drive one sampling run. Pending live validation.
### Suggested click order after deploy
1. **Seed the rain forecast.** Click the `set.rain` inject. `sumRain` / `avgRain` populate; `predictedRateM3h` jumps from `nominalFlowMin` to a band-scaled value. (Skip this step and the prediction collapses to `nominalFlowMin`, which is `0` out of the box &mdash; the run will still start, but `predFlow` will be 0 and `m3PerPuls` will round to 0; see [Limitations](Reference-Limitations#mperpuls-can-round-to-zero).)
2. **Seed the schedule.** Click the `set.schedule` inject with an AQUON row array containing at least one `START_DATE` in the future for `aquonSampleName=112100` (the default).
3. **Push a manual flow.** Click `data.flow = {value: 240, unit: 'm3/h'}`.
4. **Start.** Click `cmd.start = true`. On the next tick `validateFlowBounds` runs &mdash; if `nominalFlowMin < flowMax`, `_beginRun` fires and `running` flips to `true`.
5. **Watch Port 0.** `pulse` blips to `true` for one tick on each integrated-volume threshold cross. `bucketVol` rises in 50 mL steps. `sumPuls` increments.
6. **Stop.** Either wait `samplingtime` hours for `_endRun`, or redeploy.
> [!IMPORTANT]
> **Screenshot needed.** Editor capture of `basic.flow.json` after step 4. Save as `wiki/_partial-screenshots/monster/basic-running.png`.
### Try the cooldown guard
After the first pulse:
1. Push a higher manual flow (`data.flow = {value: 1200, unit: 'm3/h'}`) so the integrator races past `m3PerPuls` again within `minSampleIntervalSec` (default 60 s).
2. Watch the log: `Sampling too fast. Cooldown active for Ns.`
3. `missedSamples` increments, `pulse` stays `false`, `sampleCooldownMs` ticks down to 0, then the next pulse fires.
---
## Example 02 &mdash; Integrated end-to-end (`02-integrated-e2e.json`)
> [!IMPORTANT]
> **Screenshot needed.** Editor capture of `02-integrated-e2e.json`. Save as `wiki/_partial-screenshots/monster/02-integrated.png`. Replace this callout with the image link.
One monster + one or more `measurement` flow children. Demonstrates:
- Auto-registration via Port 2 at deploy (each child's `child.register` reaches the monster).
- Measured + manual flow blend &mdash; `getEffectiveFlow` averages both when present.
- Schedule-armed sampling run &mdash; `nextDate` triggers `_beginRun` without an `i_start` click.
Detailed click order: pending review.
---
## Dashboard / API example flows
The `examples/README.md` references `monster-dashboard.flow.json` (FlowFuse charts for `pulse`, `bucket`, `predFlow`) and `monster-api-dashboard.flow.json` (Open-Meteo + AQUON SFTP + Grafana publish template with placeholder credentials). Neither file is present at `cd185dc`. See the inventory note at the top of this page.
When restored, the API flow MUST be hardened before production:
- Replace every `__SET_*__` placeholder with an env-backed secret.
- Pin the Open-Meteo endpoint and Aquon SFTP host to environment variables.
- Route the dashboard publish through `dashboardAPI`, not a hardcoded Grafana URL.
---
## Docker compose snippet
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
```yaml
# docker-compose.yml (extract)
services:
nodered:
build: ./docker/nodered
ports: ['1880:1880']
volumes:
- ./docker/nodered/data:/data/evolv
influxdb:
image: influxdb:2.7
ports: ['8086:8086']
```
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
---
## Debug recipes
| Symptom | First thing to check | Where to look |
|:---|:---|:---|
| `pulse` never fires | Is `running=true`? If not, check `validateFlowBounds` &mdash; the `Invalid flow bounds. nominalFlowMin=…, flowMax=…` warn means the default `flowMax=0` is in play. Set `nominalFlowMin < flowMax` in the editor. | `parameters/parameters.js → validateFlowBounds` |
| `pulse` never fires (bounds OK) | Is `m3PerPuls` zero? `_beginRun` rounds `predFlow / targetPuls` to nearest integer m³. Low `predFlow` rounds to 0; integrator divides by 0 → `temp_pulse = Infinity`, the `sumPuls < absMaxPuls` guard then runs out. | `sampling/samplingProgram.js → _beginRun` |
| Pulses arrive too fast / skipped | Cooldown guard active. Inspect `missedSamples` + `sampleCooldownMs`. Look for `Sampling too fast. Cooldown active for Ns.` in the log. | `sampling/samplingProgram.js → _maybeEmitPulse` |
| `q` always zero | Measured-flow child not registered, manual flow not pushed. Watch Port 2 + `data.flow` history. | `flow/flowTracker.js` |
| Measurement child ignored at register | Child's `config.asset.type` is set to something other than `"flow"` (e.g. `"flow-electromagnetic"`). monster only accepts `asset.type = "flow"` or unset. Set `asset.type: "flow"` on the measurement node. | `specificClass.js → _wireMeasurementChild` |
| Measured-flow values look 60&times; high or wrong unit | `flowTracker.handleMeasuredFlow` does **not** convert: it stores the value as-supplied (defaulting unit to `'m3/h'`). Wire children that already emit m³/h or fix at the source. | `flow/flowTracker.js → handleMeasuredFlow` |
| `nextDate` not arming | `set.schedule` payload didn't include any rows matching `aquonSampleName` with a future `START_DATE`. | `schedule/schedule.js → regNextDate` |
| `sumRain` stays zero with rain input | `set.rain` is ignored while `running=true` &mdash; only consumed between runs. Wait for `_endRun` or push the rain payload before `cmd.start`. | `specificClass.js → updateRainData` |
| `data.flow` payloads dropped silently | The handler requires `{value: <number>, unit: <string>}`. Bare numbers or missing `unit` log `data.flow payload must include numeric value and unit.` | `commands/handlers.js → dataFlow` |
| `predictedRateM3h` stays at `nominalFlowMin` | `set.rain` not sent, `lastRainUpdate` is 0, or > 2 hours have passed (`RAIN_STALE_MS`). Re-push the rain payload. | `parameters/parameters.js → getRainIndex` |
| `flowmeter=false` has no effect | `constraints.flowmeter` is in the schema but **not** wired in `buildDomainConfig`. The sampling program always runs in proportional mode. | `src/nodeClass.js → buildDomainConfig` |
| `set.mode` / `set.model-prediction` no-op | Handlers delegate to `source.setMode()` / `source.setModelPrediction()` which don't exist. These topics are reserved for future use. | `commands/handlers.js` |
| Status badge stuck at `Config error: …` | `nominalFlowMin >= flowMax`. Fix the bounds and redeploy. | `io/statusBadge.js` |
> Never ship `enableLog: 'debug'` in a demo &mdash; fills the container log within seconds and obscures real errors.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, sampling-program loop, prediction + cooldown pipeline |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where monster fits in a larger plant |

View File

@@ -0,0 +1,127 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-cd185dc-blue)
> [!NOTE]
> What `monster` does not do, current rough edges, and open questions. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only. Known limitations enumerated in `CONTRACT.md` Section 14 are propagated below; additional issues may surface during the planned audit.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| Ad-hoc single grab sample | Fire `cmd.start` once and tear it down &mdash; monster is overkill for one-off work. A `measurement` child with a one-shot inject is lighter. |
| Generic flow totaliser | `measurement` with the right type/variant, or a dedicated integrator function on the upstream parent. |
| Multi-constituent analyser (NH4, NO3, COD, TSS, …) | monster only emits **pulse + bucket-volume** events &mdash; it does **not** model analyte concentrations. The Home page framing ("multi-parameter biological process monitor") describes the **downstream** consumer of the composite sample, not the monster node's outputs. Pending review whether the framing should change or whether analyte fields should be added to Port 0. |
| Time-mode sampling (no flow source) | Not exercised. `constraints.flowmeter = false` exists in the schema but is **not** forwarded in `buildDomainConfig`; the sampling program always runs in flow-proportional mode. |
---
## Known limitations
### `m3PerPuls` can round to zero
`_beginRun` computes `m3PerPuls = Math.round(predFlow / targetPuls)`. With low `predFlow` (e.g. an unconfigured `flowMax`, a stale rain forecast, or a very short `samplingtime`) this rounds to 0. The integrator then divides by 0; `temp_pulse` becomes `Infinity`; the first iteration of `_maybeEmitPulse` ramps `sumPuls` up to `absMaxPuls` in one tick and stops. **Symptom: instant bucket fill, then idle.** Workaround: ensure `predFlow ≥ targetPuls` (typical `targetPuls` is 200), e.g. `flowMax ≥ 200 m³` or longer `samplingtime`. Tracked.
### Default `flowMax = 0` blocks every run
The schema defaults `nominalFlowMin = 0` and `flowMax = 0`. `validateFlowBounds` requires `min >= 0 && max > 0 && min < max`, so the default config is invalid and the run never starts. The status badge surfaces this as `Config error: nominalFlowMin (0) >= flowMax (0)`. Fix the bounds before deploying. Tracked.
### `set.rain` is dropped while `running=true`
`updateRainData` short-circuits when `running` is true &mdash; the rain forecast is only consumed between runs. During a long `samplingtime` (e.g. 24 h) the rain forecast goes stale silently. `getRainIndex` does enforce a 2-hour `RAIN_STALE_MS` cap on `avgRain`, but only **outside** a run; during a run the cached `predFlow` is whatever `_beginRun` froze. Open question.
### `set.mode` and `set.model-prediction` are reserved
Both handlers delegate to optional methods (`source.setMode()`, `source.setModelPrediction()`) that don't exist on `Monster`. Sending these topics is a no-op. They survive the registry only as forward-compatibility placeholders. Scheduled to either be implemented or removed in a later phase.
### `constraints.flowmeter` is not wired
The schema field exists with a `true` default, but `nodeClass.buildDomainConfig` does **not** forward it. The sampling program assumes a flow source unconditionally. Setting `flowmeter=false` in the editor has no runtime effect. Tracked.
### `subSampleVolume` is hard-coded at 50 mL
`volume_pulse = 0.05` in `_initSamplingDefaults` is a constant. The schema enforces `min=max=50` so the field is informational only. Bucket-and-target math (`minPuls`, `maxPuls`, `absMaxPuls`, `targetPuls`) all assume 50 mL. Changing this requires a coordinated change across `parameters.js`, the schema, and any consumer that decodes pulse counts back to volume. Tracked.
### Measured-flow handler does not convert units
`flowTracker.handleMeasuredFlow` reads `eventData.unit` but only uses it to label the stored measurement &mdash; the **value is not converted**. If a measurement child emits in `l/s`, monster stores the raw number and labels it `l/s`, but `getEffectiveFlow` is consumed as if it were m³/h. Wire children that already emit in m³/h, or set `default unit` on the child to `m3/h`. Tracked.
### Edge test `sampling-guards.edge.test.js` cooldown-guard case fails
A pre-existing failure documented in `CONTRACT.md` Section 14. The cooldown skip increments `missedSamples` but the assertion expects different timing. Tracked.
### S88 colour cleanup pending
The editor node colour in `monster.html` is `#4f8582` (teal). The correct Unit-level S88 colour is `#50a8d9`. The Mermaid diagrams in this wiki already use the correct colour. The editor node tile must be updated separately. Tracked in `.claude/rules/node-red-flow-layout.md` Section 16.
### No formal FSM
Unlike `rotatingMachine` (idle → starting → warmingup → operational → …) monster only has the `running` boolean. There is no protected-state guarantee, no abort token, no sequence registry. Cooldown and bounds are the only safety guards. If a future requirement needs e.g. a `pausing` / `paused` state, the sampling program needs to be lifted onto the platform's `state` module. Open question.
### No multi-parent registration audit
`childRegistrationUtils.registerChild` runs unconditionally. Whether monster can be safely wired under multiple parents (e.g. one reactor and one settler), and whether teardown ordering is correct, has not been audited. Open question.
### Multi-constituent reporting absent
The node's framing in the project memory ("multi-parameter biological process monitor &mdash; surrogate for a benchtop analyser") suggests monster reports NH4 / NO3 / COD / TSS / etc. **The current implementation does not.** monster emits volumetric pulse + bucket state only. Lab analyte values are produced by a separate downstream consumer that ingests the physical composite sample collected on the monster's pulses. Either:
- the framing should be tightened to "composite sampler / sampling-cabinet controller", or
- analyte fields (with timestamps and per-pulse correlations) should be added to Port 0.
Pending decision-gate review.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Should `set.rain` updates apply mid-run (best-effort `predFlow` re-compute) or stay run-frozen? | Internal &mdash; not yet ticketed |
| Implement or remove `set.mode` + `set.model-prediction` placeholders | Internal |
| Wire `constraints.flowmeter` and exercise the time-mode path | Internal |
| Add unit conversion to `flowTracker.handleMeasuredFlow` | Internal |
| Round-to-zero `m3PerPuls` &mdash; clamp to ≥ 1 or refuse to start? | Internal |
| Multi-parent registration safety audit | Internal |
| Add formal FSM (`idle` / `armed` / `running` / `cooldown`)? | Open |
| Add analyte / multi-constituent reporting to Port 0, or rephrase the node's framing | Decision-gate &mdash; pending |
| Restore or remove the dangling `monster-dashboard.flow.json` + `monster-api-dashboard.flow.json` references | Internal |
---
## Migration notes
### From legacy topic aliases
The pre-canonical topics (`i_start`, `monsternametijden`, `rain_data`, `input_q`, `setMode`, `model_prediction`) are accepted as aliases and emit a one-time deprecation warning. They are scheduled for removal in Phase 7. Update flows to use the canonical names from [Reference &mdash; Contracts](Reference-Contracts#topic-contract):
| Legacy | Canonical |
|:---|:---|
| `i_start` | `cmd.start` |
| `monsternametijden` | `set.schedule` |
| `rain_data` | `set.rain` |
| `input_q` | `data.flow` |
| `setMode` | `set.mode` |
| `model_prediction` | `set.model-prediction` |
| `registerChild` | `child.register` |
### Pre-2026-05-11 invalid-bounds silent skip
Before 2026-05-11 `nominalFlowMin` / `flowMax` were not declared in `monster.json`'s `constraints` section. `configUtils` stripped them as unknown keys; bounds collapsed to undefined → NaN → invalid, silently blocking every run. Fixed in the schema; ensure your `monster.json` is at the current commit.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters (alias map at the top) |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, sampling-program loop, prediction + cooldown pipeline |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

21
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,21 @@
### monster
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [measurement wiki](https://gitea.wbd-rd.nl/RnD/measurement/wiki/Home)
- [reactor wiki](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home)
- [settler wiki](https://gitea.wbd-rd.nl/RnD/settler/wiki/Home)
- [dashboardAPI wiki](https://gitea.wbd-rd.nl/RnD/dashboardAPI/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)