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>
246 lines
11 KiB
Markdown
246 lines
11 KiB
Markdown
# Reference — Architecture
|
|
|
|

|
|
|
|
> [!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 — 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() — every 1000 ms]
|
|
tick --> q[q = flowTracker.getEffectiveFlow()]
|
|
q --> fc[flowCalc()<br/>m3PerTick = q/3600 * dt]
|
|
fc --> sp[samplingProgram()]
|
|
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 > now?}
|
|
begin --> active
|
|
active -- yes --> integ[temp_pulse += m3PerTick / m3PerPuls<br/>m3Total += m3PerTick]
|
|
integ --> pulse[_maybeEmitPulse]
|
|
pulse --> emit{temp_pulse ≥ 1<br/>AND sumPuls < 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 — `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 — 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`** — 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 — both sources contribute equally when both are present. Contrast with `rotatingMachine`'s `pressureSelector` which prefers real over virtual children.
|
|
|
|
---
|
|
|
|
## Lifecycle — 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 — `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 — `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 — the latest value per position wins).
|
|
|
|
See [EVOLV — 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`) — 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 — Contracts](Reference-Contracts) | Topic + config + child filters |
|
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
|
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
|
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|