From 6884ec9fdadb2a2ff39dc9f46292409bfa4877c3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 13:51:26 +0000 Subject: [PATCH] =?UTF-8?q?fix(fabric):=20honour=20the=20gov-EPC=20lodged?= =?UTF-8?q?=20per-element=20U-values=20(RdSAP=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov-EPC API surfaces the assessor's RdSAP-assessed per-element U-values as `roof_u_value` / `wall_u_value` / `floor_u_value` on each building part. These were undeclared on the RdSAP 21.0.0/21.0.1 schemas, so `from_dict` silently dropped them, and `heat_transmission` re-derived each U from the §5.6 /§5.7/§5.11 construction-default cascade. The gov OPEN data routinely redacts the backing insulation thickness, so that re-derivation mis-bills an insulated element as uninsulated. RdSAP 10 §5.1: a known element U-value (documentary evidence / the lodged RdSAP output) is used directly in place of the construction-default cascade. Per [[project_per_cert_mapper_validation_state]] the gov API carries RdSAP OUTPUT, so the lodged U reproduces the official's element heat loss exactly. Worst case in the 2026 sample: cert 7921-0052-0940-5007-0663, an age-C "Pitched, sloping ceiling" (rc=8) top-floor flat lodging roof_u_value=0.2 with no thickness. The cascade returned the uninsulated 2.30 W/m²K → SAP 56.9 vs lodged 80 (-23.09, the single largest error in the sample). The roof override alone recovers ~15 SAP; the wall override (lodged 0.34 vs cascade) closes the rest of this cohort. Override applies to the MAIN wall only (alt-wall sub-areas keep their own per-area U) and the part's floor=0. Fires only when the rare field is present (9 of 909 computed certs), so the Summary path — which never lodges these API fields — is untouched. API gauge: 67.1% → 67.7% within-0.5, mean|err| 1.024 → 0.992. Worksheet harness: 47/47, 0 divergers (unchanged). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/epc_property_data.py | 15 +++ datatypes/epc/domain/mapper.py | 12 ++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 10 ++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 11 ++ .../worksheet/heat_transmission.py | 23 ++++ .../worksheet/test_heat_transmission.py | 106 ++++++++++++++++++ 6 files changed, 177 insertions(+) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index d5c11b1c..49c9b119 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -509,10 +509,19 @@ class SapBuildingPart: # (λ = 0.04 / 0.03 / 0.025 W/m·K). Used by the documentary-evidence # R-value path when a measured wall thickness is lodged alongside it. wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None + # RdSAP 10 §5.1 — the assessor's lodged main-wall U-value (W/m²K), surfaced + # by the gov-EPC API as `wall_u_value`. Authoritative when the open data + # redacts the backing insulation; overrides the §5.6/§5.7 construction- + # default cascade for the main wall (alt-wall sub-areas keep their own U). + wall_u_value: Optional[float] = None sap_alternative_wall_1: Optional[SapAlternativeWall] = None sap_alternative_wall_2: Optional[SapAlternativeWall] = None floor_heat_loss: Optional[int] = None + # RdSAP 10 §5.1 — the assessor's lodged ground-floor U-value (W/m²K), + # surfaced by the gov-EPC API as `floor_u_value`. Overrides the BS EN ISO + # 13370 / Table 19 ground-floor cascade when present. + floor_u_value: Optional[float] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = ( None # TODO: make enum/mapping? @@ -528,6 +537,12 @@ class SapBuildingPart: roof_construction: Optional[int] = None roof_construction_type: Optional[str] = None # str from site notes e.g. "PS Pitched, sloping ceiling" + # RdSAP 10 §5.1 — the assessor's lodged roof U-value (W/m²K). The gov-EPC + # API surfaces it as `roof_u_value`; it is the RdSAP-assessed output for + # the roof and overrides the §5.11 construction-default cascade in + # `heat_transmission` (the open data can redact the backing insulation + # thickness, so the cascade otherwise mis-derives an uninsulated U). + roof_u_value: Optional[float] = None roof_insulation_location: Optional[Union[int, str]] = ( None # TODO: make enum/mapping? ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index f87c04d5..6cdc328d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1459,6 +1459,12 @@ class EpcPropertyDataMapper: bp.construction_age_band, bp.sloping_ceiling_insulation_thickness, ), + # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U + # overrides the §5.6/§5.7/§5.11 construction-default cascade + # (gov open data can redact the backing insulation). + roof_u_value=bp.roof_u_value, + wall_u_value=bp.wall_u_value, + floor_u_value=bp.floor_u_value, sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, is_flat=schema.property_type == 2, @@ -1750,6 +1756,12 @@ class EpcPropertyDataMapper: bp.construction_age_band, bp.sloping_ceiling_insulation_thickness, ), + # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U + # overrides the §5.6/§5.7/§5.11 construction-default cascade + # (gov open data can redact the backing insulation). + roof_u_value=bp.roof_u_value, + wall_u_value=bp.wall_u_value, + floor_u_value=bp.floor_u_value, sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, is_flat=schema.property_type == 2, diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 8235569c..e70d7b52 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -265,6 +265,16 @@ class SapBuildingPart: # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None + # Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U, + # authoritative when the open data redacts the backing insulation + # thickness. Consumed by `heat_transmission` as a §5.1 documentary-evidence + # override. Previously undeclared → dropped by `from_dict`. + roof_u_value: Optional[float] = None + # Lodged main-wall / ground-floor U-values (W/m²K) — same §5.1 documentary- + # evidence override; authoritative when the open data redacts the backing + # insulation. Previously undeclared → dropped by `from_dict`. + wall_u_value: Optional[float] = None + floor_u_value: Optional[float] = None @dataclass diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index ef30581e..48843b05 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -303,6 +303,17 @@ class SapBuildingPart: # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None + # Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U. The + # gov open data can redact the backing insulation thickness, so this is the + # authoritative per-element value; consumed by `heat_transmission` as a + # §5.1 documentary-evidence override. Previously undeclared → dropped by + # `from_dict` (cert 7921-0052-0940-5007-0663 lodges roof_u_value=0.2). + roof_u_value: Optional[float] = None + # Lodged main-wall / ground-floor U-values (W/m²K) — same §5.1 documentary- + # evidence override as roof_u_value; authoritative when the open data + # redacts the backing insulation. Previously undeclared → dropped. + wall_u_value: Optional[float] = None + floor_u_value: Optional[float] = None @dataclass diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 5af20455..ebdb2b52 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -819,6 +819,14 @@ def heat_transmission_from_cert( # billed at the un-adjusted U. dry_lined=bool(part.wall_dry_lined), ) + # RdSAP 10 §5.1 — a lodged/known main-wall U-value (the assessor's + # RdSAP output, surfaced by the gov-EPC API as `wall_u_value`) + # overrides the §5.6/§5.7 construction-default cascade for the MAIN + # wall. Alt-wall sub-areas keep their own per-area U (handled below), + # so this only replaces the primary `uw`. + lodged_wall_u = getattr(part, "wall_u_value", None) + if lodged_wall_u is not None: + uw = lodged_wall_u # When the per-bp `roof_insulation_thickness` is explicitly lodged # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling # age C from Slice 91's `_api_resolve_sloping_ceiling_thickness`, @@ -866,6 +874,15 @@ def heat_transmission_from_cert( # string triggers the col (3) age-band default in `u_roof`. is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling) + # RdSAP 10 §5.1 — a lodged/known roof U-value (the assessor's RdSAP + # output, surfaced by the gov-EPC API as `roof_u_value`) is used + # directly in place of the §5.11 construction-default cascade. The gov + # open data can redact the backing insulation thickness, so the + # cascade otherwise mis-derives an uninsulated U for an insulated roof + # (cert 7921-0052-0940-5007-0663: lodged 0.2 vs cascade 2.30 → -23 SAP). + lodged_roof_u = getattr(part, "roof_u_value", None) + if lodged_roof_u is not None: + ur = lodged_roof_u # Floor U-value routing (in priority order): # 1. Basement floor — Table 23 F-column override (whole floor=0). # 2. Exposed/semi-exposed upper floor — Table 20 lookup; no @@ -910,6 +927,12 @@ def heat_transmission_from_cert( wall_thickness_mm=part.wall_thickness_mm, description=effective_floor_description, ) + # RdSAP 10 §5.1 — a lodged/known ground-floor U-value (surfaced by the + # gov-EPC API as `floor_u_value`) overrides the BS EN ISO 13370 / + # Table 19 cascade for this part's floor=0. + lodged_floor_u = getattr(part, "floor_u_value", None) + if lodged_floor_u is not None: + uf = lodged_floor_u # RdSAP 10 Table 15 footnote * — flats/maisonettes with unknown # party-wall construction default to U=0.0 (both sides heated), # not the U=0.25 house default. Cert 0036-6325-1100-0063-1226 diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index b6a21d0a..3153f1bc 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -151,6 +151,112 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0) +def test_lodged_roof_u_value_overrides_construction_default() -> None: + # Arrange — RdSAP 10 §5.1: where an element's U-value is known from the + # assessment (documentary evidence / the lodged RdSAP output) it is used + # directly in place of the §5.11 construction-default cascade. The gov-EPC + # API surfaces the assessor's roof U as `roof_u_value`; the gov open data + # can redact the backing insulation thickness, leaving the §5.11 cascade + # to mis-derive an uninsulated U. Cert 7921-0052-0940-5007-0663 lodges a + # "Pitched, sloping ceiling" (rc=8) age-C roof with no thickness — the + # cascade returns the uninsulated 2.30 W/m²K (→ -23 SAP) where the lodged + # roof_u_value is 0.2. Geometry: 100 m² plan → sloped roof area = + # 100 / cos(30°) = 115.47 m². roof_w_per_k must follow the lodged 0.2 + # (0.2 × 115.47 = 23.094 W/K), NOT the 2.30 × 115.47 = 265 W/K default. + main = make_building_part( + construction_age_band="C", + wall_construction=3, + wall_insulation_type=4, + party_wall_construction=1, + roof_construction=8, + 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, + ), + ], + ) + main.roof_construction_type = "Pitched, sloping ceiling" + main.roof_u_value = 0.2 + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — 0.2 × (100 / cos(30°)) = 0.2 × 115.470 = 23.094 W/K. + assert abs(result.roof_w_per_k - 23.094) <= 1e-3 + + +def test_lodged_wall_u_value_overrides_construction_default() -> None: + # Arrange — RdSAP 10 §5.1: a lodged main-wall U-value (the gov-EPC API + # `wall_u_value`, the assessor's RdSAP output) overrides the §5.6/§5.7 + # construction-default cascade. Cohort certs 2021/7505 lodge solid-brick + # (rc=3) walls with the open data's insulation redacted, where the lodged + # wall_u_value (0.34) is far below the cascade's uninsulated default → the + # cascade under-rates by ~-2.6 SAP. Geometry chosen so the gross wall area + # (no openings) is 80 m²: net wall U×A must follow 0.34, NOT the default. + main = make_building_part( + construction_age_band="C", + wall_construction=3, + 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=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + main.wall_u_value = 0.34 + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — gross wall = perimeter 40 m × height 2.5 m = 100 m²; no windows + # or doors in this minimal cert → net wall area 100 m². 0.34 × 100 = 34.0. + assert abs(result.walls_w_per_k - 34.0) <= 1e-6 + + +def test_lodged_floor_u_value_overrides_iso_13370_cascade() -> None: + # Arrange — RdSAP 10 §5.1: a lodged ground-floor U-value (the gov-EPC API + # `floor_u_value`) overrides the BS EN ISO 13370 / Table 19 cascade. + main = make_building_part( + construction_age_band="C", + wall_construction=3, + 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=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + main.floor_u_value = 0.12 + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — 0.12 × 100 m² floor = 12.0 W/K. + assert abs(result.floor_w_per_k - 12.0) <= 1e-6 + + def test_exposed_timber_floor_age_b_uses_table_20_u_120_not_iso_13370() -> None: # Arrange — RdSAP10 §5.13 Table 20: a part whose lowest floor sits # over outside air (or unheated space) rather than soil takes its