mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
42e0bb3122
commit
6884ec9fda
6 changed files with 177 additions and 0 deletions
|
|
@ -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?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue