mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
831ebac2ae
commit
4d838bb03c
4 changed files with 230 additions and 2 deletions
|
|
@ -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():
|
||||
|
|
|
|||
149
packages/domain/src/domain/ml/tests/test_ventilation.py
Normal file
149
packages/domain/src/domain/ml/tests/test_ventilation.py
Normal 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 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
56
packages/domain/src/domain/ml/ventilation.py
Normal file
56
packages/domain/src/domain/ml/ventilation.py
Normal 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/(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
|
||||
Loading…
Add table
Reference in a new issue