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 cf4ecb27..bd37d60d 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -327,3 +327,54 @@ def test_summary_001479_ext2_sloping_ceiling_roof_uninsulated_for_pre_1950() -> # Assert assert epc.sap_building_parts[2].roof_insulation_thickness == 0 assert epc.sap_building_parts[1].roof_insulation_thickness is None + + +def test_summary_001479_secondary_heating_routes_mains_gas_fuel() -> None: + # Arrange — cert 001479 §14.1 Main Heating2 lodges "Secondary Heating + # Code: SAP code 605, Flush fitting live effect gas fire, sealed to + # chimney". The Summary surfaces only the SAP code (605); the fuel + # type 26 (mains gas) must be derived from the code range so the + # `_fuel_cost` orchestrator's `secondary_high_rate_gbp_per_kwh` + # picks up Table 32's gas tariff (£0.0348/kWh) rather than the + # default standard-electricity tariff (£0.132/kWh). Worksheet line + # (242) "Space heating - secondary … 3.4800 70.5022" confirms gas + # pricing. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert epc.sap_heating.secondary_heating_type == 605 + assert epc.sap_heating.secondary_fuel_type == 26 + + +def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None: + # Arrange — cert 001479 (Summary_001479.pdf / P960-0001-001479.pdf) + # is the first cohort cert with a real GOV.UK EPB API counterpart + # (cert ref 0535-9020-6509-0821-6222). Worksheet PDF line "SAP value" + # lodges unrounded SAP **69.0094** (rating C 69, also the API- + # published integer). This is the load-bearing forcing function for + # the API↔Elmhurst parity workstream: any drift from 1e-4 means a + # mapper gap, not a calculator bug — the cohort 6 cert cascades all + # reproduce Elmhurst exactly at 1e-4 on hand-built fixtures. + # + # Source-data caveat (documented for future debuggers): Summary §3 + # lodges Ext1 age band as "M 2023 onwards"; the worksheet header + # records "Ext1: L". Likely assessor data-entry inconsistency. The + # mapper trusts the Summary (its source of truth); accept whatever + # residual the M vs L disagreement produces. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_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 — 1e-4 pin, no widening, no xfail (project memory + # `feedback_zero_error_strict`). + worksheet_unrounded_sap = 69.0094 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 7e2b7083..784023fb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2487,6 +2487,25 @@ def _elmhurst_heat_emitter_int(emitter: str) -> Optional[int]: return _ELMHURST_HEAT_EMITTER_TO_SAP10.get(emitter) +def _elmhurst_secondary_fuel_from_sap_code( + sap_code: Optional[int], +) -> Optional[int]: + """Derive `secondary_fuel_type` from an Elmhurst secondary-heating SAP + code. The Summary PDF lodges the SAP code (e.g. 605 for "Flush + fitting live effect gas fire") but not the fuel int separately; the + cascade's `_secondary_fuel_cost_gbp_per_kwh` defaults to standard + electricity when `secondary_fuel_type` is None — correct for the + portable-electric default but wrong for cert 001479's mains-gas fire. + Returns 26 (mains gas) for SAP codes in the 600-630 range; None for + other codes (cascade default fires, matching cohort 000490 SAP code + 691 electric panel).""" + if sap_code is None: + return None + if 601 <= sap_code <= 630: + return 26 # Mains gas, matching `_ELMHURST_MAIN_FUEL_TO_SAP10` + return None + + def _elmhurst_sap_control_code(sap_control: str) -> Optional[int]: """Extract the SAP code integer from a heating-controls field like 'SAP code 2106, Programmer, room thermostat and TRVs' → 2106. The @@ -2587,6 +2606,9 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: ), water_heating_code=survey.water_heating.water_heating_sap_code, secondary_heating_type=mh.secondary_heating_sap_code, + secondary_fuel_type=_elmhurst_secondary_fuel_from_sap_code( + mh.secondary_heating_sap_code, + ), number_baths=survey.baths_and_showers.number_of_baths, electric_shower_count=1 if has_electric_shower else None, mixer_shower_count=0 if has_electric_shower else None, diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 5d25d116..2648fb12 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -1877,10 +1877,21 @@ def _fuel_cost( table_32_unit_price_p_per_kwh(water_heating_fuel_code or main_fuel_code) * _PENCE_TO_GBP ) - # Secondary fuel = standard electricity by default (portable electric - # heater per §A.2.2). Scope A has no lodged secondaries; the fraction - # is zero so the price contributes nothing to (242). - secondary_high_rate_gbp_per_kwh = other_uses_gbp_per_kwh + # Secondary fuel cost: route through the cert's `secondary_fuel_type` + # when lodged (e.g. mains-gas fire SAP code 605 → fuel 26 → Table 32 + # gas price), otherwise default to standard electricity (the portable + # electric heater per §A.2.2 — same as the cohort's electric panel + # SAP code 691). Pre-slice this column hardcoded `other_uses_gbp_per_ + # kwh` regardless of fuel type, charging gas-secondary kWh at the + # electric tariff and dropping ~£175/yr from the ECF on cert 001479. + secondary_fuel = ( + epc.sap_heating.secondary_fuel_type if epc.sap_heating else None + ) + secondary_high_rate_gbp_per_kwh = ( + table_32_unit_price_p_per_kwh(secondary_fuel) * _PENCE_TO_GBP + if secondary_fuel is not None + else other_uses_gbp_per_kwh + ) # Table 32 PV export credit (code 60 = 13.19 p/kWh, same as std # electricity under RdSAP10 amendment). diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py index 839b2f8d..0be12f5c 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py @@ -94,13 +94,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0300-2747-7640-2526-2135", actual_sap=78, - expected_sap_resid=-7, + expected_sap_resid=+2, expected_pe_resid_kwh_per_m2=-1.9082, expected_co2_resid_tonnes_per_yr=-1.0829, notes=( "Large semi-detached, TFA 526, age D, gas boiler PCDB-listed " "(no Table 4b code). Cert lodges open_flues_count=1 + " - "has_draught_lobby=true." + "has_draught_lobby=true + mains-gas secondary (SAP code 605 / " + "fuel 26). Slice 58 cascade routed secondary fuel cost through " + "the lodged fuel_type (rather than hardcoding the electric " + "tariff), tightening this cert's SAP residual −7 → +2 — the " + "biggest single SAP improvement on the golden cohort to date." ), ), _GoldenExpectation(