Model a PasHub dwelling's second main heating system 🟩

A dwelling with two main heating systems lodges a "Main Heating 2" block and a
"Percentage of space heating provided by Main Heating 1" split (e.g. 20% fan +
80% slimline storage, or 50% storage + 50% panel heaters). The survey model held
a single `main_heating`, and the extractor read the whole §Heating section
first-match — so it captured only Main 1, modelled it as 100% of the load, and
dropped Main 2 and the split (also bleeding Main 2's fields into Main 1). Every
such dwelling over-rated.

- `HeatingAndHotWater` now holds `main_heating_2` + `main_heating_1_percent`.
- The extractor slices the "Main Heating Systems" region per system on the
  "Main Heating N" markers and reads Main 1's percentage; a single-main dwelling
  (no "Main Heating 2") parses byte-identically to before.
- `_map_sap_heating` factors the per-system detail into `_pashub_main_heating_
  detail(main, fraction)` and emits two `MainHeatingDetail`s, setting Main 2's
  `main_heating_fraction = 100 - Main 1 %` (the calculator reads Main 2's share
  and gives Main 1 the remainder).

5 of the 6 dual-main dwellings converge (within-0.5 53.4% → 55.3%, MAE 0.799);
single-main fixtures and the gov-API / Elmhurst / Guinness / Wythenshawe corpora
are unaffected. The 6th (497655371974, storage+panel) regresses because the
calculator deliberately bills an electric Main 2 at Main 1's rate
(`_main_2_space_heating_fuel_cost_gbp_per_kwh`, deferred §10a off-peak slice) —
the correct two-main mapping now exposes that calculator gap; tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-24 15:45:47 +00:00
parent 0012ad1625
commit 22520becb9
5 changed files with 213 additions and 62 deletions

View file

@ -377,12 +377,53 @@ class PasHubRdSapSiteNotesExtractor:
def extract_heating_and_hot_water(self) -> HeatingAndHotWater:
hhw_section = self._section("Heating & Hot Water", "Ventilation")
main_1, main_2 = self._main_heating_slices(hhw_section)
return HeatingAndHotWater(
main_heating=self._parse_main_heating(hhw_section),
main_heating=self._parse_main_heating(main_1),
main_heating_2=(
self._parse_main_heating(main_2) if main_2 is not None else None
),
main_heating_1_percent=self._main_heating_1_percent(main_1),
secondary_heating=self._parse_secondary_heating(hhw_section),
water_heating=self._parse_water_heating(hhw_section),
)
def _main_heating_slices(
self, section: List[str]
) -> tuple[List[str], Optional[List[str]]]:
"""Split the §Heating "Main Heating Systems" region into per-system token
slices. A dwelling with two main systems lodges a "Main Heating 2"
marker; system 1 is [Main Heating 1 .. Main Heating 2), system 2 is
[Main Heating 2 .. Secondary Heating System). A single-main dwelling (no
"Main Heating 2") yields the whole [Main Heating 1 .. Secondary) slice
and None byte-identical to the pre-split parse for every single-main
cert (only the two dual-main certs' system-1 parse changes, dropping the
system-2 field bleed)."""
try:
start = section.index("Main Heating 1")
except ValueError:
return section, None
end = len(section)
if "Secondary Heating System" in section[start:]:
end = section.index("Secondary Heating System", start)
if "Main Heating 2" in section[start:end]:
split = section.index("Main Heating 2", start)
return section[start:split], section[split:end]
return section[start:end], None
def _main_heating_1_percent(self, main_1_slice: List[str]) -> Optional[int]:
"""Main 1's "Percentage of space heating provided by Main Heating 1"
split, which PasHub wraps across lines ("...by Main" / "Heating 1:" /
"50"); the value token follows the "Heating 1:" continuation. None when
the dwelling lodges a single main (no split surveyed)."""
raw = self._get_in(main_1_slice, "Heating 1:")
if raw is None:
return None
try:
return int(raw.split()[0])
except (ValueError, IndexError):
return None
def extract_ventilation(self) -> Ventilation:
v_section = self._section("Ventilation", "Conservatories")
return Ventilation(

View file

@ -568,6 +568,53 @@ class TestExactCylinderVolume:
assert wh.cylinder_volume_measured_l is None
class TestDualMainHeating:
"""A dwelling with two main heating systems lodges a "Main Heating 2" block
and Main 1's percentage split. The extractor must capture the second system
and the split rather than reading only Main 1 (and bleeding Main 2's fields
into Main 1 via the shared-section first-match read)."""
@staticmethod
def _hhw(tokens: list[str]) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(tokens).extract_heating_and_hot_water()
def test_second_main_and_split_captured(self) -> None:
hhw = self._hhw(
[
"Heating & Hot Water", "Main Heating Systems",
"Main Heating 1",
"Percentage of space heating provided by Main", "Heating 1:", "20",
"System type:", "Electric storage heaters",
"Heating System (Other):", "Fan storage heater",
"Controls:", "Automatic charge control",
"Main Heating 2",
"System type:", "Electric storage heaters",
"Heating System (Other):", "Modern slimline storage heater",
"Controls:", "Manual charge control",
"Secondary Heating System", "Secondary Fuel", "No Secondary Heating",
"Ventilation",
]
)
# Main 1 does not pick up Main 2's slimline label.
assert hhw.main_heating.community_heat_source == "Fan storage heater"
assert hhw.main_heating_2 is not None
assert (
hhw.main_heating_2.community_heat_source == "Modern slimline storage heater"
)
assert hhw.main_heating_1_percent == 20
def test_single_main_has_no_second_system(self) -> None:
hhw = self._hhw(
[
"Heating & Hot Water", "Main Heating Systems", "Main Heating 1",
"System type:", "Boiler with radiators or underfloor heating",
"Secondary Heating System", "Ventilation",
]
)
assert hhw.main_heating_2 is None
assert hhw.main_heating_1_percent is None
class TestControlsLineWrap:
"""PasHub wraps a long main-heating "Controls:" value across two PDF lines
(e.g. "...room thermostat and" / "TRVs"); `_get_in` reads only the first,

View file

@ -102,11 +102,20 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# Coded it to SAP Table 4a 191 (direct-acting electric boiler, eff 1.00): fixture
# 497712825571 +9.5 → +0.6, 461386632387 -6.2 → +0.4 → within-0.5 52.4% → 53.4%,
# MAE 0.835.
# 2026-07-24 (accuracy tail audit): a dwelling with two main heating systems
# lodges a "Main Heating 2" block + a split, but the survey model held one main
# and the extractor read only Main 1 (100% of it), dropping the second system
# and its share — over-rating the 6 dual-main dwellings. The survey model now
# holds `main_heating_2` + `main_heating_1_percent`, the extractor slices per
# system, and the mapper emits two MainHeatingDetails with the fraction → 5 of 6
# converge (within-0.5 53.4% → 55.3%, MAE 0.799). The 6th (497655371974,
# storage+panel) regresses because the calculator bills an electric Main 2 at
# Main 1's rate (deferred §10a slice) — the correct mapping exposes that gap.
# Treat the constants as a regression tripwire, NOT a cohort accuracy figure;
# re-baseline (coverage growth is not loosening) as further mapper fixes unblock
# fixtures.
_MIN_WITHIN_HALF: float = 0.53
_MAX_SAP_MAE: float = 0.84
_MIN_WITHIN_HALF: float = 0.55
_MAX_SAP_MAE: float = 0.80
_KNOWN_GAP_REASON = (
"pashub `from_site_notes` mapper does not int-code a main-heating "
@ -167,6 +176,27 @@ def _evaluate(deal_id: str) -> Outcome:
)
@pytest.mark.skipif(
"461029305556" not in _BY_ID, reason="dual-main fixture not in manifest"
)
def test_dual_main_heating_models_both_systems() -> None:
"""Regression: a dwelling with two main heating systems (a "Main Heating 2"
block + a surveyed split) must model BOTH, not 100% of Main 1. Fixture
461029305556 lodges 20% fan + 80% modern-slimline storage heaters; modelling
it as 100% of the 20% fan heater over-rated it +1.3. Emitting the second
MainHeatingDetail with the split (Main 2 fraction = 100 - Main 1 %) closes it
to 0.0.
NB the sibling storage+PANEL dual-main (497655371974) is NOT pinned here: the
calculator deliberately bills an electric Main 2 at Main 1's rate
(`_main_2_space_heating_fuel_cost_gbp_per_kwh`, deferred §10a off-peak slice),
so its on-peak panel half is under-costed a documented calculator gap the
correct two-main mapping now exposes, tracked separately."""
outcome = _evaluate("461029305556")
assert outcome.diff is not None
assert outcome.diff < 0.5, f"dual-main not modelled: diff {outcome.diff:.2f}"
@pytest.mark.skipif(
"497712825571" not in _BY_ID or "461386632387" not in _BY_ID,
reason="direct-acting electric boiler fixtures not in manifest",

View file

@ -6807,6 +6807,74 @@ def _map_sap_window(window: Window) -> SapWindow:
_PASHUB_ELECTRIC_SHOWER_LABEL: Final[str] = "Electric Shower"
def _pashub_main_heating_detail(
main: PasHubMainHeating, main_heating_fraction: Optional[int]
) -> MainHeatingDetail:
"""Build one `MainHeatingDetail` from a PasHub main-heating system.
`main_heating_fraction` is the system's percentage of space heat, set on the
SECOND of two mains the calculator reads `details[1].main_heating_fraction`
as Main 2's share and gives Main 1 the remainder (`_normalised_...`,
cert_to_inputs.py:1879). None on a single main."""
_ELECTRIC_SYSTEM_TYPES = {"electric storage heaters", "electric underfloor heating"}
_raw_fuel = main.fuel.split(", ")[0] if main.fuel else ""
fuel_type = (
_raw_fuel
if _raw_fuel
else (
"Electricity"
if main.system_type.lower() in _ELECTRIC_SYSTEM_TYPES
else _raw_fuel
)
)
# Heat networks lodge the Table 4a code + Table 12 community fuel from the
# community heat-source label; every other system resolves the plain Table 32
# fuel (issue #1590 follow-up, fixture 507644414148).
community_sap_code: Optional[int] = None
main_fuel: Union[int, str]
if main.system_type.lower() in _PASHUB_HEAT_NETWORK_SYSTEM_TYPES:
community_sap_code, community_fuel = _pashub_community_heating(
main.community_heat_source, fuel_type
)
main_fuel = community_fuel if community_fuel is not None else fuel_type
else:
main_fuel = _pashub_main_fuel_code(fuel_type)
# Table 4a system code: heat networks resolved theirs above; storage/room
# heaters resolve theirs from the surveyed type; boilers stay None.
sap_main_heating_code: Optional[int] = (
community_sap_code
if community_sap_code is not None
else _pashub_main_heating_system_code(main)
)
main_heating_control = _pashub_resolve_storage_control_coherence(
_pashub_main_heating_control_code(main.controls, main.system_type),
sap_main_heating_code,
)
return MainHeatingDetail(
has_fghrs=main.flue_gas_heat_recovery_system,
main_fuel_type=main_fuel,
sap_main_heating_code=sap_main_heating_code,
main_heating_category=(
_PASHUB_HEATING_CATEGORY_HEAT_PUMP
if main.system_type.lower() in _PASHUB_HEAT_PUMP_SYSTEM_TYPES
else None
),
heat_emitter_type=_pashub_heat_emitter_code(main.emitter),
emitter_temperature=main.emitter_temperature,
fan_flue_present=main.fan_assist,
main_heating_control=main_heating_control,
condensing=main.condensing,
weather_compensator=main.weather_compensator,
central_heating_pump_age_str=main.central_heating_pump_age,
# PCDB product id → the appliance's SEDBUK efficiency source
# (`gas_oil_boiler_record`); 0 means no product lodged (#1563).
main_heating_index_number=(
main.product_id if main.product_id > 0 else None
),
main_heating_fraction=main_heating_fraction,
)
def _map_sap_heating(
heating: HeatingAndHotWater, ventilation: Ventilation, water_use: WaterUse
) -> SapHeating:
@ -6840,18 +6908,6 @@ def _map_sap_heating(
1 for s in water_use.showers if s.outlet_type != _PASHUB_ELECTRIC_SHOWER_LABEL
)
_ELECTRIC_SYSTEM_TYPES = {"electric storage heaters", "electric underfloor heating"}
_raw_fuel = main.fuel.split(", ")[0] if main.fuel else ""
fuel_type = (
_raw_fuel
if _raw_fuel
else (
"Electricity"
if main.system_type.lower() in _ELECTRIC_SYSTEM_TYPES
else _raw_fuel
)
)
# Water heating is only typed for cylinder dwellings; a combi leaves the WHC
# None so HW inherits the main system (issue #1565). A `Water Heating Type:
# None` lodgement is the RdSAP 10 §10.7 "no water heating system" signal and
@ -6870,57 +6926,27 @@ def _map_sap_heating(
else None
)
# Heat networks lodge the Table 4a code + Table 12 community fuel from
# the community heat-source label; every other system resolves the plain
# Table 32 fuel (issue #1590 follow-up, fixture 507644414148).
community_sap_code: Optional[int] = None
main_fuel: Union[int, str]
if main.system_type.lower() in _PASHUB_HEAT_NETWORK_SYSTEM_TYPES:
community_sap_code, community_fuel = _pashub_community_heating(
main.community_heat_source, fuel_type
# One MainHeatingDetail per surveyed main system. A dwelling with two mains
# lodges a second "Main Heating 2" block: the calculator reads Main 2's share
# from `details[1].main_heating_fraction` and gives Main 1 the remainder
# (cert_to_inputs.py:1879). PasHub lodges Main 1's percentage, so Main 2
# takes `100 - it` — an even split when the percentage is not surveyed.
# Without the second detail the surveyed split collapsed to 100% of Main 1
# (e.g. a 50% off-peak storage heater modelled as the whole main, dropping
# the on-peak room-heater half and over-rating SAP).
main_details = [_pashub_main_heating_detail(main, None)]
if heating.main_heating_2 is not None:
main_1_percent = heating.main_heating_1_percent
main_2_fraction = (
100 - main_1_percent if main_1_percent is not None else 50
)
main_details.append(
_pashub_main_heating_detail(heating.main_heating_2, main_2_fraction)
)
main_fuel = community_fuel if community_fuel is not None else fuel_type
else:
main_fuel = _pashub_main_fuel_code(fuel_type)
# Table 4a system code: heat networks resolved theirs above; storage/room
# heaters resolve theirs from the surveyed type; boilers stay None.
sap_main_heating_code: Optional[int] = (
community_sap_code
if community_sap_code is not None
else _pashub_main_heating_system_code(main)
)
main_heating_control = _pashub_resolve_storage_control_coherence(
_pashub_main_heating_control_code(main.controls, main.system_type),
sap_main_heating_code,
)
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[
MainHeatingDetail(
has_fghrs=main.flue_gas_heat_recovery_system,
main_fuel_type=main_fuel,
sap_main_heating_code=sap_main_heating_code,
main_heating_category=(
_PASHUB_HEATING_CATEGORY_HEAT_PUMP
if main.system_type.lower() in _PASHUB_HEAT_PUMP_SYSTEM_TYPES
else None
),
heat_emitter_type=_pashub_heat_emitter_code(main.emitter),
emitter_temperature=main.emitter_temperature,
fan_flue_present=main.fan_assist,
main_heating_control=main_heating_control,
condensing=main.condensing,
weather_compensator=main.weather_compensator,
central_heating_pump_age_str=main.central_heating_pump_age,
# PCDB product id → the appliance's SEDBUK efficiency source
# (`gas_oil_boiler_record`); 0 means no product lodged (#1563).
main_heating_index_number=(
main.product_id if main.product_id > 0 else None
),
)
],
main_heating_details=main_details,
has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning,
secondary_fuel_type=secondary_fuel_type,
secondary_heating_type=_pashub_secondary_heating_type_code(

View file

@ -270,6 +270,13 @@ class HeatingAndHotWater:
main_heating: MainHeating
secondary_heating: SecondaryHeating
water_heating: WaterHeating
# A dwelling with two main heating systems lodges a second "Main Heating 2"
# block and a "Percentage of space heating provided by Main Heating 1" split
# (e.g. 50/50 storage + panel heaters). `main_heating_2` is None for the
# common single-main dwelling; `main_heating_1_percent` is Main 1's share
# (Main 2 takes the remainder), None when only one system is lodged.
main_heating_2: Optional[MainHeating] = None
main_heating_1_percent: Optional[int] = None
@dataclass