mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +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,
|
door_area_m2: float,
|
||||||
window_u_value: float,
|
window_u_value: float,
|
||||||
door_u_value: float,
|
door_u_value: float,
|
||||||
|
roof_description: Optional[str] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Heat loss coefficient (W/K) for a single building part: walls + roof +
|
"""Heat loss coefficient (W/K) for a single building part: walls + roof +
|
||||||
floor + party walls + windows + doors + thermal bridging.
|
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_thickness_mm=wall_ins_thickness,
|
||||||
insulation_present=wall_ins_present,
|
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(
|
uf = u_floor(
|
||||||
country=country,
|
country=country,
|
||||||
age_band=age_band,
|
age_band=age_band,
|
||||||
|
|
@ -188,6 +194,7 @@ def envelope_heat_loss_w_per_k(
|
||||||
insulated_door_count: int,
|
insulated_door_count: int,
|
||||||
insulated_door_u_value: Optional[float],
|
insulated_door_u_value: Optional[float],
|
||||||
age_band_for_door: Optional[str] = None,
|
age_band_for_door: Optional[str] = None,
|
||||||
|
roof_description: Optional[str] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Total envelope heat-loss coefficient (W/K) summed over all building parts.
|
"""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
|
dwelling) per RdSAP10 convention -- the cert's window list is not split
|
||||||
across extensions. All U-values cascade through `rdsap_uvalues` defaults,
|
across extensions. All U-values cascade through `rdsap_uvalues` defaults,
|
||||||
so the return is never null.
|
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:
|
if not sap_building_parts:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
@ -231,5 +244,6 @@ def envelope_heat_loss_w_per_k(
|
||||||
door_area_m2=d_area,
|
door_area_m2=d_area,
|
||||||
window_u_value=window_u,
|
window_u_value=window_u,
|
||||||
door_u_value=door_u,
|
door_u_value=door_u,
|
||||||
|
roof_description=roof_description,
|
||||||
)
|
)
|
||||||
return total
|
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(
|
def u_roof(
|
||||||
country: Optional[Country],
|
country: Optional[Country],
|
||||||
age_band: Optional[str],
|
age_band: Optional[str],
|
||||||
insulation_thickness_mm: Optional[int],
|
insulation_thickness_mm: Optional[int],
|
||||||
|
description: Optional[str] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""RdSAP10 roof U-value in W/m^2K, never null. Defaults via Table 18 when
|
"""RdSAP10 roof U-value in W/m^2K, never null.
|
||||||
thickness unknown; uses Table 16 column (1) joist values when known."""
|
|
||||||
|
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:
|
if insulation_thickness_mm is not None:
|
||||||
# nearest tabulated thickness <= supplied
|
# nearest tabulated thickness <= supplied
|
||||||
u = _ROOF_BY_THICKNESS[0][1]
|
u = _ROOF_BY_THICKNESS[0][1]
|
||||||
|
|
@ -266,6 +285,12 @@ def u_roof(
|
||||||
if insulation_thickness_mm >= t:
|
if insulation_thickness_mm >= t:
|
||||||
u = val
|
u = val
|
||||||
return u
|
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:
|
if age_band is None:
|
||||||
return 0.4
|
return 0.4
|
||||||
return _ROOF_BY_AGE.get(age_band.upper(), 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
|
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:
|
def test_envelope_never_null_even_with_missing_fields() -> None:
|
||||||
# Arrange — minimal building part with most fields unspecified.
|
# Arrange — minimal building part with most fields unspecified.
|
||||||
main = make_building_part(
|
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)
|
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 -----
|
# ----- Floors -----
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ def test_transform_advertises_version_and_target_columns() -> None:
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert isinstance(schema, TransformSchema)
|
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 schema.transform_version == EpcMlTransform.VERSION
|
||||||
assert set(schema.target_columns.keys()) == set(_EXPECTED_TARGET_DTYPES.keys())
|
assert set(schema.target_columns.keys()) == set(_EXPECTED_TARGET_DTYPES.keys())
|
||||||
for target_name, expected_dtype in _EXPECTED_TARGET_DTYPES.items():
|
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 import Epc
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
EnergyElement,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
SapBuildingPart,
|
SapBuildingPart,
|
||||||
SapEnergySource,
|
SapEnergySource,
|
||||||
|
|
@ -901,7 +902,7 @@ class EpcMlTransform:
|
||||||
Version 0.1.0 — schema contract only; feature columns added in subsequent slices.
|
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:
|
def schema(self) -> TransformSchema:
|
||||||
"""The cross-repo ML data contract.
|
"""The cross-repo ML data contract.
|
||||||
|
|
@ -956,6 +957,7 @@ class EpcMlTransform:
|
||||||
door_count=epc.door_count,
|
door_count=epc.door_count,
|
||||||
insulated_door_count=epc.insulated_door_count,
|
insulated_door_count=epc.insulated_door_count,
|
||||||
insulated_door_u_value=epc.insulated_door_u_value,
|
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")
|
main_heating_code = heating_aggregates.get("primary_sap_main_heating_code")
|
||||||
water_code = heating_aggregates.get("water_heating_code")
|
water_code = heating_aggregates.get("water_heating_code")
|
||||||
|
|
@ -1406,6 +1408,22 @@ def _truthy_yn(value: Any) -> Optional[bool]:
|
||||||
return None
|
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]:
|
def _ground_floor(part: SapBuildingPart) -> Optional[Any]:
|
||||||
"""Pick the ground-floor `SapFloorDimension` (floor==0) for a building part.
|
"""Pick the ground-floor `SapFloorDimension` (floor==0) for a building part.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue