From eb5bb89612de5811e9ddb861ae11c19cc3880b63 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 10 Jun 2026 14:46:54 +0000 Subject: [PATCH] =?UTF-8?q?Map=2020.0.0=20lighting,=20ventilation=20and=20?= =?UTF-8?q?hot-water=20demand=20fields=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 35 +++++- .../domain/tests/test_from_rdsap_schema.py | 104 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b8fd3312..7c867ed3 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1077,6 +1077,8 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_20_0_0(schema: RdSapSchema20_0_0) -> EpcPropertyData: es = schema.sap_energy_source + # ADR-0027: instantaneous_wwhrs holds bath/shower ROOM counts. + iw = schema.sap_heating.instantaneous_wwhrs return EpcPropertyData( uprn=schema.uprn, assessment_type=schema.assessment_type, @@ -1110,12 +1112,27 @@ class EpcPropertyDataMapper: heated_rooms_count=schema.heated_room_count, wet_rooms_count=0, extensions_count=schema.extensions_count, - open_chimneys_count=0, + open_chimneys_count=schema.open_fireplaces_count, insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=None, + percent_draughtproofed=schema.percent_draughtproofed, + # ADR-0027: 20.0.0 has no flue/fan/vent counts (calculator defaults + # them via RdSAP Table 5), but sheltered_sides must come from + # built_form — else the calculator assumes mid-terrace (2) for all. + sap_ventilation=SapVentilation( + sheltered_sides=_api_sheltered_sides(schema.built_form), + ), + # ADR-0027: 20.0.0 gives total + low-energy OUTLET counts, not an + # LED/CFL/incandescent split. Low-energy → the calculator's LEL + # path (unknown LED/CFL split); the remainder is incandescent (1 + # outlet ≈ 1 bulb). Fixes a 439-cert lighting understatement. led_fixed_lighting_bulbs_count=0, cfl_fixed_lighting_bulbs_count=0, - incandescent_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=( + schema.fixed_lighting_outlets_count + - schema.low_energy_fixed_lighting_outlets_count + ), + low_energy_fixed_lighting_bulbs_count=schema.low_energy_fixed_lighting_outlets_count, roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), @@ -1131,6 +1148,20 @@ class EpcPropertyDataMapper: sap_heating=SapHeating( # 20.0.0 uses room counts not product index numbers; domain fields default to None instantaneous_wwhrs=InstantaneousWwhrs(), + # ADR-0027: derive HW demand counts from the bath/shower ROOM + # counts (instantaneous_wwhrs is the false-friend container). + number_baths=( + iw.rooms_with_bath_and_or_shower + + iw.rooms_with_bath_and_mixer_shower + if iw is not None + else None + ), + mixer_shower_count=( + iw.rooms_with_mixer_shower_no_bath + + iw.rooms_with_bath_and_mixer_shower + if iw is not None + else None + ), main_heating_details=[ MainHeatingDetail( has_fghrs=d.has_fghrs == "Y", diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 73b10217..f536cbb7 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -1261,3 +1261,107 @@ class TestRdSap20_0_0ReducedFieldSynthesis: # Assert — cascade remaps 1 ("DG pre-2002") -> 2 (double), not raw 1. assert all(w.glazing_type == 2 for w in result.sap_windows) + + def test_lighting_counts_incandescent_remainder_and_low_energy_as_lel( + self, + ) -> None: + # Arrange — ADR-0027: 20.0.0 gives total + low-energy OUTLET counts, not + # an LED/CFL/incandescent split. The non-low-energy remainder is + # incandescent (else lighting energy is understated for the 439/1000 + # certs that have any); low-energy → the calculator's LEL path (unknown + # LED/CFL split). A cert with some incandescent outlets. + corpus = _load_20_0_0_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested") + cert = next( + ( + c + for c in corpus + if not c.get("sap_windows") + and (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"] + + # Act + result = EpcPropertyDataMapper.from_api_response(cert) + + # Assert + assert result.incandescent_fixed_lighting_bulbs_count == total - low + assert result.low_energy_fixed_lighting_bulbs_count == low + assert result.led_fixed_lighting_bulbs_count == 0 + assert result.cfl_fixed_lighting_bulbs_count == 0 + + def test_ventilation_maps_chimneys_draughtproofing_and_sheltered_sides( + self, + ) -> None: + # Arrange — ADR-0027: 20.0.0 lodges open_fireplaces_count (currently + # dropped → -80 m³/h/chimney for 53 certs), percent_draughtproofed, and + # built_form. Build sap_ventilation with sheltered_sides from built_form + # (else the calculator defaults every dwelling to mid-terrace=2). A cert + # with an open fireplace. + from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage] + + corpus = _load_20_0_0_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-20.0.0 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") + + # Act + result = EpcPropertyDataMapper.from_api_response(cert) + + # Assert + 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: + # Arrange — ADR-0027: 20.0.0's instantaneous_wwhrs carries bath/shower + # ROOM counts (a false-friend for the WWHR device index). Derive + # number_baths and mixer_shower_count from them so HW demand isn't pinned + # to the calculator's modal 1-bath default (496/1000 have ≠1 bath). + corpus = _load_20_0_0_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-20.0.0 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" + ] + + # Act + result = EpcPropertyDataMapper.from_api_response(cert) + + # Assert + assert result.sap_heating.number_baths == expected_baths + assert result.sap_heating.mixer_shower_count == expected_mixers