From 244f4555ac452fb3dc8798af674c4b03d1dc5714 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 18:48:57 +0000 Subject: [PATCH] slice 20a.1: route ventilation through predicted_space_heating_kwh (v2.7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/domain/src/domain/ml/demand.py | 12 ++++-- .../domain/src/domain/ml/tests/test_demand.py | 43 +++++++++++++++++++ .../src/domain/ml/tests/test_transform.py | 2 +- packages/domain/src/domain/ml/transform.py | 3 +- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/domain/src/domain/ml/demand.py b/packages/domain/src/domain/ml/demand.py index 738eca74..867cfe16 100644 --- a/packages/domain/src/domain/ml/demand.py +++ b/packages/domain/src/domain/ml/demand.py @@ -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 diff --git a/packages/domain/src/domain/ml/tests/test_demand.py b/packages/domain/src/domain/ml/tests/test_demand.py index 2e6482d0..05206d6e 100644 --- a/packages/domain/src/domain/ml/tests/test_demand.py +++ b/packages/domain/src/domain/ml/tests/test_demand.py @@ -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. diff --git a/packages/domain/src/domain/ml/tests/test_transform.py b/packages/domain/src/domain/ml/tests/test_transform.py index 3296d873..aac4bb11 100644 --- a/packages/domain/src/domain/ml/tests/test_transform.py +++ b/packages/domain/src/domain/ml/tests/test_transform.py @@ -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(): diff --git a/packages/domain/src/domain/ml/transform.py b/packages/domain/src/domain/ml/transform.py index 51569e87..b4f4ae90 100644 --- a/packages/domain/src/domain/ml/transform.py +++ b/packages/domain/src/domain/ml/transform.py @@ -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")