diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e17b43033..22d9227fb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -620,8 +620,13 @@ class EpcPropertyDataMapper: MainHeatingDetail( has_fghrs=d.has_fghrs == "Y", main_fuel_type=d.main_fuel_type, - boiler_flue_type=None, - fan_flue_present=None, + # Preserve the lodged flue/pump/PCDB fields, matching the + # 19.0/20.0/… paths. The 17.0 schema *does* lodge all of + # these (RdSapSchema17_0.MainHeatingDetail); hardcoding + # them to None silently dropped a PCDB main-heating index + # (efficiency lookup) and the flue/pump SAP adjustments. + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", heat_emitter_type=d.heat_emitter_type, emitter_temperature=d.emitter_temperature, main_heating_number=d.main_heating_number, @@ -629,9 +634,9 @@ class EpcPropertyDataMapper: main_heating_category=d.main_heating_category, main_heating_fraction=d.main_heating_fraction, sap_main_heating_code=d.sap_main_heating_code, - central_heating_pump_age=None, + central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, - main_heating_index_number=None, + main_heating_index_number=d.main_heating_index_number, ) for d in schema.sap_heating.main_heating_details ], @@ -642,7 +647,15 @@ class EpcPropertyDataMapper: water_heating_fuel=schema.sap_heating.water_heating_fuel, immersion_heating_type=schema.sap_heating.immersion_heating_type, cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, - cylinder_thermostat=None, + # Preserve the lodged cylinder thermostat + insulation thickness, + # matching the 19.0/20.0/… paths. Both are lodged by the 17.0 + # schema; hardcoding them to None (a) read as "no cylinder + # thermostat" so the water-heating worksheet over-applied its + # ×1.3 penalty and `recommend_cylinder_thermostat` could fire on + # a dwelling that already has one, and (b) discarded the measured + # insulation thickness so the cylinder-loss cascade fell back to + # age-band defaults. + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, # Preserve the lodged secondary heating from the 17.0 sap_heating # block, matching every other API path (19.0/20.0/…). Hardcoding # these to None silently dropped a lodged FIXED secondary (SAP @@ -654,7 +667,7 @@ class EpcPropertyDataMapper: schema.sap_heating.secondary_heating_type, ), secondary_heating_type=schema.sap_heating.secondary_heating_type, - cylinder_insulation_thickness_mm=None, + cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness, ), # ADR-0028: 990/1000 omit sap_windows -> synthesised from the # glazed_area band + TFA via the shared reduced-field core. The 10 diff --git a/tests/datatypes/epc/domain/test_mapper_sap_heating_17_0_drops.py b/tests/datatypes/epc/domain/test_mapper_sap_heating_17_0_drops.py new file mode 100644 index 000000000..ee7fc7afe --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_sap_heating_17_0_drops.py @@ -0,0 +1,77 @@ +"""Regression: the RdSAP-Schema-17.0 mapper path must preserve the whole lodged +`sap_heating` block, like every other API path (19.0/20.0/…). + +`from_rdsap_schema_17_0` was an older copy that hardcoded a cluster of lodged +`sap_heating` fields to None while the 19.0+ paths read them from +`schema.sap_heating`: + + * MainHeatingDetail.boiler_flue_type / fan_flue_present / central_heating_pump_age + / main_heating_index_number (the last is the PCDB efficiency index) + * SapHeating.cylinder_thermostat — gates the water-heating ×1.3 penalty + and `recommend_cylinder_thermostat` + * SapHeating.cylinder_insulation_thickness — drives the cylinder-loss cascade + +The 17.0 schema lodges all of them (see RdSapSchema17_0), so the hardcode silently +degraded both the SAP water-heating figure and the hot-water recommendations for +17.0 certs. This guard makes sure the block stays mapped. (The `secondary_*_type` +pair of the same block is covered by its own dedicated secondary-heating test.) +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# A real RdSAP-Schema-17.0 cert that lodges a cylinder thermostat, a measured +# cylinder-insulation thickness and flue/pump fields on its main heating. +_FIXTURE = Path("tests/fixtures/epc_prediction/PE71NT/cert-26619d8e7f8e.json") + +# A PCDB main-heating index the fixture happens not to carry; injected to prove +# the field flows through (it used to be dropped to None unconditionally). +_MAIN_HEATING_INDEX = 4321 + + +def _load_17_0_cert() -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + assert data["schema_type"] == "RdSAP-Schema-17.0" + return data + + +def test_17_0_path_preserves_lodged_cylinder_fields() -> None: + # Arrange — the real cert lodges a cylinder thermostat + measured thickness. + data = _load_17_0_cert() + assert data["sap_heating"]["cylinder_thermostat"] == "Y" + assert data["sap_heating"]["cylinder_insulation_thickness"] == 25 + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — both survive the 17.0 path now (they used to be dropped to None), + # so the water-heating worksheet and cylinder-thermostat recommendation see + # the real dwelling. + assert epc.sap_heating.cylinder_thermostat == "Y" + assert epc.sap_heating.cylinder_insulation_thickness_mm == 25 + + +def test_17_0_path_preserves_lodged_main_heating_detail_fields() -> None: + # Arrange — the fixture lodges flue/pump on the main heating; inject a PCDB + # index (absent on this cert) to cover the efficiency-lookup field too. + data = _load_17_0_cert() + detail = data["sap_heating"]["main_heating_details"][0] + assert detail["boiler_flue_type"] == 2 + assert detail["fan_flue_present"] == "Y" + assert detail["central_heating_pump_age"] == 1 + detail["main_heating_index_number"] = _MAIN_HEATING_INDEX + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — the whole MainHeatingDetail block carries through (was all None). + mapped = epc.sap_heating.main_heating_details[0] + assert mapped.boiler_flue_type == 2 + assert mapped.fan_flue_present is True + assert mapped.central_heating_pump_age == 1 + assert mapped.main_heating_index_number == _MAIN_HEATING_INDEX