diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 62fe48069..046baa622 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4607,7 +4607,23 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { # here too rather than left half-fixed. _API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { 1: "Flat", - 3: "Pitched (slates/tiles), no access to loft", + # RdSAP 10 item 2-3(h) "another dwelling above" — U=0 (spec p.45: "There is + # no heat loss through the roof of a building part that has the same + # dwelling above or another dwelling above"). Previously mislabelled + # "Pitched (slates/tiles), no access to loft" (that is code 5), so the + # per-part party override at `heat_transmission.py:1172` never matched. + # 190 of the 200 corpus code-3 parts sit at index 0 and are already + # suppressed by `_cert_lodges_exposed_roof` off the `roof_insulation_location + # == "ND"` signal; the unmitigated set is the 10 parts at index > 0, where + # extensions default to exposed and were billed a phantom pitched roof. + # Also removes a latent PV defect: the bogus "Pitched" prefix satisfied + # `is_pitched` (cert_to_inputs.py:3559) and divided PV array area by + # cos(35°), inflating kWp. Corpus evidence (1000 certs, 200 code-3 parts): + # 200/200 lodge roof_insulation_location "ND", 186/200 lodge the roof + # description literally "(another dwelling above)", zero lodge a "no access + # to loft" description, and no top-floor dwelling ever lodges it. Codes 7 + # and 9 were corrected by an earlier slice; 3 was left behind. + 3: "(another dwelling above)", 4: "Pitched (slates/tiles), access to loft", 5: "Pitched (vaulted ceiling)", 6: None, diff --git a/tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py b/tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py new file mode 100644 index 000000000..ba3fe4f1c --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py @@ -0,0 +1,78 @@ +"""Regression: gov-API `roof_construction = 3` is "another dwelling above" +(RdSAP 10 item 2-3(h), U=0), NOT a pitched roof. + +RdSAP 10 item 2-3 "Above the building part" (spec PDF p.72, printed p.71) lists +the codes and their treatment; (g) "same dwelling above" and (h) "another +dwelling above" are both U=0, and p.45 states it plainly: "There is no heat loss +through the roof of a building part that has the same dwelling above or another +dwelling above." + +The mapper labelled code 3 "Pitched (slates/tiles), no access to loft", so the +per-part party override in `heat_transmission.py` — which matches on the literal +string "another dwelling above" in `roof_construction_type` — never fired. The +building part then fell through to the dwelling-level `has_exposed_roof` +heuristic, which is keyed only on the `dwelling_type` LABEL. Where that label +says top-floor (or the part is an extension, billed exposed unconditionally), we +invented an entire pitched roof over a lodged party ceiling. + +Evidence that code 3 is a party ceiling, from the committed RdSAP-21.0.1 corpus +(1000 certs, 200 code-3 building parts): + - 200/200 lodge `roof_insulation_location == "ND"` (not determined — there is + no insulation to determine on a party ceiling); + - 186/200 lodge the cert's roof description as literally + "(another dwelling above)"; + - dwelling types are exclusively non-top-floor (Mid-floor flat 104, + Ground-floor flat 86, Ground-floor maisonette 7, Mid-floor maisonette 1, + Mid-terrace house 2) — no top-floor dwelling ever lodges it. + +Codes 7 and 9 were corrected to the party string by an earlier slice; code 3 was +left behind. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +_FIXTURE = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json") + +_PARTY_CEILING = "another dwelling above" + + +def _load_with_roof_construction(code: int) -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + for part in data["sap_building_parts"]: + part["roof_construction"] = code + return data + + +def test_roof_construction_3_maps_to_a_party_ceiling() -> None: + # Arrange — the lodged part has another dwelling above it (API code 3). + data = _load_with_roof_construction(3) + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — carries the party-ceiling marker the per-part override matches on, + # so the roof is billed at U=0 rather than as an invented pitched roof. + assert epc is not None + assert epc.sap_building_parts + for part in epc.sap_building_parts: + assert _PARTY_CEILING in (part.roof_construction_type or "").lower() + + +def test_roof_construction_4_remains_a_real_pitched_roof() -> None: + # Arrange — code 4 is 2-3(a) "pitched roof, access to loft", a REAL roof. + data = _load_with_roof_construction(4) + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — must not be swept up by the code-3 correction. + assert epc is not None + assert epc.sap_building_parts + for part in epc.sap_building_parts: + assert _PARTY_CEILING not in (part.roof_construction_type or "").lower() diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 56b826bbb..2d9f27369 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -267,7 +267,7 @@ _CORPUS = Path( # unheated corridor, 3 = unheated stairwell), which was read NOWHERE under # domain/, so every sheltered wall took the corridor 0.5 and every stairwell # flat over-billed its sheltered wall. 23 corpus certs; within-0.5 +1.0pp. -_MIN_WITHIN_HALF_SAP = 0.798 +_MIN_WITHIN_HALF_SAP = 0.796 # 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, @@ -393,7 +393,7 @@ _MIN_WITHIN_HALF_SAP = 0.798 # The -0.672 overshoot is expected to move again when the C4 plant- # efficiency and C3.2 pumping siblings land. Unit-pinned in # test_cert_to_inputs. -_MAX_SAP_MAE = 0.614 +_MAX_SAP_MAE = 0.611 _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