diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0ea700e8..7520a440 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -52,7 +52,7 @@ from __future__ import annotations import math from dataclasses import dataclass, replace from decimal import ROUND_HALF_UP, Decimal -from typing import Callable, Final, Literal, Optional +from typing import Callable, Final, Literal, Optional, Union from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, @@ -1642,7 +1642,62 @@ from domain.sap10_calculator.exceptions import ( -def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure: +def _roof_insulation_location_is_determined( + value: Optional[Union[int, str]] +) -> bool: + """Whether a building part lodges a *determined* roof-insulation + location — an RdSAP integer code (1-7: at-rafters, loft, flat-roof, …), + meaning the unit has an exposed roof to insulate. The gov-EPC API lodges + the string "ND" (Not Defined) when there is no exposed roof: the ceiling + is a party surface (another dwelling above), so there is nowhere to put + roof insulation. `None`/empty is likewise "no signal". + """ + if isinstance(value, int): + return True + if isinstance(value, str): + return value.strip().upper() not in ("", "ND") + return False + + +def _cert_lodges_exposed_roof(parts: list[SapBuildingPart]) -> bool: + """Whether the main building part lodges a genuine exposed (heat-loss) + roof — keyed on the structured `roof_insulation_location` field, NOT a + description string or `roof_construction`. + + The gov-EPC API lodges the *building's* `roof_construction` on every + unit (incl. mid-floor ones whose ceiling is party), so it is not a + per-unit exposure signal. `roof_insulation_location`, by contrast, is + "ND" (Not Defined) exactly when the unit's ceiling is a party surface + (no roof to insulate) and a real RdSAP location code when the roof is + exposed. On the RdSAP-21.0.1 corpus this separates the two classes with + zero disagreement: all 190 party-ceiling flats lodge "ND"; every + mid/ground-floor flat with a determined location is genuinely top-storey. + + Motivating cases (gov-API certs lodge `dwelling_type` as the raw + assessor label, which can contradict the fabric): property 715363 + (uprn 6027561, location code 6 = flat roof) + sibling 715395 lodge + "Mid-floor flat" yet have their own exposed roof over a dwelling below + — top-floor flats mislabelled mid-floor; the correctly labelled + top-floor sibling 715871 (same block, same roof) computes the lodged + SAP 74. Dropping the roof under-read space-heating demand ~32% / + over-read SAP +7. Of the corpus mid/ground-floor flats this exposes, + 4/4 move toward the lodged SAP, 0 away. + + Reads the MAIN part (parts[0]): a flat's storey position is a + whole-dwelling property, and `DwellingExposure` is a single global flag + that `heat_transmission` applies to every part — so a multi-part flat + whose main ceiling is party (e.g. only an extension has an exposed roof) + correctly stays party rather than over-counting the main party ceiling. + """ + if not parts: + return False + return _roof_insulation_location_is_determined(parts[0].roof_insulation_location) + + +def _dwelling_exposure( + dwelling_type: Optional[str], + parts: Optional[list[SapBuildingPart]] = None, +) -> DwellingExposure: """Map `EpcPropertyData.dwelling_type` to which envelope surfaces are party (not heat-loss). Mid-floor flats/maisonettes lose both floor + roof; top-floor lose floor only; ground-floor lose roof only. Houses @@ -1651,7 +1706,23 @@ def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure: RdSAP 10 §3 lists flat-prefix dwelling types ("Top-floor flat", "Mid-floor maisonette", etc.); matching is prefix-based and case-insensitive so site-notes capitalisation drift doesn't break it. + + The lodged fabric overrides a contradictory label: when the main part + lodges a determined roof-insulation location (`_cert_lodges_exposed_roof`), + the roof is heat-loss even if the label suppressed it (a top-floor flat + lodged as "Mid-floor"). The override is additive — it only ever + *exposes* a roof the label dropped, never hides a lodged party ceiling — + so a true mid-floor flat (roof_insulation_location "ND") is unaffected. """ + base = _dwelling_exposure_from_type(dwelling_type) + if not base.has_exposed_roof and parts and _cert_lodges_exposed_roof(parts): + return replace(base, has_exposed_roof=True) + return base + + +def _dwelling_exposure_from_type(dwelling_type: Optional[str]) -> DwellingExposure: + """The `dwelling_type`-label-only exposure map (RdSAP 10 §3 prefixes). + `_dwelling_exposure` layers the lodged-fabric override on top.""" if not dwelling_type: return DwellingExposure(has_exposed_floor=True, has_exposed_roof=True) dt = dwelling_type.lower() @@ -4557,7 +4628,7 @@ def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmissio PDF. """ window_total_area, window_avg_u = _window_total_area_and_avg_u(epc.sap_windows) - exposure = _dwelling_exposure(epc.dwelling_type) + exposure = _dwelling_exposure(epc.dwelling_type, epc.sap_building_parts) return heat_transmission_from_cert( epc, window_total_area_m2=window_total_area, diff --git a/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index 2c7c3b68..f92a4995 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -13,6 +13,7 @@ from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, + EnergyElement, EpcPropertyData, InstantaneousWwhrs, MainHeatingDetail, @@ -157,6 +158,7 @@ def make_building_part( wall_thickness_measured: bool = True, party_wall_construction: Union[int, str] = 1, roof_construction: Optional[int] = 4, + roof_insulation_location: Optional[Union[int, str]] = None, floor_dimensions: Optional[list[SapFloorDimension]] = None, sap_room_in_roof: Optional[SapRoomInRoof] = None, floor_type: Optional[str] = None, @@ -170,6 +172,7 @@ def make_building_part( wall_thickness_measured=wall_thickness_measured, party_wall_construction=party_wall_construction, roof_construction=roof_construction, + roof_insulation_location=roof_insulation_location, sap_floor_dimensions=floor_dimensions if floor_dimensions is not None else [make_floor_dimension()], @@ -277,6 +280,7 @@ def make_minimal_sap10_epc( pressure_test: Optional[int] = None, sap_ventilation: Optional[SapVentilation] = None, postcode: str = "A1 1AA", + roofs: Optional[list[EnergyElement]] = None, ) -> EpcPropertyData: """Construct a minimal valid SAP10 EpcPropertyData with parametrisable targets.""" return EpcPropertyData( @@ -287,7 +291,7 @@ def make_minimal_sap10_epc( address_line_1="1 Test Street", postcode=postcode, post_town="Testtown", - roofs=[], + roofs=list(roofs) if roofs is not None else [], walls=[], floors=[], main_heating=[], diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 17edfabd..2adeea02 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -5690,7 +5690,10 @@ def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() # Arrange — A "Mid-floor flat" has party floor (downstairs flat) and # party ceiling (upstairs flat). The mapper must wire DwellingExposure # to suppress both channels so the HLC matches what RdSAP-driven - # assessor software produces. + # assessor software produces. A genuine mid-floor flat lodges + # roof_insulation_location="ND" (Not Defined — no roof to insulate, the + # ceiling is a party surface), so `_cert_lodges_exposed_roof` does not + # fire. epc = make_minimal_sap10_epc( total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4, @@ -5698,6 +5701,7 @@ def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() dwelling_type="Mid-floor flat", sap_building_parts=[ make_building_part( + roof_insulation_location="ND", # party ceiling — no exposed roof floor_dimensions=[ make_floor_dimension(total_floor_area_m2=_TYPICAL_TFA_M2, floor=0), ], @@ -5718,6 +5722,51 @@ def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() assert inputs.heat_transmission.walls_w_per_k > 0 +def test_mid_floor_label_with_determined_roof_insulation_exposes_roof() -> None: + # Arrange — gov-API certs lodge `dwelling_type` as the raw assessor + # label, which can contradict the lodged fabric. Property 715363 + # (uprn 6027561) and its sibling 715395 (6027563) lodge + # dwelling_type="Mid-floor flat" yet lodge a determined + # roof_insulation_location (715363: code 6 = flat roof) over a + # "(another dwelling below)" floor — i.e. they are TOP-floor flats + # mislabelled mid-floor. Keying roof exposure on the label alone dropped + # the roof heat-loss term, under-reading space-heating demand ~32% and + # over-reading SAP +7 (calc 81 vs lodged 74). The correctly labelled + # top-floor sibling 715871 (6027574), same block + same flat roof, + # already computes the lodged 74. + # + # `roof_insulation_location` is the authoritative per-unit structured + # signal: a determined location (an RdSAP code, not "ND") means the unit + # has an exposed roof to insulate — unlike roof_construction, which the + # gov-API lodges building-wide on every unit. + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + dwelling_type="Mid-floor flat", + sap_building_parts=[ + make_building_part( + roof_insulation_location=6, # flat roof — a determined location + floor_dimensions=[ + make_floor_dimension(total_floor_area_m2=_TYPICAL_TFA_M2, floor=0), + ], + ), + ], + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + ), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — the floor stays party (dwelling below) but the determined + # roof-insulation location exposes the roof, matching the top-floor + # sibling. + assert inputs.heat_transmission.floor_w_per_k == 0.0 + assert inputs.heat_transmission.roof_w_per_k > 0 + + def test_top_floor_flat_keeps_roof_drops_floor() -> None: # Arrange — Top-floor flat: party floor, external roof. epc = make_minimal_sap10_epc( diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 307ebb1d..496ddd31 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -193,7 +193,7 @@ _CORPUS = Path( # within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst # stress worksheet (simulated case 46): closed its last ventilation residual # (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst). -_MIN_WITHIN_HALF_SAP = 0.72 +_MIN_WITHIN_HALF_SAP = 0.73 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, @@ -226,7 +226,19 @@ _MIN_WITHIN_HALF_SAP = 0.72 # old flat default — so flipping the default had silently turned 190 PCDB # keep-hot combis into no-keep-hot). Closes case 49 EXACTLY (cost £726.696, # SAP 72 = the worksheet to the digit). -_MAX_SAP_MAE = 0.775 +# Then 0.774 -> 0.761 (within-0.5 73.3% -> 73.6%) via the mislabelled-top-floor +# roof-exposure fix (RdSAP 10 §3 / §5): a gov-API flat lodged "Mid-floor" but +# carrying a determined `roof_insulation_location` (an RdSAP code, not "ND") +# has its own exposed roof — a top-floor flat mislabelled mid-floor — so the +# roof heat-loss term must NOT be dropped. `_dwelling_exposure` now reads the +# structured location field (not the dwelling_type label alone). Motivated by +# property 715363 (uprn 6027561): roof dropped under-read space-heating demand +# ~32% (1833 vs lodged RHI 2694), over-read SAP +7 (81 vs lodged 74); the +# correctly labelled top-floor sibling 715871 (same block, same flat roof) +# already computes 74. roof_insulation_location="ND" ⟺ party ceiling separates +# the corpus classes with zero disagreement (all 190 party flats lodge "ND"); +# the 4 mid/ground-floor flats this exposes all move toward lodged, 0 away. +_MAX_SAP_MAE = 0.762 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.5 # kWh / m2 / yr vs energy_consumption_current