PasHub scores electric-storage & room-heater dwellings (Table 4e Group 4/6) 🟩

Teaches the pashub `from_site_notes` mapper the electric-storage-heater and
room-heater families, following the existing boiler/heat-network split exactly
(fix at the mapper boundary, ADR-0015 — never mis-rate, never strict-raise deep
in the calculator). Adds:

- Table 4e Group 4 storage charge-control codes (2401/2402/2404) and Group 6
  room-heater control codes (2602/2605), each gated on its own system group so a
  control label never takes a wrong-group 2xxx code.
- The Table 4a system code from the surveyed heater type lodged in the "Heating
  System (Other)" field (`community_heat_source`): Fan storage 404, slimline
  402, PCDF-Search product ⇒ HHR 409 (RdSAP 10 p.80), room heaters 691/694.
- A 2404⇔409 coherence guard: high-heat-retention controls on a non-HHR storage
  type is a surveyor contradiction and fails loud rather than rating a mismatch.

All codes taken from the SAP 10.2 spec (14-03-2025 PDF) and already present in
the calculator's inventories — no calculator change.

LRHA WAVE 3 (lincs rural) accuracy: 8 computed / 96 blocked → 45 computed / 59
blocked / 0 errored (within-0.5 46.7%, MAE 2.989); ratchets re-baselined to the
new coverage. Guinness cohort (208) + gov-API corpus unchanged; pyright --strict
adds zero new errors.

Closes #1619

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-16 13:30:07 +00:00
parent d557e1b0b4
commit 3e2509fb7d
3 changed files with 169 additions and 29 deletions

View file

@ -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 "

View file

@ -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

View file

@ -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 = ""