S0380.204: extract Main Heating2's own emitter + control (§14.1)

Prerequisite for the SAP 10.2 p.186 two-systems-different-parts MIT.
When two main systems heat different parts of a dwelling, §14.1 Main
Heating2 lodges its OWN "Heat Emitter" + "Main Heating Controls Sap"
(simulated case 6: Main 1 radiators / control 2106 serving the living
area, Main 2 underfloor / control 2110 serving elsewhere). The extractor
+ mapper dropped both — `MainHeatingDetail.heat_emitter_type` and
`main_heating_control` came through as empty-string sentinels, so the
cascade saw system 2 as having no responsiveness (defaulted R=1.0) and no
control type.

- `MainHeating2` datatype gains `heat_emitter` + `heating_controls_sap`.
- The extractor reads them from the §14.1 block.
- `_map_elmhurst_main_heating_2` maps them via the same helpers as Main 1
  (`_elmhurst_heat_emitter_int` → underfloor-in-screed = emitter 2, Table
  4d R=0.75; `_elmhurst_sap_control_code` → 2110, Table 4e type 3),
  threading the dwelling floor + age band for the underfloor subtype.

Empty-string fallback preserved for the legacy DHW-only Main 2 (cert
000565 §14.1 omits emitter/control). No cascade output changes yet — the
MIT consumer lands in S0380.205. Full suite 2358 pass + 0 fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 15:53:32 +00:00
parent a42e03529c
commit 2b1afa7339
4 changed files with 52 additions and 6 deletions

View file

@ -1346,6 +1346,8 @@ class ElmhurstSiteNotesExtractor:
fan_assisted_flue=self._local_bool(lines, "Fan Assisted Flue"),
percentage_of_heat=pct,
main_heating_sap_code=main_heating_sap_code,
heat_emitter=self._local_str(lines, "Heat Emitter"),
heating_controls_sap=self._local_str(lines, "Main Heating Controls Sap"),
)
def _extract_community_heating(self) -> Optional[CommunityHeating]:

View file

@ -4863,6 +4863,8 @@ def _map_elmhurst_main_heating_2(
mh2: Optional[ElmhurstMainHeating2],
*,
fallback_fuel_type: Union[int, str, None] = None,
main_floor: Optional[ElmhurstFloorDetails] = None,
main_age_band: Optional[str] = None,
) -> Optional[MainHeatingDetail]:
"""Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2
block. Returns None when no Main 2 is lodged (extractor convention:
@ -4917,20 +4919,32 @@ def _map_elmhurst_main_heating_2(
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
# §14.1 lodges Main 2's own "Heat Emitter" + "Main Heating Controls
# Sap" when the two systems heat different parts of the dwelling
# (simulated case 6: Main 1 radiators / 2106, Main 2 underfloor /
# 2110). Map them through the same helpers as Main 1 so the SAP 10.2
# p.186 two-systems-different-parts MIT can read system 2's
# responsiveness (underfloor → emitter 2 → R=0.75) + control type.
# Empty-string sentinels preserved for the legacy DHW-only Main 2
# (cert 000565: §14.1 omits emitter/control → consumers key off
# Main 1).
emitter_int = _elmhurst_heat_emitter_int(
mh2.heat_emitter, main_floor=main_floor, main_age_band=main_age_band
)
control_int = _elmhurst_sap_control_code(mh2.heating_controls_sap)
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=resolved_fuel,
# §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="",
heat_emitter_type=(
emitter_int if emitter_int is not None else mh2.heat_emitter
),
emitter_temperature="",
fan_flue_present=mh2.fan_assisted_flue,
boiler_flue_type=_elmhurst_flue_type_int(mh2.flue_type),
main_heating_control="",
main_heating_control=control_int if control_int is not None else "",
main_heating_category=category,
main_heating_number=2,
main_heating_fraction=mh2.percentage_of_heat,
@ -5127,7 +5141,10 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
# 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, fallback_fuel_type=main_1_detail.main_fuel_type
mh.main_heating_2,
fallback_fuel_type=main_1_detail.main_fuel_type,
main_floor=survey.floor,
main_age_band=survey.construction_age_band,
)
main_heating_details = (
[main_1_detail, main_2_detail]

View file

@ -244,6 +244,15 @@ class MainHeating2:
fan_assisted_flue: bool = False
percentage_of_heat: int = 0
main_heating_sap_code: Optional[int] = None
# §14.1 "Heat Emitter" (e.g. "Underfloor Heating") + "Main Heating
# Controls Sap" (e.g. "SAP code 2110, ..."). Lodged when the two main
# systems serve different parts of the dwelling with their own
# emitter + control (simulated case 6: Main 1 radiators / control
# 2106, Main 2 underfloor / control 2110). Needed for the SAP 10.2
# p.186 two-systems-different-parts MIT (weighted responsiveness +
# elsewhere two-control blend).
heat_emitter: str = ""
heating_controls_sap: str = ""
@dataclass

View file

@ -283,6 +283,24 @@ def test_section_3_roof_windows_case6_match_pdf() -> None:
)
def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two
main systems heat different parts (Main 1 radiators/2106 living, Main
2 underfloor/2110 elsewhere). Pre-S0380.204 the extractor + mapper
dropped both (emitter='' / control=''), so the SAP 10.2 p.186 two-
systems-different-parts MIT could not read system 2's responsiveness
(underfloor emitter 2 R=0.75) or control type (2110 type 3)."""
# Arrange / Act
epc = _w001431_case6.build_epc()
main_2 = epc.sap_heating.main_heating_details[1]
# Assert — emitter 2 (underfloor in screed → Table 4d R=0.75) +
# control 2110 (Table 4e type 3 zone control).
assert main_2.heat_emitter_type == 2
assert main_2.main_heating_control == 2110
def test_section_4f_pumps_fans_case6_match_pdf() -> None:
"""(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler
detached dwelling. Worksheet (231) = 356 = (230c) central heating