Skip to content

Forward CO2 Dosing Mode

Increment 3 of the equipment-realism milestone (after forward heating, the calibration-forcing reconciliation, and forward ventilation/cooling). Adds closed-loop CO2 enrichment as a third capacity-limited actuator so the forward simulation mode controls the full day-climate trio — temperature (heat + ventilate), humidity (ventilate), and CO2.

Why forward CO2 is meaningful (unlike replay)

The GreenLight replay CO2 injection signal is a data-limited diagnostic — a binary dosing status, off ~97 % of the time, carrying no flux information (see the CO2 memory notes). A forward setpoint-tracking doser is therefore a genuine design-study capability, not a reproduction of an uninformative signal.

The actuator

control.CO2Actuator tracks a target CO2 concentration (ppm) with a PI controller:

  • error = co2_setpoint_ppm − current_co2_ppm (global air CO2) → PI → dose control [0,1].
  • Ventilation coupling: control *= max(0, 1 − ventilation_dose_reduction · roof_opening) — the dose is cut as the vents open, because dosing into open vents wastes gas (realistic climate-computer behaviour).
  • The control drives an existing CO2 source (CO2Tank or FlueGasCO2Source, reused via dose(control)), which clamps to [0,1] so capacity is always respected. Under-supply is surfaced as saturated.

Its output overrides co2_injection_mg_m2_s on the unit inputs. No physics change — that input was already consumed by both the explicit and ODE assemblies, so the replay is byte-identical (the byte-stability guard stays green).

Day/night without light gating

Enrichment "only during the day" falls out of the setpoint: DayNightCO2Setpoint(day_ppm=800, night_ppm=420) — at night the setpoint is ambient, so the PI demands ~0 and no gas is dosed. No explicit light gate needed.

Composition in the forward loop

simulate_forward evaluates the CO2 actuator after the ventilation actuator (it reads this step's roof_opening for the dose reduction) and overrides co2_injection_mg_m2_s. co2_actuator / co2_setpoint are optional (default None ⇒ no dosing; prior heating(+ventilation) runs are unchanged). Loop order: heating → ventilation → CO2.

Consumption ledger

ForwardConsumptionLedger gained co2_dosed_kg_m2 (CO2 mass injected), co2_source_fuel_kwh_m2 (flue-gas source fuel — 0 for a tank; distinct from the boiler's fuel_gas_kwh_m2), and co2_dosing_hours. ForwardResult gained co2_saturated_step_count.

Validation

examples/validate_forward_co2.py runs heating + ventilation + CO2 over the warmest strided HPS window (mean indoor ≈ 25 °C) with synthetic setpoints (heat 18 °C / vent 24 °C / 85 % RH / CO2 day 800 / night 420 ppm), solver="ivp", a CO2Tank source with ventilation_dose_reduction=1.0.

metric (warmest HPS window) value
daytime steps with CO2 within ±100 ppm of 800 0.95
CO2 ppm min / mean / max 361 / 622 / 799
CO2 dosed 0.035 kg/m²
CO2 source fuel (tank) 0.000 kWh/m²
dosing hours 18.4

The doser holds daytime CO2 at ~800 ppm 95 % of the time, never overshooting the target, and correctly falls back to ambient at night (no enrichment). Realistic behaviour with a grounded CO2 consumption — the design-study capability the replay's uninformative signal cannot provide.

Run: PYTHONPATH=. uv run python examples/validate_forward_co2.py.

Not in this increment

This adds forward closed-loop dosing. It does not close the deferred replay CO2 signal-shaping reconciliation into project-run (co2_injection_timing_blend, co2_pulse_* via the stateful CO2DosingMemory) — that is an independent follow-up needing a stateful builder input schedule. Other follow-ups: on/off-hysteresis; a project-run --mode forward CLI flag; local/sensor-blend CO2 control (this doser acts on global air CO2).