Slice S0380.175: Community heating main_heating_control extraction

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 13:07:59 +00:00 committed by Jun-te Kim
parent 45036e821b
commit a002c7895f
2 changed files with 97 additions and 9 deletions

View file

@ -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

View file

@ -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