slice 20a.1: route ventilation through predicted_space_heating_kwh (v2.7.1)

v20a added ventilation_heat_loss_w_per_k as a standalone feature but never
connected it to the HLC inside predicted_space_heating_kwh, so the
downstream physics aggregates (predicted_ecf, predicted_total_fuel_cost,
predicted_log10_ecf — the top-10 model features) never saw the
infiltration signal. Importance for ventilation_heat_loss_w_per_k was rank
58/196 (importance 30) vs envelope's rank 21 (86).

Adds the ventilation column to the envelope-conduction HLC before
applying HDH and efficiency, so chimney + draught-proofing signals flow
through the physics aggregates the model actually uses. Default 0 keeps
backwards compatibility.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 18:48:57 +00:00
parent 4d838bb03c
commit 244f4555ac
4 changed files with 54 additions and 6 deletions

View file

@ -70,17 +70,21 @@ def predicted_space_heating_kwh(
envelope_heat_loss_w_per_k: float,
region_code: Optional[str],
seasonal_efficiency_main: float,
ventilation_heat_loss_w_per_k: float = 0.0,
) -> float:
"""Annual delivered space-heating kWh.
delivered_kWh = HLC * HDH_region * 1e-3 / efficiency
where HLC is W/K, HDH is K*hours/year (so the product is Wh, /1000 = kWh,
/efficiency converts useful demand to delivered fuel).
where HLC = envelope (conduction + bridging) + ventilation (infiltration),
HDH is K*hours/year. SAP10.2 §5 routes both losses through the same demand
pipeline. Ventilation defaults to 0 for back-compat with pre-slice-20a
callers.
"""
if envelope_heat_loss_w_per_k <= 0 or seasonal_efficiency_main <= 0:
hlc = envelope_heat_loss_w_per_k + ventilation_heat_loss_w_per_k
if hlc <= 0 or seasonal_efficiency_main <= 0:
return 0.0
hdh = _hdh_for_region(region_code)
useful_kwh = envelope_heat_loss_w_per_k * hdh * 1e-3
useful_kwh = hlc * hdh * 1e-3
return useful_kwh / seasonal_efficiency_main

View file

@ -35,6 +35,49 @@ def test_predicted_space_heating_falls_back_to_uk_average_when_region_unknown()
assert result > 0.0
def test_predicted_space_heating_adds_ventilation_to_envelope_hlc() -> None:
# Arrange — SAP10.2 HLC = envelope (conduction) + ventilation (infiltration);
# demand scales with HLC, so adding 50 W/K of ventilation to a 200 W/K
# envelope should give the same kWh as a 250 W/K envelope alone.
# Act
combined = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=200.0,
region_code="1",
seasonal_efficiency_main=0.84,
ventilation_heat_loss_w_per_k=50.0,
)
equivalent = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=250.0,
region_code="1",
seasonal_efficiency_main=0.84,
)
# Assert
assert combined == pytest.approx(equivalent, rel=0.001)
def test_predicted_space_heating_default_ventilation_zero_preserves_envelope_only_behaviour() -> None:
# Arrange — back-compat: callers that don't pass ventilation get the
# original envelope-only result.
# Act
envelope_only = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=200.0,
region_code="1",
seasonal_efficiency_main=0.84,
)
explicit_zero = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=200.0,
region_code="1",
seasonal_efficiency_main=0.84,
ventilation_heat_loss_w_per_k=0.0,
)
# Assert
assert envelope_only == pytest.approx(explicit_zero, rel=0.001)
def test_predicted_space_heating_scotland_higher_than_thames() -> None:
# Arrange — same HLC, same efficiency; Scotland's HDH > Thames's.

View file

@ -36,7 +36,7 @@ def test_transform_advertises_version_and_target_columns() -> None:
# Assert
assert isinstance(schema, TransformSchema)
assert schema.transform_version == "2.7.0"
assert schema.transform_version == "2.7.1"
assert schema.transform_version == EpcMlTransform.VERSION
assert set(schema.target_columns.keys()) == set(_EXPECTED_TARGET_DTYPES.keys())
for target_name, expected_dtype in _EXPECTED_TARGET_DTYPES.items():

View file

@ -913,7 +913,7 @@ class EpcMlTransform:
Version 0.1.0 schema contract only; feature columns added in subsequent slices.
"""
VERSION: str = "2.7.0"
VERSION: str = "2.7.1"
def schema(self) -> TransformSchema:
"""The cross-repo ML data contract.
@ -999,6 +999,7 @@ class EpcMlTransform:
envelope_heat_loss_w_per_k=envelope_w_per_k,
region_code=epc.region_code,
seasonal_efficiency_main=space_eff,
ventilation_heat_loss_w_per_k=ventilation_w_per_k,
)
cylinder_size_val = heating_aggregates.get("cylinder_size")
cylinder_ins_thk = heating_aggregates.get("cylinder_insulation_thickness_mm")