# Reference — 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 — 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()
m3PerTick = q/3600 * dt] fc --> sp[samplingProgram()] sp --> trig{i_start OR
now ≥ nextDate?
AND NOT running} trig -- yes --> vb{validateFlowBounds?} vb -- no --> done[return — running stays false] vb -- yes --> begin[_beginRun
m3PerPuls = predFlow / targetPuls
stop_time = now + samplingtime·h] trig -- no --> active{stop_time > now?} begin --> active active -- yes --> integ[temp_pulse += m3PerTick / m3PerPuls
m3Total += m3PerTick] integ --> pulse[_maybeEmitPulse] pulse --> emit{temp_pulse ≥ 1
AND sumPuls < absMaxPuls?} emit -- no --> notify[notifyOutputChanged] emit -- yes --> cd{cooldown
blocked?} cd -- yes --> miss[missedSamples++
warn one-shot] miss --> notify cd -- no --> fire[temp_pulse -= 1
pulse = true
sumPuls++
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. 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 `` 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.` | `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 |