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>
This commit is contained in:
245
wiki/Reference-Architecture.md
Normal file
245
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user