Slice S0380.54: Elmhurst §14.1 Main Heating2 extraction + 2nd MainHeatingDetail

Cert 000565 lodges §14.1 Main Heating2 as PCDB 15100 (Vaillant Ecotec
plus 415, 88%, mains gas, 0% space heat) — this is the system that
services DHW via `Water Heating SapCode 914` ("from second main
system"). The previous extractor / mapper shape supported only ONE
main heating system, dropping Main 2 entirely.

New shape:
- `MainHeating2` dataclass (slim §14.1-shaped: PCDB ref, fuel type,
  flue type, fan_assisted_flue, percentage_of_heat, SAP code)
- `MainHeating.main_heating_2: Optional[MainHeating2]` — None when
  §14.1 is absent OR lodges only placeholder zeros (the PCDB-only
  convention; the two JSON fixtures + 14 existing Summary fixtures
  all lodge "0 / 0" for an absent Main 2)
- `_extract_main_heating_2` parses §14.1; returns None when neither
  PCDB ref nor SAP code identifies Main 2
- `_map_elmhurst_main_heating_2` builds `MainHeatingDetail` from the
  Main 2 lodgement with `main_heating_number=2` and `main_heating_
  fraction=percentage_of_heat`; strict-raises `UnmappedElmhurstLabel`
  (mirroring Slice S0380.53's Main 1 raise) when Main 2 has neither
  identifier — surfaces coverage gaps at extraction time

Per RdSAP convention "0%" is lodged without a space (vs Main 1's
"100 %" with a space) — robust percentage parse via `rstrip("%")` so
both forms thread through.

Cohort impact:
- 14 existing Summary PDF fixtures + 2 JSON fixtures: Main 2 returns
  None (placeholder zeros) → no 2nd MainHeatingDetail produced → no
  cascade behaviour change (regression-tested: 415 pass + 10 expected
  000565 fails, identical to S0380.53 baseline)
- Cert 000565: 2nd MainHeatingDetail now lodged with sap_code=None,
  pcdb=15100 (Table 105 gas-boiler 88% efficiency), category=2,
  fuel=26 (mains gas), fraction=0

Cascade still uses Main 1 for water-heating efficiency in the WHC
914 branch — that routing fix is the next slice. This commit is
the plumbing-only half; the SAP-result pin residuals are unchanged
at HEAD because the cascade hasn't been wired to read Main 2 yet.

Pyright net-zero on all 3 touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 22:36:20 +00:00 committed by Jun-te Kim
parent 043620802f
commit 6d82f8842d
3 changed files with 176 additions and 30 deletions

View file

@ -12,6 +12,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
FloorDimension,
Lighting,
MainHeating,
MainHeating2,
Meters,
PropertyDetails,
Renewables,
@ -1071,6 +1072,7 @@ class ElmhurstSiteNotesExtractor:
and int(secondary_raw) > 0
else None
)
main_heating_2 = self._extract_main_heating_2()
return MainHeating(
heat_emitter=self._local_str(lines, "Heat Emitter"),
fuel_type=self._local_str(lines, "Fuel Type"),
@ -1084,6 +1086,55 @@ class ElmhurstSiteNotesExtractor:
heat_pump_age=self._local_val(lines, "Heat pump age"),
main_heating_sap_code=main_heating_sap_code,
secondary_heating_sap_code=secondary_code,
main_heating_2=main_heating_2,
)
def _extract_main_heating_2(self) -> Optional[MainHeating2]:
"""§14.1 Main Heating2 block — returns None when the block is
either absent or lodges only placeholder zeros (the PCDB-only
convention for "no Main 2"). Otherwise builds a populated
`MainHeating2` from the lodged §14.1 fields.
Identifier signal: Main 2 is "present" when the §14.1 block
lodges either a non-zero PCDB boiler reference (e.g. cert 000565
Main 2 PCDB 15100 Vaillant Ecotec plus 415) OR a non-zero SAP
code. PCDB-only certs lodge `PCDF boiler Reference = 0` +
`Main Heating SAP Code = 0` for an absent Main 2 (per the two
JSON fixtures at `elmhurst_site_notes_{1,2}_text.json`).
"""
lines = self._section_lines(
"14.1 Main Heating2", "14.1 Community Heating",
)
pcdf_raw = self._local_val(lines, "PCDF boiler Reference")
pcdf_first = (
pcdf_raw.split()[0] if pcdf_raw and pcdf_raw.split() else ""
)
has_pcdb_ref = pcdf_first.isdigit() and int(pcdf_first) > 0
sap_code_raw = self._local_val(lines, "Main Heating SAP Code")
main_heating_sap_code: Optional[int] = None
if sap_code_raw is not None:
head = sap_code_raw.split()[0] if sap_code_raw.split() else ""
if head.isdigit():
v = int(head)
main_heating_sap_code = v if v > 0 else None
if not has_pcdb_ref and main_heating_sap_code is None:
return None
# §14.1's "Percentage of Heat" lodges either "0 %" (with space)
# or "0%" (no space). Strip the '%' before int() rather than
# split() so both forms parse.
pct_raw = self._local_val(lines, "Percentage of Heat")
pct = (
int(pct_raw.rstrip("%").strip().split()[0])
if pct_raw and pct_raw.rstrip("%").strip()
else 0
)
return MainHeating2(
pcdf_boiler_reference=pcdf_raw,
fuel_type=self._local_str(lines, "Fuel Type"),
flue_type=self._local_str(lines, "Flue Type"),
fan_assisted_flue=self._local_bool(lines, "Fan Assisted Flue"),
percentage_of_heat=pct,
main_heating_sap_code=main_heating_sap_code,
)
def _extract_meters(self) -> Meters:

View file

@ -68,6 +68,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
ElmhurstSiteNotes,
FloorDetails as ElmhurstFloorDetails,
MainHeating as ElmhurstMainHeating,
MainHeating2 as ElmhurstMainHeating2,
Renewables as ElmhurstRenewables,
RoofDetails as ElmhurstRoofDetails,
RoomInRoof as ElmhurstRoomInRoof,
@ -3804,6 +3805,64 @@ def _elmhurst_main_heating_category(
return None
def _map_elmhurst_main_heating_2(
mh2: Optional[ElmhurstMainHeating2],
) -> Optional[MainHeatingDetail]:
"""Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2
block. Returns None when no Main 2 is lodged (extractor convention:
placeholder zeros None).
Same identifier strict-raise as Main 1 if Main 2 lodges fields
but neither the PCDB index nor the SAP code identifies the heat
source, surface the gap here rather than as a silent cascade
fall-through to gas-boiler default.
The category derivation mirrors `_elmhurst_main_heating_category`
for Main 1: PCDB Table 362 membership category 4 (heat pump);
PCDB-referenced boiler on a gas fuel category 2.
"""
if mh2 is None:
return None
pcdb_index = _elmhurst_pcdb_boiler_index(mh2.pcdf_boiler_reference)
if mh2.main_heating_sap_code is None and pcdb_index is None:
raise UnmappedElmhurstLabel(
"main_heating_2",
(
f"§14.1 Main Heating2 has neither PCDF boiler reference "
f"({mh2.pcdf_boiler_reference!r}) nor SAP code "
f"({mh2.main_heating_sap_code!r}); cannot identify the "
f"heat source"
),
)
main_fuel_int = _elmhurst_main_fuel_int(mh2.fuel_type)
category: Optional[int] = None
if pcdb_index is not None and heat_pump_record(pcdb_index) is not None:
category = _ELMHURST_HEATING_CATEGORY_HEAT_PUMP
elif pcdb_index is not None and mh2.fuel_type in _ELMHURST_GAS_BOILER_FUEL_TYPES:
category = _ELMHURST_HEATING_CATEGORY_GAS_BOILER
return MainHeatingDetail(
# Main 2 doesn't carry its own FGHRS lodgement in §14.1; the
# cert-level renewables block is the single source of truth and
# is already wired into Main 1.
has_fghrs=False,
main_fuel_type=main_fuel_int if main_fuel_int is not None else mh2.fuel_type,
# §14.1 doesn't lodge a heat emitter (the emitter is Main 1's
# radiator/UFH); leave as empty-string sentinel for cascade
# consumers that key off Main 1's emitter.
heat_emitter_type="",
emitter_temperature="",
fan_flue_present=mh2.fan_assisted_flue,
boiler_flue_type=_elmhurst_flue_type_int(mh2.flue_type),
main_heating_control="",
main_heating_category=category,
main_heating_number=2,
main_heating_fraction=mh2.percentage_of_heat,
main_heating_index_number=pcdb_index,
main_heating_data_source=1 if pcdb_index is not None else None,
sap_main_heating_code=mh2.main_heating_sap_code,
)
def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
mh = survey.main_heating
sap_control = mh.heating_controls_sap
@ -3868,38 +3927,47 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
water_heating_fuel = _elmhurst_main_fuel_int(
survey.water_heating.water_heating_fuel_type,
)
main_1_detail = MainHeatingDetail(
has_fghrs=survey.renewables.flue_gas_heat_recovery_present,
# Prefer SAP integer codes when the Elmhurst string maps
# cleanly — the cascade only reads ints for fuel-cost,
# PE-factor, and CO2-factor lookups; strings fall through
# to defaults that drop the standing-charge component.
main_fuel_type=main_fuel_int if main_fuel_int is not None else mh.fuel_type,
heat_emitter_type=heat_emitter_int if heat_emitter_int is not None else mh.heat_emitter,
emitter_temperature=_elmhurst_emitter_temperature_int(mh.design_flow_temperature),
fan_flue_present=mh.fan_assisted_flue,
boiler_flue_type=_elmhurst_flue_type_int(mh.flue_type),
main_heating_control=sap_control_int if sap_control_int is not None else control,
central_heating_pump_age=_elmhurst_pump_age_int(mh.heat_pump_age),
central_heating_pump_age_str=mh.heat_pump_age,
main_heating_category=main_heating_category,
main_heating_number=1,
# Per RdSAP, a PCDB-listed boiler is data source 1
# (manufacturer measured efficiency); the integer index
# number drives PCDB lookup in the cascade.
main_heating_index_number=pcdb_index,
main_heating_data_source=1 if pcdb_index is not None else None,
# §14.0 "Main Heating SAP Code" — Table 4a integer
# identifying Main 1 when no PCDB boiler reference is
# lodged (e.g. heat pump SAP code 224 on cert 000565).
# The cascade's `seasonal_efficiency` reads this when
# there is no PCDB Table 105/362 record to override.
sap_main_heating_code=mh.main_heating_sap_code,
)
# §14.1 Main Heating2 — second main system, when lodged. Typically
# services DHW via `Water Heating SapCode 914` ("from second main
# system") while Main 1 handles space heat. None when the §14.1
# block is absent or lodges only placeholder zeros.
main_2_detail = _map_elmhurst_main_heating_2(mh.main_heating_2)
main_heating_details = (
[main_1_detail, main_2_detail]
if main_2_detail is not None
else [main_1_detail]
)
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[
MainHeatingDetail(
has_fghrs=survey.renewables.flue_gas_heat_recovery_present,
# Prefer SAP integer codes when the Elmhurst string maps
# cleanly — the cascade only reads ints for fuel-cost,
# PE-factor, and CO2-factor lookups; strings fall through
# to defaults that drop the standing-charge component.
main_fuel_type=main_fuel_int if main_fuel_int is not None else mh.fuel_type,
heat_emitter_type=heat_emitter_int if heat_emitter_int is not None else mh.heat_emitter,
emitter_temperature=_elmhurst_emitter_temperature_int(mh.design_flow_temperature),
fan_flue_present=mh.fan_assisted_flue,
boiler_flue_type=_elmhurst_flue_type_int(mh.flue_type),
main_heating_control=sap_control_int if sap_control_int is not None else control,
central_heating_pump_age=_elmhurst_pump_age_int(mh.heat_pump_age),
central_heating_pump_age_str=mh.heat_pump_age,
main_heating_category=main_heating_category,
main_heating_number=1,
# Per RdSAP, a PCDB-listed boiler is data source 1
# (manufacturer measured efficiency); the integer index
# number drives PCDB lookup in the cascade.
main_heating_index_number=pcdb_index,
main_heating_data_source=1 if pcdb_index is not None else None,
# §14.0 "Main Heating SAP Code" — Table 4a integer
# identifying Main 1 when no PCDB boiler reference is
# lodged (e.g. heat pump SAP code 224 on cert 000565).
# The cascade's `seasonal_efficiency` reads this when
# there is no PCDB Table 105/362 record to override.
sap_main_heating_code=mh.main_heating_sap_code,
)
],
main_heating_details=main_heating_details,
has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling,
shower_outlets=shower_outlets,
cylinder_size=_elmhurst_cylinder_size_code(

View file

@ -188,6 +188,29 @@ class Lighting:
low_energy_count: int = 0
@dataclass
class MainHeating2:
"""Elmhurst §14.1 "Main Heating2" block. Lodged when a cert carries a
second main heating system typically to service DHW via
`Water Heating SapCode 914` ("from second main system") while Main 1
handles space heat. Cert 000565 is the canonical example: Main 1 is
a heat pump (§14.0 SAP code 224, 100% space heat); Main 2 is a gas
combi (§14.1 PCDB 15100 Vaillant Ecotec plus 415, 0% space heat) +
WHC 914 routes DHW to Main 2.
PCDB-only certs use §14.1 to lodge "0 / 0" placeholder lines for an
absent Main 2 the extractor returns None in that case so the
mapper can distinguish "no Main 2" from "Main 2 present".
"""
pcdf_boiler_reference: Optional[str] = None
fuel_type: str = ""
flue_type: str = ""
fan_assisted_flue: bool = False
percentage_of_heat: int = 0
main_heating_sap_code: Optional[int] = None
@dataclass
class MainHeating:
heat_emitter: str # e.g. "Radiators"
@ -217,6 +240,10 @@ class MainHeating:
# `SapHeating.secondary_heating_type` to apply the Table 11
# secondary-fraction split; None when no secondary is lodged.
secondary_heating_sap_code: Optional[int] = None
# §14.1 "Main Heating2" block — Optional Main 2 system. None when
# the §14.1 block is absent OR lodges only placeholder zeros (PCDB-
# only certs). See `MainHeating2` docstring above.
main_heating_2: Optional[MainHeating2] = None
@dataclass