mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
696d43112e
commit
60eea0f52b
6 changed files with 209 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue