From a002c7895fdf6d582ab3d1b988677ea69369fed2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 13:07:59 +0000 Subject: [PATCH] Slice S0380.175: Community heating main_heating_control extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 4e Group 3 (PDF p.173) — heat-network control codes 2301-2314 dispatch to control_type 1, 2, or 3. Code 2306 = "Charging system linked to use of heating, programmer and TRVs" → control_type=3, temperature_adjustment=0. Per Table 9 the elsewhere- zone off-hours depend on control_type: type 1/2 → (7, 8); type 3 → (9, 8). The two extra off-hours change the §7 (90) T_rest mean by ~0.6 K → (92) MIT by ~0.4 K → (98) SH demand by ~390 kWh/yr. Pre-slice diagnosis: cascade defaulted `main_heating_control=2` (modal RdSAP) when the §14.0 "Main Heating Controls Sap" field was empty. The 5 community heating corpus variants ALL lodge the SAP code in §14.1 Community Heating "Heating Controls SAP" instead (format: bare 4-digit integer, e.g. "2306"). The extractor was storing this in `CommunityHeating.heating_controls_sap` but the mapper only read `mh.heating_controls_sap` (§14.0). Two changes: 1. `_elmhurst_sap_control_code` extended to accept bare 4-digit form ("2306") in addition to the §14.0 narrative form ("SAP code 2106, Programmer, room thermostat and TRVs"). Empty-string returns None instead of swallowing through the original `re.match` regex. 2. `_map_elmhurst_sap_heating` falls through to `mh.community_heating.heating_controls_sap` when the §14.0 main block leaves `heating_controls_sap` empty. Closures (heating-systems corpus 001431): CH1 ΔSAP_c -1.0572 → +0.0000 EXACT Δcost +£24.36 → -£0.00 EXACT CH3 ΔSAP_c -1.0572 → +0.0000 EXACT Δcost +£24.36 → -£0.00 EXACT CH2/CH4 SAP-side flip ±0.42 → ±0.53 (CHP-split blend reacts to the now-lower SH demand × CHP rate) CH6 ΔSAP_c -8.4406 → -7.4942 (DLF=1.0 P960 quirk untouched) Remaining CH1/CH3 ΔCO2 -23.60 / ΔPE -208.23 is the §13a (372) "Electrical energy for heat distribution" line (118.38 kWh × electric factors 0.1993 CO2 / 1.760 PE). Cascade doesn't currently meter this electricity overhead separately from heat-network heat — next slice. 932 pass + 0 fail (+5 new mapper tests). No regressions on the other 36 corpus variants — the mapper change is gated on `mh.community_ heating is not None` and only fires when §14.0 leaves the control field empty. Pyright net-zero on mapper.py + corpus test. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 74 +++++++++++++++++-- datatypes/epc/domain/mapper.py | 32 +++++++- 2 files changed, 97 insertions(+), 9 deletions(-) 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