fix(heat-transmission): match roof description per part by kind (RdSAP 10 §5.11)

The deduplicated `epc.roofs[]` list cannot be indexed 1:1 against the
building parts (190/329 multi-part certs have len(roofs) != len(parts)),
so every part's `u_roof` consumed a SINGLE join of all roof descriptions.
That leaked one part's insulation state onto another: a "Flat, no
insulation" extension dragged a "Pitched, insulated (assumed)" main roof
to the uninsulated 2.30, ~3x over-stating its heat loss. 3-part certs
systematically under-rated (56% within-0.5, mean -0.79 SAP).

Partition the non-RR roof descriptions into flat vs pitched/sloping and
match each part to its own kind (`_main_roof_descriptions_by_kind`),
falling back to the global join when a part's kind has no matching entry.

Corpus cert 100010129331: roof 110.5 -> 31.3 W/K, +13.10 -> -0.05 SAP.
RdSAP-21.0.1 within-0.5 68.8% -> 69.5% (MAE 0.888 -> 0.859; PE 13.9 ->
13.6); 3-part cohort 56% -> 61%. Floors/ceilings ratcheted. Pinned in
test_heat_transmission (by_kind split + mixed-roof no-contamination).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-17 00:48:50 +00:00
parent c5aa5620ca
commit e136e937d6
3 changed files with 127 additions and 4 deletions

View file

