mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
043620802f
commit
6d82f8842d
3 changed files with 176 additions and 30 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue