Skip to content

Forward Heating Mode

Increment 1 of the equipment-realism milestone: a forward/predictive simulation mode where a capacity-limited, PI-controlled boiler heats the greenhouse toward a temperature setpoint and reports realistic fuel consumption. The byte-stable measured-replay path and both existing integrators are untouched.

What it is (vs replay)

Replay (measured_replay*) Forward mode
Heating input replayed measured pipe-temperature proxy flux PI controller → boiler with rated capacity + efficiency
Control none (open-loop replay of measured actuators) closed-loop on a temperature setpoint
Fuel accounting none gas = delivered / efficiency, per step
Capacity limit none (unbounded flux) rated capacity; saturation reported
Use reproduce a measured dataset design studies (size a boiler, test a setpoint schedule)

The forward mode controls only heating in closed loop; every other input (weather, lighting, CO2, ventilation, screens) comes from the same forcing the replay uses.

Architecture

  • greenflux.control — new layer:
  • PIControllerParameters / PIControllerState / pi_control_step — generic PI with conditional-integration anti-windup.
  • HeatingActuator — wraps the existing equipment.heating.Boiler (per-m² capacity + efficiency + unmet), driven by the PI controller, optional ramp limit.
  • ConstantSetpoint / DayNightSetpoint / MeasuredSetpoint — setpoint providers.
  • greenflux.simulators.forward.simulate_forward(...) — outer control loop. Each control interval computes the heating flux once from the interval-start state, then advances the same GreenhouseUnit physics over the interval with that flux held constant (zero-order hold) via solver="ivp" (default) or solver="fixed". The PI integral lives in the driver, never in the frozen ODE state vector, so the mode composes with both integrators without touching parity. Returns a ForwardResult with a state trajectory, a ForwardConsumptionLedger (heat_delivered_kwh_m2, fuel_gas_kwh_m2, hours_at_capacity), and saturated_step_count.
  • projects.schema.HeatingActuatorDefinition on EquipmentDefinition — declarative (rated_capacity_w_m2, efficiency, proportional_gain_w_m2_per_k, integral_gain_w_m2_per_k_s, ramp_limit_w_m2_s). Optional with a default, so existing project.json files load unchanged. .to_actuator() builds a HeatingActuator.

Under-capacity is reported via saturation (hours_at_capacity, saturated_step_count) and shows up as a persistent setpoint-tracking error. The boiler-level "unmet" is not a separate ledger field: because the PI anti-windup bound equals the rated capacity, the boiler never receives a request above capacity, so its unmet term is always zero (a real kWh-deficit measure can return with a multi-actuator / heat-pump path).

Running the validation harness

PYTHONPATH=. uv run python examples/validate_forward_heating.py

It drives the forward mode with the measured indoor temperature as the setpoint over the 12 strided HPS windows, sizes the boiler from the replay's peak delivered heat (×1.2), and prints a comparison table (also written to outputs/forward/validate_forward_heating.json).

Validation results and honest caveats

Measured run (HPS, 12 strided windows, measured_replay_physical):

metric value
replay air-temp RMSE 2.30 °C
forward air-temp RMSE 0.79 °C (tracks the measured setpoint tightly)
replay heat (1 day) 0.64 kWh/m²
forward heat (1 day) 0.93 kWh/m² (+46 %)
forward gas (~200-day season) ~207 kWh/m²
avg hours at capacity (of 24 h) 12.7

The forward driver, controller, actuator, ledger and schema are correct and unit-tested. The over-delivery and heavy saturation above are limitations of this particular validation setup, not of the mode:

  1. Heating-only actuator chasing the full measured temperature. The measured trajectory is the result of all fluxes (heating + solar + ventilation). A heating-only boiler forced to follow it can only add heat, never remove it, so it over-heats on every solar/ventilation-driven rise and saturates. Tracking to 0.79 °C therefore comes at the cost of ~46 % excess heat.
  2. Two calibration-application paths. The harness builds the unit via the builder path (_apply_profile_to_project), while the replay applies the profile through the analysis path (per-input multipliers). The two units are not identical, so the heat comparison is not perfectly apples-to-apples.
  3. Auto-sized aggressive PI. kp = rated/2 saturates on any error > ~2 °C; morning warm-up and cold nights exceed that often.

For its actual purpose — forward design studies driven by a real setpoint schedule (e.g. DayNightSetpoint(19 °C / 17 °C)) — the mode behaves correctly: temperature converges to the setpoint within the boiler's capacity, and fuel/hours-at-capacity are accounted against the rated equipment. Reproducing a measured dataset's exact energy is the replay's job, not the forward mode's.

Next increments (same pattern)

CO2 / lighting / ventilation / heat-pump as closed-loop capacity-limited actuators; a project-run --mode forward CLI flag; on/off-hysteresis control; active cooling. Each reuses the actuator + controller + ledger pattern established here.