From d501535cbc2332d25260d25a349ca012fe79ba27 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:37:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(mapper):=20map=20dropped=20=C2=A76.1=20non-?= =?UTF-8?q?separated=20conservatory=20(API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov API lodges a NON-SEPARATED conservatory (conservatory_type=4) as a glazed "building part" carrying only {floor_area, room_height, double_glazed, glazed_perimeter} — no fabric, no floor dimensions. The four fields were undeclared on the 21.0.1 SapBuildingPart, so `from_dict` dropped them and the conservatory was silently lost: it billed no §6.1 window/rooflight/floor and added nothing to TFA (5 corpus certs over-rated — too little heat loss → SAP too high). Fix (21.0.1 schema + mapper): - declare the four glazed fields on `SapBuildingPart`; - `_api_sap_conservatory` builds `EpcPropertyData.sap_conservatory` from the glazed BP (identified by a lodged `glazed_perimeter`; only type-4 conservatories lodge it — separated ones, §6.2, lodge nothing); - exclude the glazed BP from the fabric building-part loop (it is billed by the §6.1 cascade, not as a dwelling part); - `_total_floor_area_from_building_parts` adds the conservatory floor area to TFA (drives occupancy → §4/§5 demand). Validation is cross-mapper parity, NOT a corpus back-solve: the API mapper feeds the SAME worksheet-validated §6.1 cascade (`conservatory_geometry`, pinned to 1e-4 against the case-44 Summary) as the Elmhurst path — so the API conservatory fabric is correct by construction. `from_api_response` on an injected type-4 cert reproduces the glazed wall (perimeter × ground- floor room height = 22.05), glazed roof (floor/cos20 = 12.77) and Table 25 double U_eff (2.758 wall / 2.993 roof); a separated (type 2/3) cert lodges no glazed BP → disregarded per §6.2. Gauges: corpus within-0.5 67.9% → 68.6% (MAE 0.959 → 0.942; floor 0.67→0.68, ceiling 0.97→0.95); /tmp eval mean|err| 0.822 → 0.817. Harness 47/47 0-raised; regression = the 3 pre-existing fails; pyright net-zero (65=65). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 40 +++++++++ .../domain/tests/test_from_rdsap_schema.py | 85 +++++++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 10 +++ .../epc_client/test_sap_accuracy_corpus.py | 8 +- 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c2b5c2cc..84a1dba8 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2091,8 +2091,14 @@ class EpcPropertyDataMapper: else None ), ) + # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the + # fabric loop; it is carried as `sap_conservatory` below and + # billed by the §6.1 cascade (window/rooflight/floor), not as + # a dwelling building part. for bp in schema.sap_building_parts + if getattr(bp, "glazed_perimeter", None) is None ], + sap_conservatory=_api_sap_conservatory(schema.sap_building_parts), renewable_heat_incentive=RenewableHeatIncentive( space_heating_kwh=float( schema.renewable_heat_incentive.space_heating_existing_dwelling @@ -2426,6 +2432,33 @@ def _measurement_value(field: Any) -> float: return float(field) +def _api_sap_conservatory(building_parts: Any) -> Optional[SapConservatory]: + """Build the domain `SapConservatory` from the gov-API glazed + conservatory building part — the part the API uses for a NON-SEPARATED + conservatory (RdSAP 10 §6.1, PDF p.49), identified by a lodged + `glazed_perimeter` (real dwelling parts carry fabric + floor dimensions + instead, never `glazed_perimeter`). Only type-4 (non-separated) + conservatories lodge this BP; separated ones (§6.2) lodge nothing, so + its presence is the §6.1 signal. Mirror of `_map_elmhurst_conservatory` + for the API path — proven equivalent by cross-mapper parity (the cascade + reads `epc.sap_conservatory` identically). Returns None when absent.""" + if not building_parts: + return None + for bp in building_parts: + if getattr(bp, "glazed_perimeter", None) is None: + continue + return SapConservatory( + floor_area_m2=_measurement_value(bp.floor_area), + glazed_perimeter_m=_measurement_value(bp.glazed_perimeter), + double_glazed=bp.double_glazed == "Y", + # The gov API only lodges this glazed BP for NON-separated + # (type-4) conservatories; separated ones (§6.2) lodge no BP. + thermally_separated=False, + room_height_storeys=float(_measurement_value(bp.room_height)), + ) + return None + + def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float]: """Sum per-bp `sap_floor_dimensions[*].total_floor_area` (plus each bp's `sap_room_in_roof.floor_area` when present) to recover the precise @@ -2450,6 +2483,13 @@ def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float total = 0.0 found = False for bp in building_parts: + # RdSAP 10 §6.1 — a non-separated conservatory's glazed BP (no floor + # dimensions) adds its floor area to the dwelling TFA. TFA drives + # occupancy → §4/§5 demand, so the conservatory must be in the sum. + if getattr(bp, "glazed_perimeter", None) is not None: + total += _measurement_value(bp.floor_area) + found = True + continue floor_dims: Any = bp.sap_floor_dimensions or [] for fd in floor_dims: total += _measurement_value(fd.total_floor_area) diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 84795363..77be7628 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2272,3 +2272,88 @@ class TestRoomInRoofType2SimplifiedQuadratic: assert len(party) == 1 and abs(party[0].area_m2 - 12.50) <= 1e-9 assert len(commons) == 2 assert abs(commons[0].area_m2 - 10.00) <= 1e-9 + + +class TestNonSeparatedConservatoryApiMirror: + """RdSAP 10 §6.1 (PDF p.49) — the gov API lodges a NON-SEPARATED + conservatory (conservatory_type=4) as a glazed "building part" carrying + only {floor_area, room_height, double_glazed, glazed_perimeter}. The + block was undeclared → `from_dict` dropped it → the conservatory was + silently lost (5 corpus certs over-rating). The mapper now splits it + into `EpcPropertyData.sap_conservatory`, excludes it from the fabric + building-part loop, and adds its floor area to TFA. + + Validation is cross-mapper parity, NOT a corpus back-solve: the API + mapper feeds the SAME worksheet-validated §6.1 cascade + (`conservatory_geometry`, pinned to 1e-4 against the case-44 Summary) + as the Elmhurst path — so the API conservatory fabric is correct by + construction.""" + + def test_from_api_response_splits_out_conservatory_building_part( + self, + ) -> None: + # Arrange — a 1-BP dwelling (ground-floor room height 2.45 m) plus a + # non-separated double-glazed conservatory glazed BP. + from datatypes.epc.domain.epc_property_data import SapConservatory + from domain.sap10_calculator.worksheet.conservatory import ( + conservatory_geometry, + ) + + baseline_tfa = EpcPropertyDataMapper.from_api_response( + load("21_0_1.json") + ).total_floor_area_m2 + + cert = load("21_0_1.json") + cert["conservatory_type"] = 4 + cert["sap_building_parts"].append( + { + "floor_area": 12.0, + "room_height": 1, + "double_glazed": "Y", + "glazed_perimeter": 9.0, + } + ) + + # Act + epc = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — conservatory split out; the glazed BP is NOT a fabric part. + assert epc.sap_conservatory == SapConservatory( + floor_area_m2=12.0, + glazed_perimeter_m=9.0, + double_glazed=True, + thermally_separated=False, + room_height_storeys=1.0, + ) + assert len(epc.sap_building_parts) == 1 + # §6.1: the conservatory floor area joins TFA (drives occupancy). + assert abs(epc.total_floor_area_m2 - (baseline_tfa + 12.0)) <= 1e-9 + + # Cross-mapper parity: the shared §6.1 cascade derives the same + # surfaces it does for the case-44 Summary — glazed wall = exposed + # perimeter × ground-floor room height (9.0 × 2.45 = 22.05); glazed + # roof = floor / cos(20°) (12.0 / 0.9397 = 12.77); Table 25 double + # U_eff = 1/(1/3.1 + 0.04) = 2.758 (wall) / 1/(1/3.4 + 0.04) = 2.993. + geom = conservatory_geometry(epc) + assert geom is not None + assert abs(geom.glazed_wall_area_m2 - 22.05) <= 1e-4 + assert abs(geom.glazed_roof_area_m2 - 12.77) <= 1e-4 + assert abs(geom.wall_u_eff - 2.7580) <= 1e-4 + assert abs(geom.roof_u_eff - 2.9930) <= 1e-4 + + def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None: + # Arrange — a separated conservatory (type 2/3) lodges NO glazed BP + # (verified across the gov corpus); the dwelling is unchanged. + from domain.sap10_calculator.worksheet.conservatory import ( + conservatory_geometry, + ) + + cert = load("21_0_1.json") + cert["conservatory_type"] = 2 + + # Act + epc = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — §6.2: disregarded; no conservatory geometry. + assert epc.sap_conservatory is None + assert conservatory_geometry(epc) is None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 0c6379d9..45f1ee46 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -382,6 +382,16 @@ class SapBuildingPart: # redacts the backing insulation. Previously undeclared → dropped. wall_u_value: Optional[float] = None floor_u_value: Optional[float] = None + # RdSAP 10 §6.1 (PDF p.49) — a NON-SEPARATED conservatory is lodged by + # the gov API as a glazed "building part" carrying ONLY these four + # fields (no fabric, no floor dimensions); `conservatory_type == 4` at + # the property level. Previously undeclared → dropped by `from_dict`, + # so the conservatory was silently lost on the API path. The mapper + # splits this BP out into `EpcPropertyData.sap_conservatory`. + floor_area: Optional[Union[Measurement, int, float]] = None + room_height: Optional[Union[Measurement, int, float]] = None + double_glazed: Optional[str] = None + glazed_perimeter: Optional[Union[Measurement, int, float]] = None @dataclass diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 5f8d3669..1cddd87f 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -98,8 +98,12 @@ _CORPUS = Path( # The §3.9.2 Simplified Type-2 RR mapper (room_in_roof_type_2: gable quadratic + # common-wall L×(0.25+H), MIRRORING the worksheet-validated Summary path, # cross-mapper-parity-exact on cert 000565) -> 67.9% (MAE 0.959). -_MIN_WITHIN_HALF_SAP = 0.67 -_MAX_SAP_MAE = 0.97 +# The §6.1 non-separated conservatory mapper (the gov API's glazed building +# part → SapConservatory → §6.1 window/rooflight/floor cascade + TFA, MIRRORING +# the case-44 Summary path pinned to 1e-4) -> 68.6% (MAE 0.942). 5 type-4 +# certs were over-rating (conservatory dropped → too little heat loss). +_MIN_WITHIN_HALF_SAP = 0.68 +_MAX_SAP_MAE = 0.95 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current