Skip to content

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). Command 2 = 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.