Slice S0380.170: Community heating mapper unblock (Table 12 dispatch)

Closes the 5 community-heating variants in the heating-systems corpus
(community heating 1/2/3/4/6 on property 001431). Pre-slice the
mapper returned `MainHeatingDetail.main_fuel_type=''` for every
community-heating cert because §14.0 lodges no Fuel Type — only EES
'COM' + a Table 4a heat-network SAP code (301/302/304). The cascade
strict-raised `MissingMainFuelType` per S0380.132. The actual fuel
that bills the cascade lives in the §14.1 Community Heating/Heat
Network block, which the extractor was skipping entirely.

SAP 10.2 Table 12 (PDF p.189) defines the heat-network fuel codes:

  Boilers + Mains Gas        → 51 (heat from boilers — mains gas)
  Boilers + Mineral oil      → 53 (heat from boilers — oil)
  Boilers + Coal             → 54 (heat from boilers — coal)
  Boilers + Biomass          → 43 (heat from boilers — biomass)
  Combined Heat and Power    → 48 (heat from CHP; fuel-agnostic)
  Heat pump + Electricity    → 41 (heat from electric heat pump)

Per spec text the upstream fuel determines the boiler-side code; CHP
is fuel-agnostic at the Table 12 cost / CO2 / PE level.

Three layers wired:

1. Survey schema — new `CommunityHeating` dataclass alongside
   `MainHeating2` carrying the §14.1 fields (heating_type,
   community_heat_source, community_fuel_type, heating_controls_ees,
   heating_controls_sap, chp_fuel_factor). Mutually exclusive with
   `main_heating_2` at the §14.1 level. Attached as
   `MainHeating.community_heating: Optional[CommunityHeating] = None`.

2. Extractor — new `_extract_community_heating()` method bracketed by
   "14.1 Community Heating/Heat Network" / "14.2 Meters". Returns
   None on individually-heated dwellings (no Community Heat Source
   lodged). Wired into `_extract_main_heating()`.

3. Mapper — new `_resolve_community_heating_fuel_code(heat_source,
   fuel)` dispatch helper + `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12`
   constant for the boiler upstream-fuel split. Wired in
   `_map_elmhurst_sap_heating` after the EES-code-to-fuel dispatch
   and before the strict-raise on absent SAP code.

Per the standard slice workflow + [[feedback-aaa-test-convention]]:

- 5 new AAA tests in `test_community_heating_mapper_resolves_table_12_
  fuel_code` parametrized over the 5 corpus variants, asserting the
  mapper resolves the expected Table 12 code per variant.

- The existing parametrized residual-pin test in
  `test_heating_systems_corpus_residual_matches_pin` picks up the
  5 community-heating variants with cascade-side residuals pinned as
  forcing functions for follow-up slices:

      variant            dSAP    dcost     dCO2     dPE
      CH1 (Boilers/Gas)  +0.59   -£14    -787    -3827
      CH2 (CHP/Gas)      +4.50  -£104   -1430    +1506
      CH3 (HP/Elec)      +0.59   -£14   +1614   +11879
      CH4 (CHP/Oil)      +4.50  -£104   -4397     +495
      CH6 (CHP/Coal)     -3.52   +£81   -2935    +7865

  These reflect open cascade-side work (SAP 10.2 Appendix C CHP/
  boiler heat-fraction split missing — cascade treats CHP+Boilers as
  100% CHP; community-HP COP cascade missing — cascade doesn't divide
  delivered heat by COP for Table 12 code 41; heat-network overall
  CO2/PE blended-factor cascade missing — cascade doesn't compute
  worksheet rows (386)/(486)). Pinned per [[feedback-zero-error-strict]];
  follow-up slices close gaps and re-pin smaller residuals.

- `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` tuple now empty; the
  blocked-tier test pytest-skipped via `pytest.mark.skipif` with a
  reason naming this slice.

