slice 20a: ventilation_heat_loss_w_per_k feature (v2.7.0)

Adds SAP10.2 §C tracer-bullet infiltration model as a new physics-as-feature
column alongside envelope_heat_loss_w_per_k. ACH = structural baseline
(0.35 masonry / 0.25 timber-or-system-built) + open chimneys at 40 m³/h each
minus a draught-proofing reduction scaled by window_pct_draught_proofed,
then volumed and converted to W/K. Targets the d0 catastrophic-low-SAP tail
where chimney + leakage signals dominate but envelope conduction alone
under-counts heat loss.

Scope deferred to follow-ups: MVHR/MEV factors (mechanical_ventilation is
100% null in the corpus), pressure-test override (pressure_test also 100%
null - slice 18e mapper fix), open flues / passive vents / flueless gas
fires (sap_ventilation sparsely populated).
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 18:30:02 +00:00
parent 831ebac2ae
commit 4d838bb03c
4 changed files with 230 additions and 2 deletions

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.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():

View file

@ -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 /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)

View file

@ -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,

View file

@ -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/(·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