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>
11 KiB
Reference — Architecture
Note
Code structure for
monster: the three-tier sandwich, thesrc/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.
Note
Pending full node review (2026-05). Content reflects
CONTRACT.mdand 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:
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 insideminSampleIntervalSecand incrementsmissedSamples(without rolling the integrator back —temp_pulseis clamped to1so subsequent ticks land on the same threshold once the cooldown clears). _beginRunroundsm3PerPuls = round(predFlow / targetPuls). With lowpredFlowthis can round to 0; the integrator then divides by zero andtemp_pulsebecomesInfinity. Tracked — see Limitations.subSampleVolumeis hard-coded at 50 mL viavolume_pulse = 0.05. The schema enforcesmin=max=50so the field is informational only.set.rainupdates are skipped whilerunning=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
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 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 | Intuitive overview |
| Reference — Contracts | Topic + config + child filters |
| Reference — Examples | Shipped flows + debug recipes |
| Reference — Limitations | Known issues and open questions |
| EVOLV — Architecture | Platform-wide three-tier pattern |