diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 82a28ed6..20628972 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -608,11 +608,28 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # offset the §7 MIT over-count, masking the bug. Per # [[feedback-software-no-special-handling]] apply spec-correct fix # uniformly; the exposed §7 MIT residual is the next closure front. - _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=-1.0572, expected_cost_resid_gbp=+24.3605, expected_co2_resid_kg=+127.2164, expected_pe_resid_kwh=+408.6704), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.4187, expected_cost_resid_gbp=+9.6470, expected_co2_resid_kg=-1356.9498, expected_pe_resid_kwh=+1778.5550), - _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=-1.0572, expected_cost_resid_gbp=+24.3605, expected_co2_resid_kg=-72.8776, expected_pe_resid_kwh=-239.0266), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.4187, expected_cost_resid_gbp=+9.6470, expected_co2_resid_kg=-4323.7080, expected_pe_resid_kwh=+767.1285), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.4406, expected_cost_resid_gbp=+194.4846, expected_co2_resid_kg=-2861.5307, expected_pe_resid_kwh=+8137.1145), + # + # Slice S0380.175 wired the §14.1 Community Heating "Heating Controls + # SAP" lodging (bare 4-digit form like "2306") into the + # `main_heating_control` field on the mapper-produced Main 1. Pre- + # slice the mapper only read §14.0 Main Heating "Main Heating + # Controls Sap" which is empty for community heating certs; the + # cascade defaulted to control_type=2, mis-routing the §7 elsewhere- + # zone off-hours to (7, 8) when SAP code 2306 ("Charging system + # linked to use of heating, programmer and TRVs") dispatches via + # Table 4e Group 3 to control_type=3 / off-hours (9, 8). The fix + # closes CH1 and CH3 SAP / cost EXACTLY; CH2/CH4 cost flip from + # +£9.65 to -£12.16 (CHP-split blend now sees lower SH kWh × CHP + # rate); CH6 SAP narrows -8.44 → -7.49. Remaining CH1/CH3 CO2/PE + # residuals are the §13a (372) "Electrical energy for heat + # distribution" line — 118.38 kWh billed at electricity factors + # (CO2 0.1993, PE 1.760), not heat-network factors — the cascade + # doesn't currently meter this. Next follow-up slice. + _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-23.6007, expected_pe_resid_kwh=-208.2267), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1435.0874, expected_pe_resid_kwh=+1123.0063), + _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-98.9235, expected_pe_resid_kwh=-457.5428), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4401.8456, expected_pe_resid_kwh=+111.5798), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2939.6683, expected_pe_resid_kwh=+7481.5658), ) @@ -945,6 +962,53 @@ def test_community_heating_mapper_populates_chp_split_fields( assert main_1.community_heating_boiler_fuel_type == expected_boiler_fuel_code +# S0380.175 — Community heating main_heating_control extraction. +# +# Per SAP 10.2 Table 4e Group 3 (PDF p.173): heat-network control codes +# 2301-2314 dispatch to control_type 1, 2, or 3. The cert lodges the +# code in §14.1 Community Heating "Heating Controls SAP" rather than +# §14.0 Main Heating's "Main Heating Controls Sap". Pre-slice the mapper +# only read the §14.0 field, leaving `main_heating_control=''` and the +# cascade defaulting to type 2 (modal RdSAP default). The §14.1 lodging +# carries the actual control code, which feeds Table 9 elsewhere-zone +# off-hours selection (type 1/2 → (7,8); type 3 → (9,8)) and the §7 +# T_h2 MIT cascade. +@pytest.mark.parametrize( + ("variant", "expected_main_heating_control"), + ( + # All 5 CH variants lodge "Heating Controls SAP: 2306" in §14.1 + # Community Heating. SAP 10.2 Table 4e Group 3 row 2306 = + # "Charging system linked to use of heating, programmer and TRVs" + # → control_type 3, temperature_adjustment 0 °C. + ('community heating 1', 2306), + ('community heating 2', 2306), + ('community heating 3', 2306), + ('community heating 4', 2306), + ('community heating 6', 2306), + ), + ids=lambda v: v if isinstance(v, str) else str(v), +) +def test_community_heating_mapper_picks_up_section_14_1_heating_controls_sap( + variant: str, expected_main_heating_control: int, +) -> None: + # Arrange — community heating Summary lodges the SAP control code in + # §14.1 Community Heating "Heating Controls SAP", NOT in §14.0 Main + # Heating "Main Heating Controls Sap" (which is empty for community + # heating certs). Mapper must read from the community block when the + # main block is empty. + summary_pdf, _ = _variant_paths(variant) + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert — Main 1 picks up the §14.1 community heating control code. + main_heating_details = epc.sap_heating.main_heating_details + assert main_heating_details is not None and len(main_heating_details) >= 1 + assert main_heating_details[0].main_heating_control == expected_main_heating_control + + # S0380.172 — Heat-network heat-source-eff scaling residual coverage. # # Per SAP 10.2 Table 4a (PDF p.164): "Boilers (RdSAP)" eff=80%, "Heat diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index db35e1c2..7c6532a9 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4042,11 +4042,27 @@ def _elmhurst_secondary_fuel_from_sap_code( 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 - cascade reads `main_heating_control` as int when present.""" + """Extract the SAP code integer from a heating-controls field. + + Two lodgement forms across the Elmhurst Summary corpus: + 1. '§14.0 Main Heating Controls Sap: SAP code 2106, Programmer, + room thermostat and TRVs' (individually-heated dwellings). + 2. '§14.1 Community Heating Heating Controls SAP: 2306' — bare + 4-digit integer string (community heating dwellings, per + SAP 10.2 Table 4e Group 3 codes 2301-2314). + + Either form yields the cascade-readable int. Returns None when the + lodgement is empty or doesn't carry a recognisable code. + """ + if not sap_control: + return None m = re.match(r"SAP code\s+(\d+)", sap_control) - return int(m.group(1)) if m else None + if m: + return int(m.group(1)) + bare = sap_control.strip() + if bare.isdigit(): + return int(bare) + return None # SAP10.2 Table 4a main-heating-category codes. The cascade reads @@ -4697,7 +4713,15 @@ def _map_elmhurst_main_heating_2( def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: mh = survey.main_heating + # Community heating dwellings lodge the SAP control code in §14.1 + # Community Heating "Heating Controls SAP" (bare 4-digit form, e.g. + # "2306"), not in §14.0 Main Heating "Main Heating Controls Sap". + # Fall through to the §14.1 lodging when §14.0 is empty so the + # cascade reads `main_heating_control` as the lodged Table 4e Group 3 + # code instead of defaulting to type 2. sap_control = mh.heating_controls_sap + if not sap_control and mh.community_heating is not None: + sap_control = mh.community_heating.heating_controls_sap control = ( sap_control.split(", ", 1)[1] if sap_control.startswith("SAP code") and ", " in sap_control