slice 18b: description-aware u_roof for catastrophic roofs (v2.4.0)

Table 18 age-band roof defaults assume joist insulation >= 100mm, which
mis-rates heritage roofs the surveyor explicitly described as
uninsulated. u_roof now reads roofs[i].description and routes
"no insulation" / "uninsulated" -> 2.30 W/m^2K and "limited insulation"
-> 1.50 W/m^2K, threaded through envelope_heat_loss_w_per_k via a single
joined description string off the top-level roofs list.

Explicit insulation_thickness_mm still wins over description.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 17:32:57 +00:00
parent 696d43112e
commit 60eea0f52b
6 changed files with 209 additions and 5 deletions

View file

@ -103,6 +103,7 @@ def _part_heat_loss_w_per_k(
door_area_m2: float,
window_u_value: float,
door_u_value: float,
roof_description: Optional[str] = None,
) -> float:
"""Heat loss coefficient (W/K) for a single building part: walls + roof +
floor + party walls + windows + doors + thermal bridging.
@ -141,7 +142,12 @@ def _part_heat_loss_w_per_k(
insulation_thickness_mm=wall_ins_thickness,
insulation_present=wall_ins_present,
)
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness)
ur = u_roof(
country=country,
age_band=age_band,
insulation_thickness_mm=roof_thickness,
description=roof_description,
)
uf = u_floor(
country=country,
age_band=age_band,
@ -188,6 +194,7 @@ def envelope_heat_loss_w_per_k(
insulated_door_count: int,
insulated_door_u_value: Optional[float],
age_band_for_door: Optional[str] = None,
roof_description: Optional[str] = None,
) -> float:
"""Total envelope heat-loss coefficient (W/K) summed over all building parts.
@ -195,6 +202,12 @@ def envelope_heat_loss_w_per_k(
dwelling) per RdSAP10 convention -- the cert's window list is not split
across extensions. All U-values cascade through `rdsap_uvalues` defaults,
so the return is never null.
`roof_description` carries the worst-case surveyor description across the
top-level `roofs[i]` list (e.g. "Pitched, no insulation"). When the cert
flags a roof as uninsulated, u_roof returns Table 16 0mm/12mm values
instead of the optimistic Table 18 age-band default -- catastrophic
heritage roofs need that correction.
"""
if not sap_building_parts:
return 0.0
@ -231,5 +244,6 @@ def envelope_heat_loss_w_per_k(
door_area_m2=d_area,
window_u_value=window_u,
door_u_value=door_u,
roof_description=roof_description,
)
return total

View file

@ -252,13 +252,32 @@ _ROOF_BY_AGE: Final[dict[str, float]] = {
}
_ROOF_NO_INSULATION_MARKERS: Final[tuple[str, ...]] = (
"no insulation",
"uninsulated",
)
_ROOF_LIMITED_INSULATION_MARKERS: Final[tuple[str, ...]] = (
"limited insulation",
)
def u_roof(
country: Optional[Country],
age_band: Optional[str],
insulation_thickness_mm: Optional[int],
description: Optional[str] = None,
) -> float:
"""RdSAP10 roof U-value in W/m^2K, never null. Defaults via Table 18 when
thickness unknown; uses Table 16 column (1) joist values when known."""
"""RdSAP10 roof U-value in W/m^2K, never null.
Resolution order:
1. Explicit `insulation_thickness_mm` Table 16 column (1) joist row.
2. Surveyor `description` text (top-level `roofs[i].description`) flagging
uninsulated / limited-insulation roofs Table 16 0mm / 12mm rows.
Table 18 age-band defaults assume joist insulation 100 mm, which is
wrong for catastrophic heritage roofs the EPC explicitly describes
as uninsulated.
3. Table 18 age-band default.
"""
if insulation_thickness_mm is not None:
# nearest tabulated thickness <= supplied
u = _ROOF_BY_THICKNESS[0][1]
@ -266,6 +285,12 @@ def u_roof(
if insulation_thickness_mm >= t:
u = val
return u
if description is not None:
desc = description.lower()
if any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS):
return _ROOF_BY_THICKNESS[0][1] # 2.30 W/m^2K
if any(marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS):
return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row)
if age_band is None:
return 0.4
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)

View file

@ -217,6 +217,74 @@ def test_envelope_increases_with_windows_and_doors() -> None:
assert with_openings > no_openings
def test_envelope_uninsulated_roof_description_raises_heat_loss() -> None:
# Arrange — Catastrophic heritage roof: top-level roofs[i].description says
# "no insulation". Without the flag the Table 18 age-G default of 0.40 W/m^2K
# under-states heat loss; with it u_roof returns 2.30 W/m^2K so the envelope
# rises by roughly (2.30-0.40)*100 = 190 W/K for a 100 m^2 roof.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
default_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
uninsulated_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
roof_description="Pitched, no insulation (assumed)",
)
# Assert — heat loss jumps by ~190 W/K (1.90 W/m^2K * 100 m^2 roof area).
assert uninsulated_roof - default_roof == pytest.approx(190.0, abs=15.0)
def test_envelope_limited_roof_insulation_description_raises_heat_loss() -> None:
# Arrange — "limited insulation" maps to u_roof=1.50; delta vs Table 18
# age-G default of 0.40 is 1.10 W/m^2K * 100 m^2 = 110 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
default_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
limited_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
roof_description="Roof room(s), limited insulation",
)
# Assert
assert limited_roof - default_roof == pytest.approx(110.0, abs=15.0)
def test_envelope_never_null_even_with_missing_fields() -> None:
# Arrange — minimal building part with most fields unspecified.
main = make_building_part(

View file

@ -178,6 +178,85 @@ def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
assert result == pytest.approx(0.4, abs=0.001)
def test_u_roof_description_no_insulation_overrides_age_band_default() -> None:
# Arrange — surveyor description on a Victorian roof says uninsulated;
# Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm
# joist insulation is 2.30 W/m^2K.
# Act
result = u_roof(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=None,
description="Pitched, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(2.30, abs=0.001)
def test_u_roof_description_limited_insulation_overrides_age_band_default() -> None:
# Arrange — "limited insulation" maps to Table 16 row 12mm -> 1.50 W/m^2K.
# Act
result = u_roof(
country=Country.ENG,
age_band="D",
insulation_thickness_mm=None,
description="Roof room(s), limited insulation",
)
# Assert
assert result == pytest.approx(1.50, abs=0.001)
def test_u_roof_description_uninsulated_synonym_also_triggers_high_u() -> None:
# Arrange — surveyor writes "uninsulated" (no space) instead of "no insulation".
# Act
result = u_roof(
country=Country.ENG,
age_band="C",
insulation_thickness_mm=None,
description="Flat, uninsulated",
)
# Assert
assert result == pytest.approx(2.30, abs=0.001)
def test_u_roof_description_well_insulated_does_not_override_default() -> None:
# Arrange — description says "insulated"; do NOT override the Table 18
# age-G default of 0.40 with a penalty.
# Act
result = u_roof(
country=Country.ENG,
age_band="G",
insulation_thickness_mm=None,
description="Pitched, insulated at rafters",
)
# Assert
assert result == pytest.approx(0.40, abs=0.001)
def test_u_roof_explicit_thickness_beats_description() -> None:
# Arrange — when surveyor measured 200mm joist insulation, Table 16 wins
# regardless of any description text. 200mm -> 0.21 W/m^2K.
# Act
result = u_roof(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=200,
description="No insulation", # ignored because thickness is explicit
)
# Assert
assert result == pytest.approx(0.21, abs=0.001)
# ----- Floors -----

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.3.0"
assert schema.transform_version == "2.4.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

@ -16,6 +16,7 @@ import pandas as pd
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import (
EnergyElement,
EpcPropertyData,
SapBuildingPart,
SapEnergySource,
@ -901,7 +902,7 @@ class EpcMlTransform:
Version 0.1.0 schema contract only; feature columns added in subsequent slices.
"""
VERSION: str = "2.3.0"
VERSION: str = "2.4.0"
def schema(self) -> TransformSchema:
"""The cross-repo ML data contract.
@ -956,6 +957,7 @@ class EpcMlTransform:
door_count=epc.door_count,
insulated_door_count=epc.insulated_door_count,
insulated_door_u_value=epc.insulated_door_u_value,
roof_description=_joined_descriptions(epc.roofs),
)
main_heating_code = heating_aggregates.get("primary_sap_main_heating_code")
water_code = heating_aggregates.get("water_heating_code")
@ -1406,6 +1408,22 @@ def _truthy_yn(value: Any) -> Optional[bool]:
return None
def _joined_descriptions(elements: list[EnergyElement]) -> Optional[str]:
"""Concatenate `description` text across an `EnergyElement` list.
Used so envelope_heat_loss_w_per_k can spot worst-case markers ("no
insulation" / "limited insulation") across every roof / wall / floor entry
on the cert, since those are top-level lists not keyed by building part.
Returns None when the list is empty so callers can short-circuit.
"""
if not elements:
return None
parts = [e.description for e in elements if e.description]
if not parts:
return None
return " | ".join(parts)
def _ground_floor(part: SapBuildingPart) -> Optional[Any]:
"""Pick the ground-floor `SapFloorDimension` (floor==0) for a building part.