From 3e2509fb7d50a25f414516758c272fdcdb4ab2b4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 16 Jul 2026 13:30:07 +0000 Subject: [PATCH] =?UTF-8?q?PasHub=20scores=20electric-storage=20&=20room-h?= =?UTF-8?q?eater=20dwellings=20(Table=204e=20Group=204/6)=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../test_pashub_sap_accuracy_lrha_wave3.py | 30 ++-- datatypes/epc/domain/mapper.py | 158 ++++++++++++++++-- .../epc/surveys/pashub_rdsap_site_notes.py | 10 +- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index 109f6b58e..52c6d4d54 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -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 " diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index db43f6069..acf3c615d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py index 1a1135c8a..121b7ca17 100644 --- a/datatypes/epc/surveys/pashub_rdsap_site_notes.py +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -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 = ""