Forward Ventilation / Cooling Mode¶
Increment 2 of the equipment-realism milestone (after the forward heating mode and the calibration-forcing reconciliation). Adds ventilation as a second closed-loop, capacity-limited actuator so the forward simulation mode closes the thermal loop in both directions — heat below a heating setpoint, ventilate above a ventilation setpoint — plus humidity-driven venting.
The actuator¶
control.VentilationActuator stages natural roof vents into forced fans:
- Two PI demands: a temperature demand (
air_temp − ventilation_setpoint) and a humidity demand (RH − max-RH setpoint), each gated so ventilation is never counterproductive — cooling only when outside is cooler than inside, dehumidifying only when outside is drier (in absolute vapour pressure). A gated demand freezes its PI integral (no windup). - The larger demand drives a split-range command in
[0, 2]:[0, 1]opens the roof (stage 1, free),[1, 2]ramps the fan (stage 2, electric). Command2= fully open and fan maxed while a setpoint is still exceeded → saturated (the honest "can't cool below outside / can't dehumidify enough" signal, mirroring heating capacity saturation).
Its outputs override roof_opening and forced_ventilation_control on the unit inputs.
Composition with heating (the deadband)¶
simulate_forward evaluates both actuators each step (ventilation via optional
ventilation_actuator / ventilation_setpoint / humidity_setpoint kwargs; omit them for
the increment-1 heating-only behaviour). The deadband emerges from separate setpoints:
below heating_setpoint the heating PI demands heat and the vent temperature-error is
negative (command 0); above ventilation_setpoint the vent PI opens and heating demand is
0; between the two, both sit at ~0. Humidity can vent inside the deadband independently.
Physics wiring (byte-stable)¶
The greenhouse physics already computed forced ventilation
(flows.heat_and_vapour.ventilation_heat_and_vapour(forced_*)); it was just not exposed by
the unit assembly. Two additive fields wire it: GreenhouseUnitParameters.forced_ventilation_flow_m3_s
(fan rated flow, default 0) and GreenhouseUnitInputs.forced_ventilation_control (0..1,
default 0). With both 0 the forced term is exactly 0, so the replay is byte-identical —
guarded by tests/test_replay_byte_stability.py and tests/test_ivp_parity.py.
⚠ Ventilation is stiff — use the IVP solver¶
Natural ventilation at a large inside–outside ΔT removes a very large heat flux (hundreds of
W/m² with the roof fully open). Under a 300 s explicit-Euler step this overshoots — the
air temperature can jump past the outside temperature in a single step and oscillate. Run
the forward mode with solver="ivp" (the adaptive integrator, the default) whenever
ventilation is active, or use a much smaller dt_s. The fixed-step path remains available
for heating-only runs.
Consumption ledger¶
ForwardConsumptionLedger gained: fan_electricity_kwh_m2 (natural venting is free — the
fan draws power via its specific_power_w_per_m3_s), ventilation_open_hours,
ventilation_hours_at_capacity. The heating field hours_at_capacity was renamed
heating_hours_at_capacity. ForwardResult gained ventilation_saturated_step_count.
Validation¶
examples/validate_forward_ventilation.py runs the forward mode with both actuators over
the warmest strided HPS window (mean indoor ≈ 25 °C) using synthetic setpoints
(heating 18 °C / ventilation 24 °C / max 85 % RH) — synthetic on purpose, to avoid the
increment-1 finding that chasing the measured temperature over-attributes flux.
metric (warmest HPS window, solver="ivp") |
value |
|---|---|
| fraction of steps in-band [18, 24] °C | 0.92 |
| air temp min / mean / max | 12.9 / 20.2 / 25.8 °C |
| ventilation open | 0.50 h |
| fan electricity | 0.009 kWh/m² |
| heating gas | 0.101 kWh/m² |
| vent-saturated steps | 5 |
The combined heating + ventilation controller holds the greenhouse 92 % of the time inside the [18, 24] °C band on a warm day, venting (mostly natural, minimal fan) to shed heat and heating overnight — realistic behaviour with grounded consumptions.
Run: PYTHONPATH=. uv run python examples/validate_forward_ventilation.py.
Next increment (same pattern)¶
Closed-loop CO2 dosing (and, with it, the deferred CO2 signal-shaping reconciliation, which
needs a stateful builder input schedule); on/off-hysteresis control; a project-run
--mode forward CLI flag.