mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Merge pull request #1621 from Hestia-Homes/feat/1619-pashub-storage-heaters
PasHub: score electric-storage & room-heater dwellings (Table 4e Group 4/6) — closes #1619
This commit is contained in:
commit
80bba24598
3 changed files with 169 additions and 29 deletions
|
|
@ -36,21 +36,23 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
|
|||
|
||||
# --- Ratchets (never loosen at fixed coverage), mirroring the Guinness module.
|
||||
# First full run (2026-07-16): **8 computed / 96 blocked / 0 errored**. Almost
|
||||
# the whole cohort is blocked on the pashub `from_site_notes` mapper, dominated
|
||||
# by ELECTRIC STORAGE HEATER systems + their charge-control labels (~62 of 96:
|
||||
# 'Controls for high heat retention storage heaters' ×29, 'Automatic charge
|
||||
# control' ×18, 'Manual charge control' ×9, ...) which the mapper flags as an
|
||||
# "unsupported system"; plus a tail — triple glazing '...2002-2021' ×11,
|
||||
# 'Heating oil' fuel ×7, community-heating charging controls ×11, 'MV - Heat
|
||||
# Recovery' ×2. Closing these is the same field-by-field mapper campaign the
|
||||
# Guinness module went through, not tuning.
|
||||
# The floor/ceiling below are calibrated to the 8 that currently compute
|
||||
# (within-0.5 50.0% = 4/8, MAE 3.647) — a THIN, noisy baseline. Treat them as a
|
||||
# regression tripwire, NOT a cohort accuracy figure; re-baseline (coverage
|
||||
# growth is not loosening — see the Guinness module) as mapper fixes unblock
|
||||
# the whole cohort was blocked on the pashub `from_site_notes` mapper, dominated
|
||||
# by ELECTRIC STORAGE HEATER systems + their charge-control labels which the
|
||||
# mapper flagged as an "unsupported system".
|
||||
# 2026-07-16 (#1619): taught the mapper Table 4e Group 4 (electric storage) +
|
||||
# Group 6 (room heater) controls and their Table 4a system codes → **45 computed
|
||||
# / 59 blocked / 0 errored** (within-0.5 46.7% = 21/45, MAE 2.989). Coverage
|
||||
# growth (8 → 45) is not loosening — the floor/ceiling are re-baselined to the
|
||||
# new, wider cohort per the Guinness-module convention. Residual blocks are the
|
||||
# separately-ticketed mapper gaps behind the storage heaters — triple glazing
|
||||
# '...2002-2021', cylinder size 'Exact cylinder volume' / 'Medium (131-170
|
||||
# litres)', 'Heating oil' fuel, community-heating charging controls, 'MV - Heat
|
||||
# Recovery' — each its own field-by-field follow-up, not tuning.
|
||||
# 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.50
|
||||
_MAX_SAP_MAE: float = 3.65
|
||||
_MIN_WITHIN_HALF: float = 0.46
|
||||
_MAX_SAP_MAE: float = 3.0
|
||||
|
||||
_KNOWN_GAP_REASON = (
|
||||
"pashub `from_site_notes` mapper does not int-code a main-heating "
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ from datatypes.epc.surveys.pashub_rdsap_site_notes import (
|
|||
ExtensionRoofSpace,
|
||||
FloorMeasurement,
|
||||
HeatingAndHotWater,
|
||||
MainHeating as PasHubMainHeating,
|
||||
PasHubRdSapSiteNotes,
|
||||
Renewables as PasHubRenewables,
|
||||
RoomInRoofSurfaceDetail,
|
||||
|
|
@ -6469,19 +6470,31 @@ def _map_sap_heating(
|
|||
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_main_heating_control_code(
|
||||
main.controls, main.system_type
|
||||
)
|
||||
_pashub_guard_storage_control_coherence(
|
||||
main_heating_control, 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=community_sap_code,
|
||||
sap_main_heating_code=sap_main_heating_code,
|
||||
heat_emitter_type=_pashub_heat_emitter_code(main.emitter),
|
||||
emitter_temperature=main.emitter_temperature,
|
||||
fan_flue_present=main.fan_assist,
|
||||
main_heating_control=_pashub_main_heating_control_code(
|
||||
main.controls, main.system_type
|
||||
),
|
||||
main_heating_control=main_heating_control,
|
||||
condensing=main.condensing,
|
||||
weather_compensator=main.weather_compensator,
|
||||
central_heating_pump_age_str=main.central_heating_pump_age,
|
||||
|
|
@ -7918,6 +7931,68 @@ _PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10: Dict[str, int] = {
|
|||
}
|
||||
|
||||
|
||||
# PasHub `system_type` labels that are SAP 10.2 Table 4e GROUP 4: ELECTRIC
|
||||
# STORAGE SYSTEMS (spec PDF p.173). A distinct group from boiler/heat-network,
|
||||
# so its control labels carry their own 24xx codes (see the boiler comment on
|
||||
# why the same label recurs per group under a different code).
|
||||
_PASHUB_STORAGE_HEATER_SYSTEM_TYPES: frozenset[str] = frozenset({
|
||||
"electric storage heaters",
|
||||
})
|
||||
|
||||
|
||||
# PasHub `system_type` labels that are SAP 10.2 Table 4e GROUP 6: ROOM HEATER
|
||||
# SYSTEMS (spec PDF p.173) — kept separate from storage so a room-heater control
|
||||
# never takes a storage 24xx code (the same wrong-group hazard the boiler split
|
||||
# guards against; e.g. 'Appliance thermostats' is a Group 6 code, not storage).
|
||||
_PASHUB_ROOM_HEATER_SYSTEM_TYPES: frozenset[str] = frozenset({
|
||||
"room heaters",
|
||||
})
|
||||
|
||||
|
||||
# PasHub surveyed storage-heater charge-control labels → their SAP 10.2 Table 4e
|
||||
# Group 4 code (spec PDF p.173, all control type 3). Every code is present in the
|
||||
# calculator's `_CONTROL_TYPE_BY_CODE` / `_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE`
|
||||
# inventory. 2404 carries a spec footnote — it "applies to high heat retention
|
||||
# storage heaters (code 409) only" — enforced by the coherence guard below. 2403
|
||||
# (Celect-type) is not surveyed by PasHub, so it is left unmapped (strict-raise).
|
||||
_PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10: Dict[str, int] = {
|
||||
"Manual charge control": 2401,
|
||||
"Automatic charge control": 2402,
|
||||
"Controls for high heat retention storage heaters": 2404,
|
||||
}
|
||||
|
||||
|
||||
# PasHub surveyed room-heater control labels → their SAP 10.2 Table 4e Group 6
|
||||
# code (spec PDF p.173). PasHub lodges the singular "Programmer and room
|
||||
# thermostat"; Table 4e's Group 6 wording is the plural "Programmer and room
|
||||
# thermostats" (2605) — same control.
|
||||
_PASHUB_ROOM_HEATER_CONTROL_TO_SAP10: Dict[str, int] = {
|
||||
"Appliance thermostats": 2602,
|
||||
"Programmer and room thermostat": 2605,
|
||||
}
|
||||
|
||||
|
||||
# PasHub surveyed storage-heater *type* (lodged in the "Heating System (Other)"
|
||||
# field, `PasHubMainHeating.community_heat_source`, for Manual Entry) → Table 4a
|
||||
# code (spec PDF p.165, all 100% efficient rd rows). A PCDF-Search storage
|
||||
# lodgement instead carries a PCDB `product_id`, which per RdSAP 10 p.80 (survey
|
||||
# item 7-3, "storage heaters (high heat retention types only): index number of
|
||||
# each heater") is lodged for high-heat-retention units only ⇒ Table 4a 409.
|
||||
_PASHUB_STORAGE_HEATER_TYPE_TO_SAP10: Dict[str, int] = {
|
||||
"Modern slimline storage heater": 402,
|
||||
"Fan storage heater": 404,
|
||||
}
|
||||
_PASHUB_STORAGE_HEATER_HHR_CODE: int = 409
|
||||
|
||||
|
||||
# PasHub surveyed room-heater emitter class ("Heating System (Other)" field) →
|
||||
# Table 4a code (spec PDF p.170, both 100% efficient).
|
||||
_PASHUB_ROOM_HEATER_TYPE_TO_SAP10: Dict[str, int] = {
|
||||
"Panel, Convector or radiant heaters": 691,
|
||||
"Water or oil-filled radiators": 694,
|
||||
}
|
||||
|
||||
|
||||
def _pashub_main_heating_control_code(
|
||||
control_label: str, system_type: str
|
||||
) -> Union[int, str]:
|
||||
|
|
@ -7927,12 +8002,14 @@ def _pashub_main_heating_control_code(
|
|||
|
||||
The resolution is system-aware: the same control label recurs across Table
|
||||
4e's system groups under different 2xxx codes, so the code is chosen by the
|
||||
system's group — Group 1 boiler (`_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10`) or
|
||||
Group 3 heat network (`_PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10`). Any other
|
||||
system group, or a label the group's lookup does not cover, strict-raises
|
||||
`UnmappedPasHubLabel` (naming the label and system) rather than silently
|
||||
taking a wrong-group code — the gap is fixed here, never mis-rated or
|
||||
strict-raised deep in the calculator as `UnmappedSapCode`."""
|
||||
system's group — Group 1 boiler (`_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10`),
|
||||
Group 3 heat network (`_PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10`), Group 4
|
||||
electric storage (`_PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10`) or Group 6 room
|
||||
heater (`_PASHUB_ROOM_HEATER_CONTROL_TO_SAP10`). Any other system group, or a
|
||||
label the group's lookup does not cover, strict-raises `UnmappedPasHubLabel`
|
||||
(naming the label and system) rather than silently taking a wrong-group code
|
||||
— the gap is fixed here, never mis-rated or strict-raised deep in the
|
||||
calculator as `UnmappedSapCode`."""
|
||||
if not control_label:
|
||||
return control_label
|
||||
system = system_type.lower()
|
||||
|
|
@ -7940,12 +8017,16 @@ def _pashub_main_heating_control_code(
|
|||
group_map = _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10
|
||||
elif system in _PASHUB_HEAT_NETWORK_SYSTEM_TYPES:
|
||||
group_map = _PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10
|
||||
elif system in _PASHUB_STORAGE_HEATER_SYSTEM_TYPES:
|
||||
group_map = _PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10
|
||||
elif system in _PASHUB_ROOM_HEATER_SYSTEM_TYPES:
|
||||
group_map = _PASHUB_ROOM_HEATER_CONTROL_TO_SAP10
|
||||
else:
|
||||
raise UnmappedPasHubLabel(
|
||||
"main heating control",
|
||||
f"{control_label!r} on unsupported system {system_type!r} "
|
||||
f"(only Table 4e Group 1 boiler + Group 3 heat-network codes "
|
||||
f"are mapped)",
|
||||
f"(only Table 4e Group 1 boiler, Group 3 heat-network, Group 4 "
|
||||
f"electric-storage + Group 6 room-heater codes are mapped)",
|
||||
)
|
||||
code = group_map.get(control_label)
|
||||
if code is None:
|
||||
|
|
@ -7956,6 +8037,59 @@ def _pashub_main_heating_control_code(
|
|||
return code
|
||||
|
||||
|
||||
def _pashub_main_heating_system_code(main: PasHubMainHeating) -> Optional[int]:
|
||||
"""Resolve the Table 4a system code for a PasHub storage/room-heater
|
||||
lodgement at the mapper boundary (ADR-0015). Heat networks resolve their
|
||||
code via `_pashub_community_heating`; boilers carry None (their efficiency
|
||||
is a SEDBUK/product lookup, not a Table 4a rd row).
|
||||
|
||||
The surveyed heater *type* is lodged in the "Heating System (Other)" field
|
||||
(`community_heat_source`) for Manual Entry; a PCDF-Search storage lodgement
|
||||
instead carries a PCDB `product_id`, which RdSAP 10 p.80 lodges for
|
||||
high-heat-retention units only ⇒ 409. A storage system with neither a
|
||||
product nor a recognised type label strict-raises rather than guessing a
|
||||
Table 4a row."""
|
||||
system = main.system_type.lower()
|
||||
if system in _PASHUB_STORAGE_HEATER_SYSTEM_TYPES:
|
||||
if main.product_id > 0:
|
||||
return _PASHUB_STORAGE_HEATER_HHR_CODE
|
||||
code = _PASHUB_STORAGE_HEATER_TYPE_TO_SAP10.get(main.community_heat_source)
|
||||
if code is None:
|
||||
raise UnmappedPasHubLabel(
|
||||
"storage heater type",
|
||||
f"{main.community_heat_source!r} (Heating System (Other))",
|
||||
)
|
||||
return code
|
||||
if system in _PASHUB_ROOM_HEATER_SYSTEM_TYPES:
|
||||
code = _PASHUB_ROOM_HEATER_TYPE_TO_SAP10.get(main.community_heat_source)
|
||||
if code is None:
|
||||
raise UnmappedPasHubLabel(
|
||||
"room heater type",
|
||||
f"{main.community_heat_source!r} (Heating System (Other))",
|
||||
)
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _pashub_guard_storage_control_coherence(
|
||||
main_heating_control: Union[int, str], sap_main_heating_code: Optional[int]
|
||||
) -> None:
|
||||
"""SAP 10.2 Table 4e footnote: the high-heat-retention storage control code
|
||||
2404 "applies to high heat retention storage heaters (code 409) only". A
|
||||
survey pairing 2404 with a non-HHR storage type (402/404) is a genuine
|
||||
surveyor contradiction — fail loud rather than rate a mismatched pair. The
|
||||
converse (409 with a non-2404 charge control, e.g. 2402) is spec-legal and
|
||||
passes through."""
|
||||
is_hhr_control = main_heating_control == 2404
|
||||
if is_hhr_control and sap_main_heating_code != _PASHUB_STORAGE_HEATER_HHR_CODE:
|
||||
raise UnmappedPasHubLabel(
|
||||
"main heating control",
|
||||
"'Controls for high heat retention storage heaters' (2404) lodged "
|
||||
f"against non-HHR storage system code {sap_main_heating_code} "
|
||||
"(Table 4e: 2404 applies to code 409 only)",
|
||||
)
|
||||
|
||||
|
||||
def _pashub_heat_emitter_code(emitter_label: str) -> Union[int, str]:
|
||||
"""Resolve a PasHub surveyed main-heating emitter label to its SAP10 emitter
|
||||
code at the mapper boundary (ADR-0015), reusing the Elmhurst emitter map
|
||||
|
|
|
|||
|
|
@ -224,9 +224,13 @@ class MainHeating:
|
|||
weather_compensator: bool
|
||||
emitter: str
|
||||
emitter_temperature: str
|
||||
# §Heating "Heating System (Other)" — the community heat-source label
|
||||
# (e.g. "Community heating - boilers only") lodged only by heat-network
|
||||
# dwellings; blank elsewhere.
|
||||
# §Heating "Heating System (Other)" — the generic RdSAP "system from Table
|
||||
# 4a" lodging, NOT heat-network only. Heat networks lodge the community
|
||||
# heat-source label here (e.g. "Community heating - boilers only"); Manual
|
||||
# Entry storage/room heaters lodge their heater type ("Fan storage heater",
|
||||
# "Modern slimline storage heater", "Panel, Convector or radiant heaters",
|
||||
# "Water or oil-filled radiators"); blank when the type is fixed elsewhere
|
||||
# (a PCDF-Search product, or a boiler). See #1619.
|
||||
community_heat_source: str = ""
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue