diff --git a/packages/domain/src/domain/ml/tests/test_transform.py b/packages/domain/src/domain/ml/tests/test_transform.py index e1c3c83f..3296d873 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.6.0" + assert schema.transform_version == "2.7.0" 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/tests/test_ventilation.py b/packages/domain/src/domain/ml/tests/test_ventilation.py new file mode 100644 index 00000000..55673302 --- /dev/null +++ b/packages/domain/src/domain/ml/tests/test_ventilation.py @@ -0,0 +1,149 @@ +"""Tests for ventilation_heat_loss_w_per_k. + +SAP10.2 §C + RdSAP10 §5 / Table 5: air change rate per hour (ACH) from +structural infiltration + openings minus draught-proofing reduction, then +W/K = ACH * volume_m3 * 0.33. Volume is total floor area * average storey +height. Open chimneys, blocked chimneys, and flueless gas fires contribute +fixed m³/h additions; draught-proofed windows reduce the structural baseline. +""" + +import pytest + +from domain.ml.ventilation import ventilation_heat_loss_w_per_k + + +def test_ventilation_bare_masonry_no_openings_returns_structural_baseline_only() -> None: + # Arrange — 80 m² × 2.5 m = 200 m³, masonry struct ACH = 0.35, + # 100% draught-proofed windows -> 0.05 reduction => 0.30 ACH total. + # 0.30 * 200 * 0.33 = 19.8 W/K. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=80.0, + avg_room_height_m=2.5, + is_timber_frame=False, + open_chimneys_count=0, + window_pct_draught_proofed=100.0, + ) + + # Assert + assert result == pytest.approx(19.8, abs=0.5) + + +def test_ventilation_timber_frame_no_openings_lower_struct_baseline() -> None: + # Arrange — same volume but timber frame -> struct ACH = 0.25. + # 100% DP -> 0.20 ACH; 0.20 * 200 * 0.33 = 13.2 W/K. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=80.0, + avg_room_height_m=2.5, + is_timber_frame=True, + open_chimneys_count=0, + window_pct_draught_proofed=100.0, + ) + + # Assert + assert result == pytest.approx(13.2, abs=0.5) + + +def test_ventilation_open_chimney_adds_40_m3_per_h() -> None: + # Arrange — masonry, 100% DP (0.30 base ACH), one open chimney adds 40 m³/h. + # In 200 m³ volume that's 0.20 ACH, so total = 0.50. + # 0.50 * 200 * 0.33 = 33.0 W/K. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=80.0, + avg_room_height_m=2.5, + is_timber_frame=False, + open_chimneys_count=1, + window_pct_draught_proofed=100.0, + ) + + # Assert + assert result == pytest.approx(33.0, abs=0.5) + + +def test_ventilation_no_draught_proofing_raises_structural_share() -> None: + # Arrange — masonry, 0% DP -> 0.35 ACH unreduced; no openings. + # 0.35 * 200 * 0.33 = 23.1 W/K. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=80.0, + avg_room_height_m=2.5, + is_timber_frame=False, + open_chimneys_count=0, + window_pct_draught_proofed=0.0, + ) + + # Assert + assert result == pytest.approx(23.1, abs=0.5) + + +def test_ventilation_heritage_home_with_two_chimneys_and_no_dp() -> None: + # Arrange — catastrophic heritage profile: TFA 100, room_h 2.5 (vol 250), + # masonry, 2 open chimneys (80 m³/h), 0% draught-proofed. + # struct=0.35, openings=80/250=0.32, total=0.67 ACH. + # 0.67 * 250 * 0.33 = 55.3 W/K. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=100.0, + avg_room_height_m=2.5, + is_timber_frame=False, + open_chimneys_count=2, + window_pct_draught_proofed=0.0, + ) + + # Assert + assert result == pytest.approx(55.3, abs=1.0) + + +def test_ventilation_returns_zero_when_floor_area_is_zero() -> None: + # Arrange — no volume, no air loss. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=0.0, + avg_room_height_m=2.5, + is_timber_frame=False, + open_chimneys_count=1, + window_pct_draught_proofed=0.0, + ) + + # Assert + assert result == 0.0 + + +def test_ventilation_handles_null_chimney_count_as_zero() -> None: + # Arrange — open_chimneys_count is None on ~70% of the corpus; treat as 0. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=80.0, + avg_room_height_m=2.5, + is_timber_frame=False, + open_chimneys_count=None, + window_pct_draught_proofed=100.0, + ) + + # Assert — same as zero-chimney case (19.8 W/K). + assert result == pytest.approx(19.8, abs=0.5) + + +def test_ventilation_handles_null_dp_share_as_zero() -> None: + # Arrange — null draught-proofing share treated as no draught-proofing. + + # Act + result = ventilation_heat_loss_w_per_k( + total_floor_area_m2=80.0, + avg_room_height_m=2.5, + is_timber_frame=False, + open_chimneys_count=0, + window_pct_draught_proofed=None, + ) + + # Assert — falls back to full 0.35 struct ACH -> 23.1 W/K. + assert result == pytest.approx(23.1, abs=0.5) diff --git a/packages/domain/src/domain/ml/transform.py b/packages/domain/src/domain/ml/transform.py index 0bc1a53f..51569e87 100644 --- a/packages/domain/src/domain/ml/transform.py +++ b/packages/domain/src/domain/ml/transform.py @@ -35,6 +35,7 @@ from domain.ml.ecf import ( predicted_total_fuel_cost_gbp, ) from domain.ml.envelope import envelope_heat_loss_w_per_k +from domain.ml.ventilation import ventilation_heat_loss_w_per_k from domain.ml.sap_efficiencies import seasonal_efficiency, water_heating_efficiency from domain.ml.schema import ColumnSpec, TransformSchema from domain.ml.ucl import apply_ucl_correction @@ -775,6 +776,16 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { "conduction loss in W/K." ), ), + "ventilation_heat_loss_w_per_k": ColumnSpec( + dtype=float, nullable=False, + description=( + "SAP10.2 §C ventilation heat-loss in W/K from structural infiltration " + "(0.35 ACH masonry / 0.25 ACH timber) plus open chimneys (40 m³/h each) " + "minus draught-proofing reduction (0.05 max × window DP share), all " + "multiplied by dwelling volume × 0.33. Captures the infiltration share " + "of total heat loss that envelope_heat_loss_w_per_k misses. ADR-0008." + ), + ), "seasonal_efficiency_main_heating": ColumnSpec( dtype=float, nullable=False, description=( @@ -902,7 +913,7 @@ class EpcMlTransform: Version 0.1.0 — schema contract only; feature columns added in subsequent slices. """ - VERSION: str = "2.6.0" + VERSION: str = "2.7.0" def schema(self) -> TransformSchema: """The cross-repo ML data contract. @@ -960,6 +971,17 @@ class EpcMlTransform: roof_description=_joined_descriptions(epc.roofs), wall_description=_joined_descriptions(epc.walls), ) + main_wall_con = building_part_aggregates.get("main_dwelling_wall_construction") + is_timber_frame = isinstance(main_wall_con, int) and main_wall_con in (5, 6) + avg_room_h = building_part_aggregates.get("avg_room_height_m") + window_dp_pct = window_aggregates.get("window_pct_draught_proofed") + ventilation_w_per_k = ventilation_heat_loss_w_per_k( + total_floor_area_m2=epc.total_floor_area_m2, + avg_room_height_m=float(avg_room_h) if isinstance(avg_room_h, (int, float)) else 2.5, + is_timber_frame=is_timber_frame, + open_chimneys_count=epc.open_chimneys_count, + window_pct_draught_proofed=float(window_dp_pct) if isinstance(window_dp_pct, (int, float)) else None, + ) main_heating_code = heating_aggregates.get("primary_sap_main_heating_code") water_code = heating_aggregates.get("water_heating_code") main_category = heating_aggregates.get("primary_main_heating_category") @@ -1051,6 +1073,7 @@ class EpcMlTransform: **building_part_aggregates, # Features — engineered physics (ADR-0008) "envelope_heat_loss_w_per_k": envelope_w_per_k, + "ventilation_heat_loss_w_per_k": ventilation_w_per_k, "seasonal_efficiency_main_heating": space_eff, "seasonal_efficiency_water_heating": water_eff, "predicted_space_heating_kwh": pred_space_kwh, diff --git a/packages/domain/src/domain/ml/ventilation.py b/packages/domain/src/domain/ml/ventilation.py new file mode 100644 index 00000000..1125d23f --- /dev/null +++ b/packages/domain/src/domain/ml/ventilation.py @@ -0,0 +1,56 @@ +"""Ventilation heat-loss W/K from SAP10.2 §C + RdSAP10 §5 / Table 5. + +The ventilation feature complements `envelope_heat_loss_w_per_k` (conduction + +thermal bridging only). Catastrophic-low-SAP homes are dominated by +infiltration that the conduction model can't see: open chimneys, no +draught-proofing, leaky old windows. Tracer-bullet scope: + + ACH_total = ACH_struct + (chimney_m3_h / volume_m3) − DP_reduction + W/K = ACH_total × volume_m3 × 0.33 + +Where 0.33 = ρ_air × c_p_air in kWh/(m³·K) at typical UK indoor conditions. + +Scope explicitly deferred: +- Mechanical ventilation (MVHR / MEV) — `mechanical_ventilation` is 100% null + in the 250k corpus, so no signal to act on yet. +- Pressure-test override — `pressure_test` is also 100% null (see slice 18e + candidate in HANDOFF §7-D). +- Open flues / passive vents / extract fans / flueless gas fires — read off + `sap_ventilation` which itself is sparsely populated. +""" + +from __future__ import annotations + +from typing import Final, Optional + + +_STRUCTURAL_ACH_MASONRY: Final[float] = 0.35 +_STRUCTURAL_ACH_TIMBER: Final[float] = 0.25 +_DRAUGHT_PROOFING_MAX_REDUCTION: Final[float] = 0.05 +_OPEN_CHIMNEY_M3_PER_H: Final[float] = 40.0 +_AIR_HEAT_CAPACITY_KWH_PER_M3_K: Final[float] = 0.33 + + +def ventilation_heat_loss_w_per_k( + total_floor_area_m2: float, + avg_room_height_m: float, + *, + is_timber_frame: bool, + open_chimneys_count: Optional[int], + window_pct_draught_proofed: Optional[float], +) -> float: + """SAP10.2 §C ventilation heat-loss in W/K, never null. + + Linear sum: structural infiltration ACH + chimneys ACH − draught-proofing + reduction, multiplied by dwelling volume × 0.33. + """ + if total_floor_area_m2 <= 0 or avg_room_height_m <= 0: + return 0.0 + volume_m3 = total_floor_area_m2 * avg_room_height_m + struct_ach = _STRUCTURAL_ACH_TIMBER if is_timber_frame else _STRUCTURAL_ACH_MASONRY + chimney_m3_h = (open_chimneys_count or 0) * _OPEN_CHIMNEY_M3_PER_H + chimney_ach = chimney_m3_h / volume_m3 + dp_share = (window_pct_draught_proofed or 0.0) / 100.0 + dp_reduction = _DRAUGHT_PROOFING_MAX_REDUCTION * dp_share + total_ach = max(0.0, struct_ach - dp_reduction) + chimney_ach + return total_ach * volume_m3 * _AIR_HEAT_CAPACITY_KWH_PER_M3_K