@ -400,6 +400,32 @@ def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]:
return " | ".join(parts)
def _main_roof_descriptions_by_kind(
roofs: list[Any],
) -> tuple[Optional[str], Optional[str]]:
"""Partition the non-RR roof descriptions into ``(pitched, flat)`` joins.
The deduplicated ``epc.roofs[]`` list cannot be indexed 1:1 against the
building parts (190/329 multi-part certs have len(roofs) != len(parts)),
so each part's ``u_roof`` historically consumed the SINGLE join of every
roof description. That leaks one part's insulation state onto another: a
"Flat, no insulation" extension dragged a "Pitched, insulated (assumed)"
main roof to the uninsulated 2.30, ~3x over-stating its heat loss (cert
100010129331: roof 110.5 -> ~28 W/K, +13 SAP). Splitting by flat vs
pitched/sloping lets each part match its own kind; the global join
(`_joined_main_roof_descriptions`) stays the fallback when a part's kind
has no matching entry. "Roof room(s)" entries are dropped (they carry
their own §3.9/§3.10 shell cascade)."""
pitched: list[str] = []
flat: list[str] = []
for e in roofs:
d = getattr(e, "description", "")
if not d or "roof room" in d.lower():
continue
(flat if "flat" in d.lower() else pitched).append(d)
return (" | ".join(pitched) or None, " | ".join(flat) or None)
def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
if not part.sap_floor_dimensions:
# A part with no floor dimensions has no derivable RR shell or
@ -617,6 +643,9 @@ def heat_transmission_from_cert(
country = Country.from_code(epc.country_code)
roof_description = _joined_main_roof_descriptions(epc.roofs)
pitched_roof_description, flat_roof_description = (
_main_roof_descriptions_by_kind(epc.roofs)
)
wall_description = _joined_descriptions(epc.walls)
floor_description = _joined_descriptions(epc.floors)
@ -888,8 +917,19 @@ def heat_transmission_from_cert(
roof_thickness_explicitly_zero = (
isinstance(raw_roof_thickness, int) and raw_roof_thickness == 0
)
# RdSAP 10 §5.11 — match THIS part's roof to its own kind's lodged
# description (flat vs pitched/sloping) rather than the global join,
# so a flat "no insulation" part does not drag a pitched insulated
# part to the uninsulated 2.30. Fall back to the global join when the
# part's kind has no matching `epc.roofs[]` entry.
part_roof_is_flat = "flat" in (part.roof_construction_type or "").lower()
matched_roof_description = (
flat_roof_description if part_roof_is_flat else pitched_roof_description
)
if matched_roof_description is None:
matched_roof_description = roof_description
effective_roof_description = (
None if roof_thickness_explicitly_zero else roof_description
None if roof_thickness_explicitly_zero else matched_roof_description
)
# RdSAP 10 §5.11 Table 18 page 45: column (3) "Flat roof" applies
# when the per-bp roof construction lodges as a flat roof and the

View file

@ -38,6 +38,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import (
from domain.sap10_calculator.worksheet.heat_transmission import (
_alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage]
_joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage]
_main_roof_descriptions_by_kind, # pyright: ignore[reportPrivateUsage]
_part_geometry, # pyright: ignore[reportPrivateUsage]
_round_half_up, # pyright: ignore[reportPrivateUsage]
_window_bp_index, # pyright: ignore[reportPrivateUsage]
@ -82,6 +83,80 @@ def test_joined_main_roof_descriptions_keeps_pure_rr_fallback() -> None:
assert result == "Roof room(s), no insulation (assumed)"
def test_main_roof_descriptions_by_kind_splits_flat_from_pitched() -> None:
# Arrange — a cert with a pitched insulated main roof + a flat
# uninsulated extension. The deduplicated epc.roofs[] cannot be indexed
# 1:1 against the parts, so each part must match its own KIND's
# description: the flat part's "no insulation" must not leak onto the
# pitched part (which would force the whole pitched roof to U=2.30).
roofs = [
_Desc("Pitched, insulated (assumed)"),
_Desc("Flat, no insulation"),
_Desc("Roof room(s), no insulation (assumed)"),
]
# Act
pitched, flat = _main_roof_descriptions_by_kind(roofs)
# Assert — RR dropped; flat and pitched kept apart.
assert pitched == "Pitched, insulated (assumed)"
assert flat == "Flat, no insulation"
def test_mixed_flat_pitched_roof_does_not_contaminate_pitched_u_value() -> None:
# Arrange — 2-part dwelling: a 100 m² pitched insulated-assumed main
# roof (U=0.40) + a 2 m² flat uninsulated extension (U=2.30). Before the
# per-kind split, the joined "Pitched, insulated (assumed) | Flat, no
# insulation" description leaked the flat's "no insulation" onto the
# pitched part, billing the WHOLE roof at 2.30 (100×2.30 + 2×2.30 =
# 234.6 W/K). Correct: 100×0.40 + 2×2.30 = 44.6 W/K. Mirrors corpus
# cert 100010129331 (roof 110.5 -> 31.3 W/K, +13 -> 0 SAP).
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="C",
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
ext = make_building_part(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="C",
roof_construction=5,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=2.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=6.0, floor=0,
),
],
)
ext.roof_construction_type = "Flat"
epc = make_minimal_sap10_epc(
total_floor_area_m2=102.0,
country_code="ENG",
sap_building_parts=[main, ext],
)
epc.roofs = [
EnergyElement(
description="Pitched, insulated (assumed)",
energy_efficiency_rating=4, environmental_efficiency_rating=4,
),
EnergyElement(
description="Flat, no insulation",
energy_efficiency_rating=1, environmental_efficiency_rating=1,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert — pitched main billed at its insulated U, not the flat's 2.30.
assert abs(result.roof_w_per_k - 44.6) <= 2.0
def test_part_geometry_floorless_part_honours_full_key_contract() -> None:
# Arrange — a building part lodged with NO sap_floor_dimensions (e.g.
# a party-wall-only or RR-only extension; observed on 5 certs in a

View file

@ -111,10 +111,18 @@ _CORPUS = Path(
# the wall_insulation_type=3 under-rate cluster (cert 100052159386 -26.2 -> -4.1
# SAP, walls 300 -> 55 W/K). within-0.5 68.6% -> 68.8% (MAE 0.942 -> 0.888;
# PE MAE 14.3 -> 13.9; CO2 MAE 0.27 -> 0.26). Unit-pinned in test_rdsap_uvalues.
_MIN_WITHIN_HALF_SAP = 0.685
_MAX_SAP_MAE = 0.89
# PER-PART ROOF DESCRIPTION (RdSAP 10 §5.11): the deduplicated epc.roofs[] list
# was joined into ONE description fed to EVERY building part's u_roof, so a flat
# "no insulation" extension dragged a pitched "insulated (assumed)" main roof to
# the uninsulated 2.30 (3-part certs systematically under-rated: 56% within,
# -0.79 mean). Matching each part to its own kind (flat vs pitched) fixed cert
# 100010129331 (roof 110.5 -> 31.3 W/K, +13.1 -> -0.05 SAP). within-0.5
# 68.8% -> 69.5% (MAE 0.888 -> 0.859; PE 13.9 -> 13.6); 3-part cohort 56% ->
# 61%. Pinned in test_heat_transmission (by_kind split + no-contamination).
_MIN_WITHIN_HALF_SAP = 0.69
_MAX_SAP_MAE = 0.86
_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 14.5 # kWh / m2 / yr vs energy_consumption_current
_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]: