diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 216af22c..88ab7f14 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -4361,6 +4361,32 @@ def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> Non assert code == 1 +def test_elmhurst_roof_construction_int_matches_api_codes() -> None: + # Arrange — cross-mapper structural parity: the gov-EPC API mapper + # populates BOTH roof_construction (int) and roof_construction_type + # (str derived via `_API_ROOF_CONSTRUCTION_TO_STR`), but the Elmhurst + # mapper set only the string, leaving the int None. The SAP cascade + # reads the string (so SAP parity held), but consumers of the int + # (e.g. domain/sap10_ml ML aggregates) saw None on every site-notes + # cert. `_elmhurst_roof_construction_int` closes the gap, mapping the + # Elmhurst roof code to the same SAP10 int the API lodges. Unmapped + # codes return None (not a raise) — the int is not cascade-load- + # bearing, so an unknown roof must not block the cert. + from datatypes.epc.domain.mapper import _elmhurst_roof_construction_int # pyright: ignore[reportPrivateUsage] + + # Act / Assert — each Elmhurst roof code → the gov-EPC API int. + assert _elmhurst_roof_construction_int("F Flat") == 1 + assert _elmhurst_roof_construction_int("PN Pitched (slates/tiles), no access") == 3 + assert _elmhurst_roof_construction_int("PA Pitched (slates/tiles), access to loft") == 4 + assert _elmhurst_roof_construction_int("PS Pitched, sloping ceiling") == 8 + assert _elmhurst_roof_construction_int("S Same dwelling above") == 7 + assert _elmhurst_roof_construction_int("A Another dwelling above") == 7 + # Absent / unmapped → None (no raise; not cascade-load-bearing). + assert _elmhurst_roof_construction_int(None) is None + assert _elmhurst_roof_construction_int("") is None + assert _elmhurst_roof_construction_int("NR Non-residential space above") is None + + def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None: # Arrange — "SY System build" and "B Basement wall" both map to SAP10 # wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 57edae7a..2c272975 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2314,6 +2314,40 @@ def _elmhurst_dwelling_type( return f"{position} flat" +# Elmhurst roof-type codes → SAP10 roof_construction integer, matching the +# gov-EPC API codes in `_API_ROOF_CONSTRUCTION_TO_STR` so the two +# front-ends populate the same field. Harvested from the committed +# Elmhurst Summary fixtures (corpus + cohort): F/PN/PA/PS/S/A. Vaulted (5) +# and thatched (6) are omitted until a fixture surfaces their Elmhurst +# codes. NR ("Non-residential space above") is intentionally left +# unmapped — the gov enum's code 7 is specifically "dwelling above". +_ELMHURST_ROOF_CODE_TO_SAP10: Dict[str, int] = { + "F": 1, # Flat + "PN": 3, # Pitched (slates/tiles), no access (to loft) + "PA": 4, # Pitched (slates/tiles), access to loft + "PS": 8, # Pitched, sloping ceiling + "S": 7, # Same dwelling above + "A": 7, # Another dwelling above +} + + +def _elmhurst_roof_construction_int(coded: Optional[str]) -> Optional[int]: + """Map an Elmhurst roof_type string ('PA Pitched (slates/tiles), + access to loft') to the SAP10 `roof_construction` integer the gov-EPC + API lodges (4), so the site-notes and API front-ends populate the + same field (cross-mapper structural parity). + + Returns None for an absent or unmapped code — and, unlike + `_elmhurst_wall_construction_int`, does NOT raise. `roof_construction` + (int) is not read by the SAP cascade (which reads the string + `roof_construction_type`, populated on both paths), so an unmapped + roof code stays None — the pre-existing Elmhurst behaviour — rather + than blocking the cert.""" + if not coded: + return None + return _ELMHURST_ROOF_CODE_TO_SAP10.get(_leading_code(coded)) + + def _elmhurst_wall_construction_int(coded: str) -> Optional[int]: """Map an Elmhurst wall_type string ('CA Cavity') to the SAP10 integer code (4). Returns None when the lodging is absent (empty @@ -3494,6 +3528,7 @@ def _map_elmhurst_building_part( if walls.insulation_thickness_mm is not None else None ), + roof_construction=_elmhurst_roof_construction_int(roof.roof_type), roof_construction_type=_strip_code(roof.roof_type), roof_insulation_location=_strip_code(roof.insulation), roof_insulation_thickness=_resolve_sloping_ceiling_thickness( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py index 1c7f4787..531381b9 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py @@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -98,6 +100,8 @@ def build_epc() -> EpcPropertyData: ) extension_1 = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -130,6 +134,8 @@ def build_epc() -> EpcPropertyData: ) extension_2 = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_2, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=3, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py index a73f047b..f87730d9 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py @@ -63,6 +63,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py index 145ef3d0..490091da 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py @@ -64,6 +64,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -133,6 +135,8 @@ def build_epc() -> EpcPropertyData: ) extension = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py index a5cbe9b4..4c82aa7f 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py @@ -60,6 +60,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, # "A As Built" @@ -130,6 +132,8 @@ def build_epc() -> EpcPropertyData: ) extension = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py index f06194a4..a9e26137 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py @@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -97,6 +99,8 @@ def build_epc() -> EpcPropertyData: ) extension = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py index c561b353..2745b20a 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py @@ -69,6 +69,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=3, construction_age_band="A", wall_construction=_WC_CAVITY, wall_insulation_type=4,