diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 4159bf658..deabcf55a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2454,6 +2454,7 @@ class EpcPropertyDataMapper: sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, is_flat=schema.property_type == 2, + built_form=schema.built_form, ), sap_alternative_wall_1=( SapAlternativeWall( @@ -2790,6 +2791,7 @@ class EpcPropertyDataMapper: sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, is_flat=schema.property_type == 2, + built_form=schema.built_form, ), sap_alternative_wall_1=( SapAlternativeWall( @@ -5561,8 +5563,16 @@ _API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = { 3: "connected_wall", } +# RdSAP 10 §1.1 (p.7-8): built forms whose BOTH gable ends are party walls, so +# neither can be a genuinely exposed gable. 4 = Mid-Terrace, 6 = Enclosed +# Mid-Terrace. End-terrace {3, 5}, semi-detached {2} and detached {1} each have +# at least one legitimately exposed gable and are left untouched. +_BOTH_ENDS_PARTY_BUILT_FORMS: Final[frozenset[int]] = frozenset({4, 6}) -def _api_type_1_gable_kind(gable_type: Optional[int]) -> str: + +def _api_type_1_gable_kind( + gable_type: Optional[int], *, built_form: Optional[int] = None +) -> str: """Map a `gable_wall_type_*` code to the cascade's RR surface kind. `None` (type unlodged) defaults to `gable_wall` (party) — the modal @@ -5570,16 +5580,35 @@ def _api_type_1_gable_kind(gable_type: Optional[int]) -> str: main-wall U). A lodged integer outside the known set raises `UnmappedApiCode` so a new gable variant forces an explicit mapping rather than silently mis-routing its U-value (mirror of the strict- - raise pattern on the other API helpers).""" + raise pattern on the other API helpers). + + `built_form` consistency guard: a mid-terrace / enclosed-mid-terrace has + external walls on its two opposite (front/rear) faces only (RdSAP 10 §1.1, + p.7-8); both end/gable elevations — and their room-in-roof continuations — + abut a neighbour and are party (§1.4.3, p.9). So an "Exposed" RR gable + lodged on such a form is itself the data anomaly: reclassify it to a Party + gable (Table 4, p.22 → U=0.25) rather than billing it at the main-wall U. + Corroborated by S10TP-05 Appendix A (a mid-terrace's roof-gable junction + length is zero) and by three accredited Elmhurst mid-terrace worksheets + (000477/000480/000516), which all lodge their RR gables as Party. The API + path lodges no per-gable U-value, so this only ever overrides a cascade + fallback, never an assessor-measured U. Surfaced by corpus cert + 100040550095 (mid-terrace, Exposed RR gable billed 19.32 m² × 2.5 = + 48.3 W/K → SAP -10.13; reclassified to Party → SAP 40, matching lodged).""" if gable_type is None: return "gable_wall" if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND: raise UnmappedApiCode("gable_wall_type", gable_type) - return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type] + kind = _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type] + if kind == "gable_wall_external" and built_form in _BOTH_ENDS_PARTY_BUILT_FORMS: + return "gable_wall" + return kind def _api_type_1_gable_surfaces( type_1: Any, + *, + built_form: Optional[int] = None, ) -> Optional[List[SapRoomInRoofSurface]]: """Translate the Simplified Type 1 scalar gable fields into the per-surface list the cascade's Detailed-RR branch consumes. @@ -5598,7 +5627,7 @@ def _api_type_1_gable_surfaces( continue surfaces.append( SapRoomInRoofSurface( - kind=_api_type_1_gable_kind(gable_type), + kind=_api_type_1_gable_kind(gable_type, built_form=built_form), area_m2=_round_half_up_2dp(float(length), _RIR_TYPE_1_GABLE_HEIGHT_M), ) ) @@ -5609,6 +5638,7 @@ def _api_build_room_in_roof( bp_rir: Any, *, is_flat: bool = False, + built_form: Optional[int] = None, ) -> Optional[SapRoomInRoof]: """Build `SapRoomInRoof` from the API schema's per-bp RR block. Two real-API shapes coexist: @@ -5644,10 +5674,12 @@ def _api_build_room_in_roof( # roof (a ~52 W/K over-count on cert 6035). Gable area = L × the # §3.9.1 default RR storey height (2.45 m); the type code routes # the U-value (Exposed → main-wall U, Party → 0.25). - rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1) + rir.detailed_surfaces = _api_type_1_gable_surfaces( + type_1, built_form=built_form + ) type_2 = getattr(bp_rir, "room_in_roof_type_2", None) if type_2 is not None: - rir.detailed_surfaces = _api_type_2_surfaces(type_2) + rir.detailed_surfaces = _api_type_2_surfaces(type_2, built_form=built_form) details = getattr(bp_rir, "room_in_roof_details", None) if details is not None: rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat) @@ -5656,6 +5688,8 @@ def _api_build_room_in_roof( def _api_type_2_surfaces( type_2: Any, + *, + built_form: Optional[int] = None, ) -> Optional[List[SapRoomInRoofSurface]]: """Translate the §3.9.2 Simplified Type 2 block into the per-surface list the cascade's Detailed-RR branch consumes — MIRRORING the @@ -5704,7 +5738,7 @@ def _api_type_2_surfaces( continue if height <= 0 and not cw_heights: continue - kind = _api_type_1_gable_kind(gable_type) + kind = _api_type_1_gable_kind(gable_type, built_form=built_form) length_m, height_m = float(length), float(height) if cw_heights: if kind == "connected_wall": diff --git a/tests/datatypes/epc/domain/test_mapper_rir_gable_terraced.py b/tests/datatypes/epc/domain/test_mapper_rir_gable_terraced.py new file mode 100644 index 000000000..367ba6e31 --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_rir_gable_terraced.py @@ -0,0 +1,65 @@ +"""Mapper boundary: a room-in-roof gable lodged "Exposed" on a both-ends-party +terraced form must be reclassified to a Party gable (U=0.25), not billed as an +external wall (main-wall U ~2.5). + +RdSAP 10 §1.1 (p.7-8): a mid-terrace has external walls on its two *opposite* +(front/rear) faces only; both end/gable elevations — and their room-in-roof +continuations — abut the neighbours and are party walls (§1.4.3 p.9). Table 4 +(p.22) bills a Party RR gable at U=0.25; an Exposed gable "as common wall". +S10TP-05 Appendix A independently tabulates the roof-gable junction length as +zero for a mid-terrace (Detached 15.6 m, Semi 7.2 m, **Mid-terrace 0**), and +three accredited Elmhurst mid-terrace worksheets (000477/000480/000516) lodge +their gables as Party. So a mid/enclosed-mid terrace lodging an Exposed RR +gable is itself the data anomaly. + +Surfaced by corpus cert 100040550095 (mid-terrace, built_form 4): its Type-2 +RR `gable_wall_type_1 = 1` (Exposed) was billed at the masonry main-wall U +(19.32 m² × 2.5 = 48.3 W/K), over-counting fabric → SAP 29.87 vs lodged 40 +(-10.13). Reclassifying it to a Party gable (0.25 × 19.32 = 4.83 W/K) lands +SAP 40 / PE 189, matching lodged. The correction is scoped to the both-ends- +party forms {4 Mid-Terrace, 6 Enclosed Mid-Terrace}; end-terrace / semi / +detached forms keep their genuine Exposed gable. +""" + +from datatypes.epc.domain.mapper import ( + _api_type_1_gable_kind, # pyright: ignore[reportPrivateUsage] +) + +_EXPOSED = 1 +_PARTY = 0 + + +class TestApiRirGableTerraced: + def test_mid_terrace_exposed_gable_reclassified_to_party(self) -> None: + # built_form 4 = Mid-Terrace: both gable ends are party. + assert _api_type_1_gable_kind(_EXPOSED, built_form=4) == "gable_wall" + + def test_enclosed_mid_terrace_exposed_gable_reclassified_to_party(self) -> None: + # built_form 6 = Enclosed Mid-Terrace: both gable ends party. + assert _api_type_1_gable_kind(_EXPOSED, built_form=6) == "gable_wall" + + def test_end_terrace_exposed_gable_stays_external(self) -> None: + # built_form 3 = End-Terrace: the outer gable is genuinely exposed. + assert _api_type_1_gable_kind(_EXPOSED, built_form=3) == "gable_wall_external" + + def test_enclosed_end_terrace_exposed_gable_stays_external(self) -> None: + # built_form 5 = Enclosed End-Terrace: one genuine exposed gable. + assert _api_type_1_gable_kind(_EXPOSED, built_form=5) == "gable_wall_external" + + def test_semi_detached_exposed_gable_stays_external(self) -> None: + assert _api_type_1_gable_kind(_EXPOSED, built_form=2) == "gable_wall_external" + + def test_detached_exposed_gable_stays_external(self) -> None: + assert _api_type_1_gable_kind(_EXPOSED, built_form=1) == "gable_wall_external" + + def test_unknown_built_form_exposed_gable_unchanged(self) -> None: + # No built form → conservative: keep the lodged Exposed classification. + assert _api_type_1_gable_kind(_EXPOSED, built_form=None) == "gable_wall_external" + + def test_party_gable_unchanged_on_mid_terrace(self) -> None: + # A lodged Party gable stays Party regardless of built form. + assert _api_type_1_gable_kind(_PARTY, built_form=4) == "gable_wall" + + def test_default_call_without_built_form_backwards_compatible(self) -> None: + # The pre-existing single-arg contract still resolves Exposed → external. + assert _api_type_1_gable_kind(_EXPOSED) == "gable_wall_external" diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 8e90dac15..f20ab52a3 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -260,7 +260,20 @@ _CORPUS = Path( # the plant efficiency C4 reserves for CO2/PE is a known follow-up. Spec- # verified against the SAP 10.2 PDF, not the ticket prose; unit-pinned in # test_cert_to_inputs (950/951/952 must bill identical DHW fuel). -_MIN_WITHIN_HALF_SAP = 0.788 +# 78.8% -> 79.5% (MAE 0.625 -> 0.599, PE 2.85 -> 2.71, CO2 0.070 -> 0.068) via +# the terraced room-in-roof gable fix (RdSAP 10 §1.1 p.7-8 + Table 4 p.22): a +# mid-terrace / enclosed-mid-terrace (built_form 4/6) has external walls on its +# two opposite faces only, so both gable ends are party — yet the gov API lodges +# an "Exposed" RR gable (`gable_wall_type=1`) that the built_form-blind mapper +# billed at the masonry main-wall U (main-wall U ~2.5 vs the party 0.25). The +# `_api_type_1_gable_kind` built_form guard reclassifies an Exposed RR gable to +# Party (0.25) on forms {4,6} only; end-terrace/semi/detached keep their genuine +# exposed gable. Corroborated by S10TP-05 App A (mid-terrace roof-gable junction +# length = 0) and three accredited Elmhurst mid-terrace worksheets (000477/ +# 000480/000516 lodge Party gables). Cert 100040550095 -10.13 -> +0.37; the +# 18-cert terraced-external-gable cohort mean -1.94 -> +0.11; end-terrace +# untouched. Unit-pinned in test_mapper_rir_gable_terraced. +_MIN_WITHIN_HALF_SAP = 0.795 # 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, @@ -391,9 +404,9 @@ _MIN_WITHIN_HALF_SAP = 0.788 # efficiency / interlock -5pp — SAP Table 4a immersion = 100%): observed # within-0.5 78.9%, MAE 0.622. A handful of corpus certs with a gas-boiler # space main + separate electric immersion moved closer to accredited. -_MAX_SAP_MAE = 0.625 -_MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current +_MAX_SAP_MAE = 0.599 +_MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: