fix(fabric): honour the gov-EPC lodged per-element U-values (RdSAP §5.1)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 13:51:26 +00:00
parent 42e0bb3122
commit 6884ec9fda
6 changed files with 177 additions and 0 deletions

View file

@ -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?
)

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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