mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
parent
0012ad1625
commit
22520becb9
5 changed files with 213 additions and 62 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue