diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 8f8dec1a..514e78e2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -607,12 +607,7 @@ class EpcPropertyDataMapper: uprn=schema.uprn, assessment_type=schema.assessment_type, sap_version=schema.sap_version, - # ADR-0028: 17.1 lodges dwelling_type as str OR localised dict. - dwelling_type=( - schema.dwelling_type - if isinstance(schema.dwelling_type, str) - else schema.dwelling_type.value - ), + dwelling_type=schema.dwelling_type.value, property_type=str(schema.property_type), built_form=str(schema.built_form), address_line_1=schema.address_line_1, @@ -706,7 +701,7 @@ class EpcPropertyDataMapper: percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, ) ) - if getattr(es.photovoltaic_supply, "none_or_no_details", None) + if es.photovoltaic_supply else None ), ), @@ -2135,14 +2130,6 @@ class EpcPropertyDataMapper: from_dict(RdSapSchema18_0, data) ) ) - if schema == "RdSAP-Schema-17.1": - from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 - - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_17_1( - from_dict(RdSapSchema17_1, data) - ) - ) raise ValueError(f"Unsupported EPC schema: {schema!r}") diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index b71c58a8..747702bb 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -1744,3 +1744,192 @@ class TestRdSap17_1ReducedFieldSynthesis: # Assert assert isinstance(result, EpcPropertyData) + + def test_rich_cert_uses_lodged_window_area_for_geometry(self) -> None: + # ADR-0028: 14/1000 17.1 certs lodge a per-window sap_windows array + # (band-4); use lodged window_area as geometry, not synthesised. + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next((c for c in corpus if c.get("sap_windows")), None) + if cert is None: + pytest.skip("no corpus cert lodging sap_windows") + lodged = cert["sap_windows"] + expected_total = sum(w["window_area"]["value"] for w in lodged) + + result = EpcPropertyDataMapper.from_api_response(cert) + + assert len(result.sap_windows) == len(lodged) + total_area = sum(w.window_width * w.window_height for w in result.sap_windows) + assert total_area == pytest.approx(expected_total) + + def test_band_normal_synthesises_total_glazing_at_0_148_of_floor_area( + self, + ) -> None: + # ADR-0028: band-1 (969/1000) synthesises total glazing = 0.148 x TFA, + # the inherited 20.0.0 coefficient (validated vs 17.1's band-4 rich certs). + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next( + (c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 1), + None, + ) + if cert is None: + pytest.skip("no band-1 corpus cert without sap_windows") + tfa = float(cert["total_floor_area"]) + + result = EpcPropertyDataMapper.from_api_response(cert) + + assert len(result.sap_windows) == 4 + assert all(w.window_height == 1.0 for w in result.sap_windows) + assert sorted(w.orientation for w in result.sap_windows) == [1, 3, 5, 7] + total_area = sum(w.window_width * w.window_height for w in result.sap_windows) + assert total_area == pytest.approx(0.148 * tfa) + + def test_band_more_than_typical_scales_glazing_by_1_25(self) -> None: + # ADR-0028: band 2 ("More than typical") = inherited 1.25 multiplier. + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next( + (c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 2), + None, + ) + if cert is None: + pytest.skip("no band-2 corpus cert without sap_windows") + tfa = float(cert["total_floor_area"]) + + result = EpcPropertyDataMapper.from_api_response(cert) + + total_area = sum(w.window_width * w.window_height for w in result.sap_windows) + assert total_area == pytest.approx(0.148 * tfa * 1.25) + + def test_synthesised_glazing_type_routed_through_cascade(self) -> None: + # ADR-0028: multiple_glazing_type shares 20.0.0's code space — route + # through the cascade so code 1 ("DG pre-2002") remaps to 2, not single. + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next( + ( + c + for c in corpus + if not c.get("sap_windows") and c.get("multiple_glazing_type") == 1 + ), + None, + ) + if cert is None: + pytest.skip("no corpus cert with multiple_glazing_type=1") + + result = EpcPropertyDataMapper.from_api_response(cert) + + assert all(w.glazing_type == 2 for w in result.sap_windows) + + def test_synthesised_glazing_type_handles_not_defined_code(self) -> None: + # ADR-0028: "ND" (56/1000) maps to a valid INTEGER glazing_type (DG-modal), + # never the raw string on an int field. + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next( + ( + c + for c in corpus + if not c.get("sap_windows") and c.get("multiple_glazing_type") == "ND" + ), + None, + ) + if cert is None: + pytest.skip("no windowless corpus cert with multiple_glazing_type ND") + + result = EpcPropertyDataMapper.from_api_response(cert) + + assert result.sap_windows + assert all(isinstance(w.glazing_type, int) for w in result.sap_windows) + + def test_lighting_counts_incandescent_remainder_and_low_energy_as_lel( + self, + ) -> None: + # ADR-0028: total + low-energy OUTLET counts -> incandescent remainder + + # low-energy as LEL. + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next( + ( + c + for c in corpus + if (c.get("fixed_lighting_outlets_count") or 0) + > (c.get("low_energy_fixed_lighting_outlets_count") or 0) + ), + None, + ) + if cert is None: + pytest.skip("no corpus cert with incandescent lighting") + total = cert["fixed_lighting_outlets_count"] + low = cert["low_energy_fixed_lighting_outlets_count"] + + result = EpcPropertyDataMapper.from_api_response(cert) + + assert result.incandescent_fixed_lighting_bulbs_count == total - low + assert result.low_energy_fixed_lighting_bulbs_count == low + + def test_ventilation_maps_chimneys_draughtproofing_and_sheltered_sides( + self, + ) -> None: + # ADR-0028: open_fireplaces_count -> chimneys, percent_draughtproofed, + # sheltered_sides from built_form. + from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage] + + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next( + ( + c + for c in corpus + if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1 + ), + None, + ) + if cert is None: + pytest.skip("no corpus cert with an open fireplace") + + result = EpcPropertyDataMapper.from_api_response(cert) + + assert result.open_chimneys_count == cert["open_fireplaces_count"] + assert result.percent_draughtproofed == cert["percent_draughtproofed"] + assert result.sap_ventilation is not None + assert result.sap_ventilation.sheltered_sides == _api_sheltered_sides( + cert["built_form"] + ) + + def test_hot_water_derives_bath_and_mixer_counts_from_room_counts(self) -> None: + # ADR-0028: instantaneous_wwhrs ROOM counts -> number_baths/mixer_shower. + corpus = _load_17_1_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-17.1 corpus harvested") + cert = next( + ( + c + for c in corpus + if not c.get("sap_windows") + and c.get("sap_heating", {}).get("instantaneous_wwhrs") + ), + None, + ) + if cert is None: + pytest.skip("no corpus cert with instantaneous_wwhrs") + iw = cert["sap_heating"]["instantaneous_wwhrs"] + expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[ + "rooms_with_bath_and_mixer_shower" + ] + expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[ + "rooms_with_bath_and_mixer_shower" + ] + + result = EpcPropertyDataMapper.from_api_response(cert) + + assert result.sap_heating.number_baths == expected_baths + assert result.sap_heating.mixer_shower_count == expected_mixers