Test baseline at HEAD: 921 pass + 1 skipped (was 916 + 0 at
predecessor 7e08e7af). Pyright net-zero on affected files
(elmhurst_site_notes.py, elmhurst_extractor.py, mapper.py,
test_heating_systems_corpus.py): 32 → 32.

Per [[feedback-spec-citation-in-commits]] the dispatch is grounded
in SAP 10.2 Table 12 (PDF p.189). Per
[[feedback-bigger-slices-for-uniform-work]] all 5 variants land in
one slice — the work is uniform (single Elmhurst label dict + single
dispatch helper) and the per-variant residuals surface together
because of cascade-side gaps, not mapper-side variation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 10:50:21 +00:00
parent 7e08e7af71
commit 9f0d23adc6
4 changed files with 237 additions and 6 deletions

View file

@ -6,6 +6,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
AlternativeWall,
BathsAndShowers,
BuildingPartDimensions,
CommunityHeating,
ElmhurstSiteNotes,
ExtensionPart,
FloorDetails,
@ -1239,6 +1240,7 @@ class ElmhurstSiteNotesExtractor:
else None
)
main_heating_2 = self._extract_main_heating_2()
community_heating = self._extract_community_heating()
return MainHeating(
heat_emitter=self._local_str(lines, "Heat Emitter"),
fuel_type=self._local_str(lines, "Fuel Type"),
@ -1254,6 +1256,7 @@ class ElmhurstSiteNotesExtractor:
main_heating_ees=self._local_str(lines, "Main Heating EES Code"),
secondary_heating_sap_code=secondary_code,
main_heating_2=main_heating_2,
community_heating=community_heating,
)
def _extract_main_heating_2(self) -> Optional[MainHeating2]:
@ -1304,6 +1307,38 @@ class ElmhurstSiteNotesExtractor:
main_heating_sap_code=main_heating_sap_code,
)
def _extract_community_heating(self) -> Optional[CommunityHeating]:
"""§14.1 Community Heating/Heat Network block. Lodged in place of
§14.1 Main Heating2 when the §14.0 Main Heating SAP code names a
heat-network row (Table 4a 301/302/304). Returns None when no
§14.1 Community Heating block is present on the cert.
The block carries the Community Heat Source (Boilers / CHP /
Heat pump) + Community Fuel Type (Mains Gas / Electricity /
Mineral oil or biodiesel / Coal) together these resolve the
Table 12 heat-network fuel code that bills the cascade. See
`_resolve_community_heating_fuel_code` in the mapper.
"""
lines = self._section_lines(
"14.1 Community Heating/Heat Network", "14.2 Meters",
)
# Absence of the §14.1 Community Heating block: no marker found
# → `_section_lines` returns []. Lodgement convention also
# leaves Community Heat Source empty on individually-heated
# dwellings; treat both as "no community heating present".
heat_source = self._local_str(lines, "Community Heat Source")
if not lines or not heat_source:
return None
return CommunityHeating(
heating_type=self._local_str(lines, "Heating Type"),
pcdf_boiler_reference=self._local_val(lines, "PCDF Boiler Reference"),
community_heat_source=heat_source,
community_fuel_type=self._local_str(lines, "Community Fuel Type"),
heating_controls_ees=self._local_str(lines, "Heating Controls EES"),
heating_controls_sap=self._local_str(lines, "Heating Controls SAP"),
chp_fuel_factor=self._local_val(lines, "CHP Fuel Factor"),
)
def _extract_meters(self) -> Meters:
return Meters(
electricity_meter_type=self._str_val("Electricity meter type"),

View file

@ -476,6 +476,50 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# cascade-side §A.2.2 efficiency or tariff-routing gap; pinned as
# forcing function for follow-up.
_CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+1.1783, expected_cost_resid_gbp=-27.1485, expected_co2_resid_kg=-49.8272, expected_pe_resid_kwh=-562.4367),
# Slice S0380.170 unblocked the 5 community-heating variants. Per
# SAP 10.2 Table 12 (PDF p.189) the heat-network fuel code comes
# from the §14.1 Community Heat Source × Community Fuel Type pair:
# `Boilers × Mains Gas` → 51, `CHP × *` → 48 (fuel-agnostic),
# `Heat pump × Electricity` → 41. New CommunityHeating dataclass
# on `ElmhurstSiteNotes.main_heating` + extractor `_extract_
# community_heating()` + mapper `_resolve_community_heating_fuel_
# code(heat_source, fuel)` + dispatch wired before the strict-raise.
#
# CH1 (301 / Boilers / Mains Gas / code 51): cascade lands ~£14
# under-cost; the gap is the missing electricity-for-heat-
# distribution kWh stream not propagating to (340)/(342) at the
# heat-network rate. CO2/PE residuals reflect the heat-network
# overall CO2 / PE factor calc not yet matching Elmhurst's (386)/
# (486) blended-factor cascade.
#
# CH2/CH4 (302 / CHP / fuel / code 48): cascade overshoots SAP by
# +4.5 because it treats CHP+Boilers as 100% CHP at 2.97 p/kWh,
# missing the SAP 10.2 Appendix C 35% CHP / 65% boiler heat-
# fraction split for "Existing CHP (2015+), flexible operation".
# The boiler-side fuel-code dispatch + CHP-credit emissions for
# exported electricity (worksheet rows (464)/(466)) are the next
# cascade-side work.
#
# CH3 (304 / Heat pump / Electricity / code 41): cascade SAP +0.59
# (same as CH1 — both worksheet SAP=64.2427 with identical Block
# 10b shapes). CO2/PE residuals are large because the cascade
# doesn't yet divide by the community-HP COP — Table 12 code 41
# carries electricity factors but the worksheet divides delivered
# heat by COP first.
#
# CH6 (302 / CHP / Coal / code 48): same CHP split gap as CH2/CH4
# but with upstream coal — cascade under-CO2 by ~2935 kg and
# over-PE by ~7865 kWh because the boiler-side code-54 coal CO2/PE
# factors are not applied.
#
# All 5 pinned as forcing functions for follow-up cascade work
# (CHP heat-fraction split, community-HP COP cascade, heat-network
# overall factor calc). Mapper-side closure complete.
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=-787.2531, expected_pe_resid_kwh=-3827.1887),
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+4.5018, expected_cost_resid_gbp=-103.7279, expected_co2_resid_kg=-1430.3212, expected_pe_resid_kwh=+1506.0355),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=+1613.7837, expected_pe_resid_kwh=+11878.7588),
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+4.5018, expected_cost_resid_gbp=-103.7279, expected_co2_resid_kg=-4397.0794, expected_pe_resid_kwh=+494.6090),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-3.5201, expected_cost_resid_gbp=+81.1097, expected_co2_resid_kg=-2934.9021, expected_pe_resid_kwh=+7864.5950),
)
@ -495,16 +539,15 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# - Solid-fuel boilers (Table 4a 150-160, 600-636) ×10
# - PCDB-lodged "Bulk LPG" mapper-dict gap ×1
_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE: tuple[str, ...] = (
'community heating 1',
'community heating 2',
'community heating 3',
'community heating 4',
'community heating 6',
# Slice S0380.133 unblocked all 10 solid-fuel variants via the
# §14.0 EES-code-driven fuel derivation; they now appear in
# `_EXPECTATIONS` above with their post-derivation residual pins.
# Slice S0380.166 unblocked `pcdb 3` via `"Bulk LPG": 27` in the
# Elmhurst label dict; it now lives in `_EXPECTATIONS` at ±0.0000.
# Slice S0380.170 unblocked all 5 community-heating variants via
# the new CommunityHeating extractor field + the §14.1 Heat
# Source × Fuel Type → Table 12 fuel-code dispatch. They now
# appear in `_EXPECTATIONS` with pinned cascade-side residuals.
)
@ -674,9 +717,13 @@ def test_heating_systems_corpus_residual_matches_pin(
)
@pytest.mark.skipif(
not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
reason="all blocked variants have been unblocked (latest: S0380.170)",
)
@pytest.mark.parametrize(
"variant",
_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE or ("__placeholder__",),
ids=lambda v: v,
)
def test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type(
@ -703,3 +750,52 @@ def test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type(
# Act / Assert
with pytest.raises(MissingMainFuelType):
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# S0380.170 — Community heating mapper dispatch coverage tests.
#
# These focused tests document the per-variant resolution path
# independently of the cascade. The parametrized `_EXPECTATIONS` test
# above is the load-bearing assertion that the cascade lands at the
# pinned residual; these unit tests assert the mapper's `main_fuel_type`
# resolves to the correct Table 12 heat-network code per
# `_resolve_community_heating_fuel_code(heat_source, fuel)`.
_COMMUNITY_HEATING_EXPECTED_FUEL_CODES: tuple[tuple[str, int], ...] = (
# (variant, SAP 10.2 Table 12 fuel code)
('community heating 1', 51), # Boilers + Mains Gas
('community heating 2', 48), # CHP + Mains Gas
('community heating 3', 41), # Heat pump + Electricity
('community heating 4', 48), # CHP + Mineral oil or biodiesel
('community heating 6', 48), # CHP + Coal
)
@pytest.mark.parametrize(
("variant", "expected_table_12_code"),
_COMMUNITY_HEATING_EXPECTED_FUEL_CODES,
ids=lambda v: v if isinstance(v, str) else str(v),
)
def test_community_heating_mapper_resolves_table_12_fuel_code(
variant: str, expected_table_12_code: int,
) -> None:
# Arrange — community-heating Summary lodges §14.0 EES='COM' + a
# Table 4a heat-network SAP code, with §14.0 Fuel Type empty. The
# §14.1 Community Heating/Heat Network block carries the upstream
# Heat Source + Fuel Type pair, which the mapper's
# `_resolve_community_heating_fuel_code` translates to a SAP 10.2
# Table 12 (PDF p.189) heat-network code per the dispatch:
# Boilers + Mains Gas → 51
# Combined Heat and Power → 48 (fuel-agnostic)
# Heat pump + Electricity → 41
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 Table 12 fuel code derived from the
# §14.1 Community Heat Source + Community Fuel Type pair.
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_fuel_type == expected_table_12_code

View file

@ -4226,6 +4226,50 @@ _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE: Final[dict[str, int]] = {
}
# Elmhurst §14.1 "Community Fuel Type" labels mapped to the SAP 10.2
# Table 12 heat-network boiler fuel code (PDF p.189). Used when
# `community_heat_source == "Boilers"` — the upstream fuel determines
# which 51-58 row applies. CHP is fuel-agnostic at the Table 12 cost /
# CO2 / PE level (code 48 carries the same factors irrespective of
# upstream fuel); Heat-pump networks always route to code 41.
#
# Spec-correct codes from SAP 10.2 Table 12:
# 51 = heat from boilers — mains gas
# 52 = heat from boilers — LPG
# 53 = heat from boilers — oil
# 54 = heat from boilers — coal
# 43 = heat from boilers — biomass
_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12: Final[dict[str, int]] = {
"Mains Gas": 51,
"Mineral oil or biodiesel": 53,
"Coal": 54,
"Biomass": 43,
}
def _resolve_community_heating_fuel_code(
heat_source: str, community_fuel: str,
) -> Optional[int]:
"""Resolve the SAP 10.2 Table 12 (PDF p.189) heat-network fuel code
from the §14.1 "Community Heat Source" + "Community Fuel Type"
pair. Returns None when the heat-source string isn't recognised
(mapper-coverage gap for a future fixture).
Dispatch table (verified against corpus block 10b/11b/12b/13b):
- "Combined Heat and Power" 48 (heat from CHP; fuel-agnostic)
- "Heat pump" 41 (heat from electric heat pump)
- "Boilers" + upstream fuel 51/52/53/54/43 per
`_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12`
"""
if heat_source == "Combined Heat and Power":
return 48
if heat_source == "Heat pump":
return 41
if heat_source == "Boilers":
return _ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12.get(community_fuel)
return None
class UnmappedElmhurstLabel(ValueError):
"""An Elmhurst Summary lodged a finite-enum label that the mapper
does not yet know how to translate to the SAP10 cascade enum.
@ -4679,6 +4723,22 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
and mh.main_heating_ees in _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE
):
main_fuel_int = _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE[mh.main_heating_ees]
# Community heating: §14.0 lodges EES='COM' + a Table 4a heat-network
# SAP code (301/302/304) but no §14.0 Fuel Type. The §14.1 Community
# Heating/Heat Network block carries the actual heat source (Boilers
# / CHP / Heat pump) + upstream fuel (Mains Gas / Electricity /
# Mineral oil or biodiesel / Coal) which together resolve the
# Table 12 heat-network fuel code (PDF p.189, codes 41/43/48/51-58).
# Cascade routes through `_is_heat_network_main` (which keys on the
# SAP code) for the DLF and seasonal-efficiency overrides.
if (
main_fuel_int is None
and mh.community_heating is not None
):
main_fuel_int = _resolve_community_heating_fuel_code(
mh.community_heating.community_heat_source,
mh.community_heating.community_fuel_type,
)
heat_emitter_int = _elmhurst_heat_emitter_int(
mh.heat_emitter,
main_floor=survey.floor,

View file

@ -246,6 +246,41 @@ class MainHeating2:
main_heating_sap_code: Optional[int] = None
@dataclass
class CommunityHeating:
"""Elmhurst §14.1 "Community Heating/Heat Network" block. Lodged
when the §14.0 Main Heating SAP code identifies a heat-network row
(Table 4a 301-304). Mutually exclusive with `MainHeating2` at the
§14.1 level (the extractor closes §14.0 at whichever §14.1 form
appears first).
The §14.0 "Main Heating SAP Code" identifies the Table 4a category
(301 = community boilers, 302 = CHP + boilers, 304 = community heat
pump), but the fuel that ultimately bills the cascade comes from
the Community Fuel Type field combined with the Community Heat
Source. See SAP 10.2 Table 12 (PDF p.189) heat-network fuel codes:
- Boilers + Mains Gas code 51
- Boilers + Mineral oil code 53
- Boilers + Coal code 54
- Boilers + Biomass code 43
- Combined Heat and Power code 48 (fuel-agnostic)
- Heat pump + Electricity code 41
"""
heating_type: str = "" # "Space and Water Heating"
pcdf_boiler_reference: Optional[str] = None
community_heat_source: str = "" # "Boilers" / "Combined Heat and Power" / "Heat pump"
community_fuel_type: str = "" # "Mains Gas" / "Electricity" / "Mineral oil or biodiesel" / "Coal"
heating_controls_ees: str = ""
heating_controls_sap: str = ""
# SAP 10.2 Appendix C — CHP Fuel Factor lookup label. Drives the
# CHP-vs-boiler heat-fraction split when `community_heat_source ==
# "Combined Heat and Power"`. Absent on non-CHP networks (e.g.
# CH1 boilers-only / CH3 heat-pump only).
chp_fuel_factor: Optional[str] = None
@dataclass
class MainHeating:
heat_emitter: str # e.g. "Radiators"
@ -289,6 +324,11 @@ class MainHeating:
# the §14.1 block is absent OR lodges only placeholder zeros (PCDB-
# only certs). See `MainHeating2` docstring above.
main_heating_2: Optional[MainHeating2] = None
# §14.1 "Community Heating/Heat Network" block — Optional, lodged
# in place of Main Heating2 when the §14.0 SAP code identifies a
# heat-network row (Table 4a 301/302/304). Mutually exclusive with
# `main_heating_2`. None on individually-heated dwellings.
community_heating: Optional[CommunityHeating] = None
@dataclass