diff --git a/backend/documents_parser/extractor.py b/backend/documents_parser/extractor.py index b5e0f9582..334cf08cc 100644 --- a/backend/documents_parser/extractor.py +++ b/backend/documents_parser/extractor.py @@ -595,6 +595,8 @@ class PasHubRdSapSiteNotesExtractor: ) or "", system_type=self._get_in(data, "System type:") or "", + community_heat_source=self._get_in(data, "Heating System (Other):") + or "", product_id=int(self._get_in(data, "Product Id") or 0), manufacturer=self._get_in(data, "Manufacturer") or "", model=self._get_in(data, "Model") or "", diff --git a/backend/documents_parser/tests/test_extractor.py b/backend/documents_parser/tests/test_extractor.py index 63cad4be9..9e356acd9 100644 --- a/backend/documents_parser/tests/test_extractor.py +++ b/backend/documents_parser/tests/test_extractor.py @@ -718,7 +718,7 @@ class TestMainHeatingCommunityHeatSource: # Arrange — fixture 1's heating section with the community block # spliced in after its "Fuel:" lodgement. lines = load_text_fixture() - idx = lines.index("Fuel:") + idx = lines.index("System type:") spliced = ( lines[: idx + 2] + ["Heating System (Other):", "Community heating - boilers only"] diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy.py b/backend/documents_parser/tests/test_pashub_sap_accuracy.py index 00063df5d..4cbcbfe26 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy.py @@ -93,7 +93,11 @@ _MIN_WITHIN_HALF: float = 0.18 # under-rate closing. # Ratcheted 2.58 -> 2.55 by #1590 bug 3 (PV Connection label -> gov-API int: # "Not connected" no longer silently credited): observed MAE 2.538. -_MAX_SAP_MAE: float = 1.75 +# Ratcheted 1.75 -> 1.70 by the dig-round-2 fixes: RIR surfaces billed with +# the surveyed roof insulation + stud-wall commons, and community heating +# lodging Table 4a code 301 + Table 12 fuel 51 (507644414148 computes on the +# heat-network branch). Observed MAE 1.684. +_MAX_SAP_MAE: float = 1.70 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index efdf45e4a..6bfd5154c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6191,12 +6191,26 @@ def _map_sap_heating( else None ) + # Heat networks lodge the Table 4a code + Table 12 community fuel from + # the community heat-source label; every other system resolves the plain + # Table 32 fuel (issue #1590 follow-up, fixture 507644414148). + community_sap_code: Optional[int] = None + main_fuel: Union[int, str] + if main.system_type.lower() in _PASHUB_HEAT_NETWORK_SYSTEM_TYPES: + community_sap_code, community_fuel = _pashub_community_heating( + main.community_heat_source, fuel_type + ) + main_fuel = community_fuel if community_fuel is not None else fuel_type + else: + main_fuel = _pashub_main_fuel_code(fuel_type) + return SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), main_heating_details=[ MainHeatingDetail( has_fghrs=main.flue_gas_heat_recovery_system, - main_fuel_type=_pashub_main_fuel_code(fuel_type), + main_fuel_type=main_fuel, + sap_main_heating_code=community_sap_code, heat_emitter_type=_pashub_heat_emitter_code(main.emitter), emitter_temperature=main.emitter_temperature, fan_flue_present=main.fan_assist, @@ -6265,6 +6279,37 @@ def _pashub_mechanical_ventilation_kind( return _PASHUB_VENTILATION_TYPE_TO_KIND[ventilation_type] +# PasHub community heat-source label ("Heating System (Other)") → the +# Table 4a heat-network SAP code that routes `is_heat_network_main` (DLF, +# standing charge, 80% heat-source efficiency) and, via the shared Elmhurst +# resolver, the Table 12 community fuel. Only the cohort's boilers-only +# variant is lodged so far; CHP (302) / heat pump (304) join when observed. +_PASHUB_COMMUNITY_HEAT_SOURCE_TO_SAP_CODE: Dict[str, int] = { + "Community heating - boilers only": 301, +} +_PASHUB_COMMUNITY_HEAT_SOURCE_TO_ELMHURST: Dict[str, str] = { + "Community heating - boilers only": "Boilers", +} + + +def _pashub_community_heating( + heat_source_label: str, fuel_label: str +) -> tuple[int, Optional[int]]: + """Resolve a PasHub community-heating lodgement to its (Table 4a SAP + code, Table 12 fuel code) at the mapper boundary (ADR-0015), reusing + `_resolve_community_heating_fuel_code`. An uncovered heat-source label + strict-raises so the dwelling is never silently priced as an ordinary + boiler (issue #1590 follow-up, fixture 507644414148).""" + if heat_source_label not in _PASHUB_COMMUNITY_HEAT_SOURCE_TO_SAP_CODE: + raise UnmappedPasHubLabel("community heat source", heat_source_label) + sap_code = _PASHUB_COMMUNITY_HEAT_SOURCE_TO_SAP_CODE[heat_source_label] + fuel = _resolve_community_heating_fuel_code( + _PASHUB_COMMUNITY_HEAT_SOURCE_TO_ELMHURST[heat_source_label], + fuel_label, + ) + return sap_code, fuel + + # PasHub surveyed §Water-Heating "Cylinder Size" band → SAP10 cylinder-size # enum (Table 28: 2=Normal/110L, 3=Medium/160L, 4=Large/210L). PasHub renders # the band with its own litre-range suffix (unlike the Elmhurst forms in diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 95a6f123e..83cadc038 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -1802,10 +1802,14 @@ class TestPasHubMainHeatingControlCoding: ) raw = load("pashub_rdsap_site_notes_example1.json") - raw["heating_and_hot_water"]["main_heating"][ - "system_type" - ] = "Community heating system" - raw["heating_and_hot_water"]["main_heating"]["controls"] = ( + mh = raw["heating_and_hot_water"]["main_heating"] + mh["system_type"] = "Community heating system" + # a complete community lodgement (heat source + fuel) so the control + # resolution is what's exercised, not the community strict-raise + mh["community_heat_source"] = "Community heating - boilers only" + mh["fuel"] = "Mains Gas" + mh["product_id"] = 0 + mh["controls"] = ( "Charging system linked to use of community heating, room thermostat only" ) survey = from_dict(PasHubRdSapSiteNotes, raw) @@ -1823,10 +1827,12 @@ class TestPasHubMainHeatingControlCoding: # A community-system control label the Group 3 lookup does not cover # strict-raises (naming label + system), never silently mis-rating. raw = load("pashub_rdsap_site_notes_example1.json") - raw["heating_and_hot_water"]["main_heating"][ - "system_type" - ] = "Community heating system" - raw["heating_and_hot_water"]["main_heating"]["controls"] = "Some novel control" + mh = raw["heating_and_hot_water"]["main_heating"] + mh["system_type"] = "Community heating system" + mh["community_heat_source"] = "Community heating - boilers only" + mh["fuel"] = "Mains Gas" + mh["product_id"] = 0 + mh["controls"] = "Some novel control" survey = from_dict(PasHubRdSapSiteNotes, raw) with pytest.raises(UnmappedPasHubLabel, match="Community heating system"):