diff --git a/backend/documents_parser/extractor.py b/backend/documents_parser/extractor.py index dae4ea64e..cc337e41d 100644 --- a/backend/documents_parser/extractor.py +++ b/backend/documents_parser/extractor.py @@ -377,12 +377,53 @@ class PasHubRdSapSiteNotesExtractor: def extract_heating_and_hot_water(self) -> HeatingAndHotWater: hhw_section = self._section("Heating & Hot Water", "Ventilation") + main_1, main_2 = self._main_heating_slices(hhw_section) return HeatingAndHotWater( - main_heating=self._parse_main_heating(hhw_section), + main_heating=self._parse_main_heating(main_1), + main_heating_2=( + self._parse_main_heating(main_2) if main_2 is not None else None + ), + main_heating_1_percent=self._main_heating_1_percent(main_1), secondary_heating=self._parse_secondary_heating(hhw_section), water_heating=self._parse_water_heating(hhw_section), ) + def _main_heating_slices( + self, section: List[str] + ) -> tuple[List[str], Optional[List[str]]]: + """Split the §Heating "Main Heating Systems" region into per-system token + slices. A dwelling with two main systems lodges a "Main Heating 2" + marker; system 1 is [Main Heating 1 .. Main Heating 2), system 2 is + [Main Heating 2 .. Secondary Heating System). A single-main dwelling (no + "Main Heating 2") yields the whole [Main Heating 1 .. Secondary) slice + and None — byte-identical to the pre-split parse for every single-main + cert (only the two dual-main certs' system-1 parse changes, dropping the + system-2 field bleed).""" + try: + start = section.index("Main Heating 1") + except ValueError: + return section, None + end = len(section) + if "Secondary Heating System" in section[start:]: + end = section.index("Secondary Heating System", start) + if "Main Heating 2" in section[start:end]: + split = section.index("Main Heating 2", start) + return section[start:split], section[split:end] + return section[start:end], None + + def _main_heating_1_percent(self, main_1_slice: List[str]) -> Optional[int]: + """Main 1's "Percentage of space heating provided by Main Heating 1" + split, which PasHub wraps across lines ("...by Main" / "Heating 1:" / + "50"); the value token follows the "Heating 1:" continuation. None when + the dwelling lodges a single main (no split surveyed).""" + raw = self._get_in(main_1_slice, "Heating 1:") + if raw is None: + return None + try: + return int(raw.split()[0]) + except (ValueError, IndexError): + return None + def extract_ventilation(self) -> Ventilation: v_section = self._section("Ventilation", "Conservatories") return Ventilation( diff --git a/backend/documents_parser/tests/test_extractor.py b/backend/documents_parser/tests/test_extractor.py index 7e2d79530..a5f9766c1 100644 --- a/backend/documents_parser/tests/test_extractor.py +++ b/backend/documents_parser/tests/test_extractor.py @@ -568,6 +568,53 @@ class TestExactCylinderVolume: assert wh.cylinder_volume_measured_l is None +class TestDualMainHeating: + """A dwelling with two main heating systems lodges a "Main Heating 2" block + and Main 1's percentage split. The extractor must capture the second system + and the split rather than reading only Main 1 (and bleeding Main 2's fields + into Main 1 via the shared-section first-match read).""" + + @staticmethod + def _hhw(tokens: list[str]) -> HeatingAndHotWater: + return PasHubRdSapSiteNotesExtractor(tokens).extract_heating_and_hot_water() + + def test_second_main_and_split_captured(self) -> None: + hhw = self._hhw( + [ + "Heating & Hot Water", "Main Heating Systems", + "Main Heating 1", + "Percentage of space heating provided by Main", "Heating 1:", "20", + "System type:", "Electric storage heaters", + "Heating System (Other):", "Fan storage heater", + "Controls:", "Automatic charge control", + "Main Heating 2", + "System type:", "Electric storage heaters", + "Heating System (Other):", "Modern slimline storage heater", + "Controls:", "Manual charge control", + "Secondary Heating System", "Secondary Fuel", "No Secondary Heating", + "Ventilation", + ] + ) + # Main 1 does not pick up Main 2's slimline label. + assert hhw.main_heating.community_heat_source == "Fan storage heater" + assert hhw.main_heating_2 is not None + assert ( + hhw.main_heating_2.community_heat_source == "Modern slimline storage heater" + ) + assert hhw.main_heating_1_percent == 20 + + def test_single_main_has_no_second_system(self) -> None: + hhw = self._hhw( + [ + "Heating & Hot Water", "Main Heating Systems", "Main Heating 1", + "System type:", "Boiler with radiators or underfloor heating", + "Secondary Heating System", "Ventilation", + ] + ) + assert hhw.main_heating_2 is None + assert hhw.main_heating_1_percent is None + + class TestControlsLineWrap: """PasHub wraps a long main-heating "Controls:" value across two PDF lines (e.g. "...room thermostat and" / "TRVs"); `_get_in` reads only the first, diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index 999ffebd0..b6aaa1df7 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -102,11 +102,20 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # Coded it to SAP Table 4a 191 (direct-acting electric boiler, eff 1.00): fixture # 497712825571 +9.5 → +0.6, 461386632387 -6.2 → +0.4 → within-0.5 52.4% → 53.4%, # MAE 0.835. +# 2026-07-24 (accuracy tail audit): a dwelling with two main heating systems +# lodges a "Main Heating 2" block + a split, but the survey model held one main +# and the extractor read only Main 1 (100% of it), dropping the second system +# and its share — over-rating the 6 dual-main dwellings. The survey model now +# holds `main_heating_2` + `main_heating_1_percent`, the extractor slices per +# system, and the mapper emits two MainHeatingDetails with the fraction → 5 of 6 +# converge (within-0.5 53.4% → 55.3%, MAE 0.799). The 6th (497655371974, +# storage+panel) regresses because the calculator bills an electric Main 2 at +# Main 1's rate (deferred §10a slice) — the correct mapping exposes that gap. # Treat the constants as a regression tripwire, NOT a cohort accuracy figure; # re-baseline (coverage growth is not loosening) as further mapper fixes unblock # fixtures. -_MIN_WITHIN_HALF: float = 0.53 -_MAX_SAP_MAE: float = 0.84 +_MIN_WITHIN_HALF: float = 0.55 +_MAX_SAP_MAE: float = 0.80 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " @@ -167,6 +176,27 @@ def _evaluate(deal_id: str) -> Outcome: ) +@pytest.mark.skipif( + "461029305556" not in _BY_ID, reason="dual-main fixture not in manifest" +) +def test_dual_main_heating_models_both_systems() -> None: + """Regression: a dwelling with two main heating systems (a "Main Heating 2" + block + a surveyed split) must model BOTH, not 100% of Main 1. Fixture + 461029305556 lodges 20% fan + 80% modern-slimline storage heaters; modelling + it as 100% of the 20% fan heater over-rated it +1.3. Emitting the second + MainHeatingDetail with the split (Main 2 fraction = 100 - Main 1 %) closes it + to 0.0. + + NB the sibling storage+PANEL dual-main (497655371974) is NOT pinned here: the + calculator deliberately bills an electric Main 2 at Main 1's rate + (`_main_2_space_heating_fuel_cost_gbp_per_kwh`, deferred §10a off-peak slice), + so its on-peak panel half is under-costed — a documented calculator gap the + correct two-main mapping now exposes, tracked separately.""" + outcome = _evaluate("461029305556") + assert outcome.diff is not None + assert outcome.diff < 0.5, f"dual-main not modelled: diff {outcome.diff:.2f}" + + @pytest.mark.skipif( "497712825571" not in _BY_ID or "461386632387" not in _BY_ID, reason="direct-acting electric boiler fixtures not in manifest", diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c3931ee2b..298d91c92 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6807,6 +6807,74 @@ def _map_sap_window(window: Window) -> SapWindow: _PASHUB_ELECTRIC_SHOWER_LABEL: Final[str] = "Electric Shower" +def _pashub_main_heating_detail( + main: PasHubMainHeating, main_heating_fraction: Optional[int] +) -> MainHeatingDetail: + """Build one `MainHeatingDetail` from a PasHub main-heating system. + + `main_heating_fraction` is the system's percentage of space heat, set on the + SECOND of two mains — the calculator reads `details[1].main_heating_fraction` + as Main 2's share and gives Main 1 the remainder (`_normalised_...`, + cert_to_inputs.py:1879). None on a single main.""" + _ELECTRIC_SYSTEM_TYPES = {"electric storage heaters", "electric underfloor heating"} + _raw_fuel = main.fuel.split(", ")[0] if main.fuel else "" + fuel_type = ( + _raw_fuel + if _raw_fuel + else ( + "Electricity" + if main.system_type.lower() in _ELECTRIC_SYSTEM_TYPES + else _raw_fuel + ) + ) + # 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) + # Table 4a system code: heat networks resolved theirs above; storage/room + # heaters resolve theirs from the surveyed type; boilers stay None. + sap_main_heating_code: Optional[int] = ( + community_sap_code + if community_sap_code is not None + else _pashub_main_heating_system_code(main) + ) + main_heating_control = _pashub_resolve_storage_control_coherence( + _pashub_main_heating_control_code(main.controls, main.system_type), + sap_main_heating_code, + ) + return MainHeatingDetail( + has_fghrs=main.flue_gas_heat_recovery_system, + main_fuel_type=main_fuel, + sap_main_heating_code=sap_main_heating_code, + main_heating_category=( + _PASHUB_HEATING_CATEGORY_HEAT_PUMP + if main.system_type.lower() in _PASHUB_HEAT_PUMP_SYSTEM_TYPES + else None + ), + heat_emitter_type=_pashub_heat_emitter_code(main.emitter), + emitter_temperature=main.emitter_temperature, + fan_flue_present=main.fan_assist, + main_heating_control=main_heating_control, + condensing=main.condensing, + weather_compensator=main.weather_compensator, + central_heating_pump_age_str=main.central_heating_pump_age, + # PCDB product id → the appliance's SEDBUK efficiency source + # (`gas_oil_boiler_record`); 0 means no product lodged (#1563). + main_heating_index_number=( + main.product_id if main.product_id > 0 else None + ), + main_heating_fraction=main_heating_fraction, + ) + + def _map_sap_heating( heating: HeatingAndHotWater, ventilation: Ventilation, water_use: WaterUse ) -> SapHeating: @@ -6840,18 +6908,6 @@ def _map_sap_heating( 1 for s in water_use.showers if s.outlet_type != _PASHUB_ELECTRIC_SHOWER_LABEL ) - _ELECTRIC_SYSTEM_TYPES = {"electric storage heaters", "electric underfloor heating"} - _raw_fuel = main.fuel.split(", ")[0] if main.fuel else "" - fuel_type = ( - _raw_fuel - if _raw_fuel - else ( - "Electricity" - if main.system_type.lower() in _ELECTRIC_SYSTEM_TYPES - else _raw_fuel - ) - ) - # Water heating is only typed for cylinder dwellings; a combi leaves the WHC # None so HW inherits the main system (issue #1565). A `Water Heating Type: # None` lodgement is the RdSAP 10 §10.7 "no water heating system" signal and @@ -6870,57 +6926,27 @@ 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 + # One MainHeatingDetail per surveyed main system. A dwelling with two mains + # lodges a second "Main Heating 2" block: the calculator reads Main 2's share + # from `details[1].main_heating_fraction` and gives Main 1 the remainder + # (cert_to_inputs.py:1879). PasHub lodges Main 1's percentage, so Main 2 + # takes `100 - it` — an even split when the percentage is not surveyed. + # Without the second detail the surveyed split collapsed to 100% of Main 1 + # (e.g. a 50% off-peak storage heater modelled as the whole main, dropping + # the on-peak room-heater half and over-rating SAP). + main_details = [_pashub_main_heating_detail(main, None)] + if heating.main_heating_2 is not None: + main_1_percent = heating.main_heating_1_percent + main_2_fraction = ( + 100 - main_1_percent if main_1_percent is not None else 50 + ) + main_details.append( + _pashub_main_heating_detail(heating.main_heating_2, main_2_fraction) ) - main_fuel = community_fuel if community_fuel is not None else fuel_type - else: - main_fuel = _pashub_main_fuel_code(fuel_type) - - # Table 4a system code: heat networks resolved theirs above; storage/room - # heaters resolve theirs from the surveyed type; boilers stay None. - sap_main_heating_code: Optional[int] = ( - community_sap_code - if community_sap_code is not None - else _pashub_main_heating_system_code(main) - ) - main_heating_control = _pashub_resolve_storage_control_coherence( - _pashub_main_heating_control_code(main.controls, main.system_type), - sap_main_heating_code, - ) return SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), - main_heating_details=[ - MainHeatingDetail( - has_fghrs=main.flue_gas_heat_recovery_system, - main_fuel_type=main_fuel, - sap_main_heating_code=sap_main_heating_code, - main_heating_category=( - _PASHUB_HEATING_CATEGORY_HEAT_PUMP - if main.system_type.lower() in _PASHUB_HEAT_PUMP_SYSTEM_TYPES - else None - ), - heat_emitter_type=_pashub_heat_emitter_code(main.emitter), - emitter_temperature=main.emitter_temperature, - fan_flue_present=main.fan_assist, - main_heating_control=main_heating_control, - condensing=main.condensing, - weather_compensator=main.weather_compensator, - central_heating_pump_age_str=main.central_heating_pump_age, - # PCDB product id → the appliance's SEDBUK efficiency source - # (`gas_oil_boiler_record`); 0 means no product lodged (#1563). - main_heating_index_number=( - main.product_id if main.product_id > 0 else None - ), - ) - ], + main_heating_details=main_details, has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning, secondary_fuel_type=secondary_fuel_type, secondary_heating_type=_pashub_secondary_heating_type_code( diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py index 4a30a238d..e425f89af 100644 --- a/datatypes/epc/surveys/pashub_rdsap_site_notes.py +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -270,6 +270,13 @@ class HeatingAndHotWater: main_heating: MainHeating secondary_heating: SecondaryHeating water_heating: WaterHeating + # A dwelling with two main heating systems lodges a second "Main Heating 2" + # block and a "Percentage of space heating provided by Main Heating 1" split + # (e.g. 50/50 storage + panel heaters). `main_heating_2` is None for the + # common single-main dwelling; `main_heating_1_percent` is Main 1's share + # (Main 2 takes the remainder), None when only one system is lodged. + main_heating_2: Optional[MainHeating] = None + main_heating_1_percent: Optional[int] = None @dataclass