diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index b376f876..fb5e8af8 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -202,6 +202,28 @@ def test_summary_001431_lpg_boiler_maps_main_fuel_to_bottled_lpg() -> None: assert epc.sap_heating.water_heating_fuel == 3 +def test_summary_001431_lpg_boiler_full_chain_sap_matches_worksheet_pdf() -> None: + # Arrange — the lpg-boiler "before" worksheet (P960-0001-001431): + # pre-1998 LPG boiler (SAP code 115, eff 61%) + 210 L cylinder, NO + # cylinder thermostat, control 2113 (room thermostat + TRVs, no + # programmer). RdSAP 10 §10.5 (p.57) "Hot water separately timed": + # a no-programmer + pre-1998 boiler is NOT separately timed, so the + # Table 2b temperature factor (53) is 0.78 (= 0.60 × 1.3), not + # 0.702 (× 0.9). Worksheet §11a lodges unrounded SAP -6.6499. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_LPG_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert + worksheet_unrounded_sap = -6.6499 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + def test_summary_001431_topfloor_flat_classified_as_top_floor() -> None: # Arrange — the recommendation "after" Summary lodges §6.0 "Position # of flat in block of flats: Top Floor": floor "A Another dwelling diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 395626d8..8a97cae0 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -5150,6 +5150,28 @@ _TABLE_4A_SOLID_FUEL_BOILER_CODES: Final[frozenset[int]] = frozenset( ) +# SAP 10.2 Table 4c(2) boiler controls (21xx) that carry NO programmer / +# time switch: 2101 "No time or thermostatic control", 2103 "Room +# thermostat only", 2111 "TRVs and bypass", 2113 "Room thermostat and +# TRVs". Every other 21xx control includes a programmer (2102/2104/2105/ +# 2106 …) or time-and-temperature zone control (2110/2112). Used by the +# RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed" rule below. +_BOILER_CONTROLS_WITHOUT_PROGRAMMER: Final[frozenset[int]] = frozenset( + {2101, 2103, 2111, 2113} +) + +# SAP 10.2 Table 4b (PDF p.168) gas/LPG/biogas boilers lodged pre-1998 +# (fan-assisted flue 110-114 + balanced/open flue 115-119) plus the +# pre-1998 liquid-fuel boilers (124 pre-1985, 125 1985-1997, 128 combi +# pre-1998). Gas/LPG 101-109 and oil 126/127/129/130 are 1998-or-later. +# Used by the RdSAP 10 §10.5 separate-timing rule: a 1998-or-later boiler +# is always separately timed; a pre-1998 boiler only when a programmer +# is present. +_PRE_1998_BOILER_SAP_CODES: Final[frozenset[int]] = frozenset( + set(range(110, 120)) | {124, 125, 128} +) + + def _separately_timed_dhw( epc: EpcPropertyData, main: Optional[MainHeatingDetail], ) -> bool: @@ -5232,7 +5254,24 @@ def _separately_timed_dhw( # DHW is not separately timed. if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: return False - return bool(epc.has_hot_water_cylinder) + if not epc.has_hot_water_cylinder: + return False + # RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed": + # No programmer, pre-1998 boiler → No + # Programmer, pre-1998 boiler → Yes + # Post-1998 boiler → Yes + # i.e. DHW is NOT separately timed only when a pre-1998 boiler is + # paired with a no-programmer control (Table 4c(2): room-thermostat- + # only / TRV-only). Every other boiler+cylinder cert keeps the + # separately-timed default — so the change is confined to old, low- + # control stock (this lpg-boiler "before" worksheet: code 115 + 2113 + # → (53) temperature factor 0.78, not 0.702). + if ( + main.main_heating_control in _BOILER_CONTROLS_WITHOUT_PROGRAMMER + and main.sap_main_heating_code in _PRE_1998_BOILER_SAP_CODES + ): + return False + return True def _table_2b_note_b_multiplier_applies(