Score heat-pump dwellings via Table 4e Group 2 controls and Appendix N efficiency 🟩

#1635: 'Programmer, room thermostat and TRVs' on a heat pump → 2210
(PCDB heat_pump_controls), and the lodgement now carries Table 4a
category 4 — without it the calculator's Appendix N3.6/N3.7 gate never
fired and the PCDB heat pump was billed as 100%-efficient direct
electric (6.0/18.5 vs pre 62/57). With the category threaded the two
LRHA WAVE 3 fixtures score 52.1/56.9 (the latter within 0.5).
LRHA 59→61 computed, within-0.5 42.6%, MAE 2.830 — ratchets hold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-16 15:54:36 +00:00
parent 436675d64f
commit 9d761f6ad1

View file

@ -6491,6 +6491,11 @@ def _map_sap_heating(
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,
@ -7941,6 +7946,29 @@ _PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10: Dict[str, int] = {
}
# PasHub `system_type` labels that are SAP 10.2 Table 4e GROUP 2: HEAT PUMPS
# WITH RADIATORS OR UNDERFLOOR HEATING (spec PDF p.172). Their control labels
# carry 22xx codes; the system's efficiency comes from the lodged PCDB product
# (`main_heating_index_number`), not a Table 4a rd row, so
# `_pashub_main_heating_system_code` correctly leaves the code None.
_PASHUB_HEAT_PUMP_SYSTEM_TYPES: frozenset[str] = frozenset({
"heat pump with radiators or underfloor heating",
})
# Table 4a main-heating category 4 (heat pumps) — the calculator's Appendix
# N3.6/N3.7 efficiency, extended-heating and §4f pumps+fans paths all gate on
# `main_heating_category == 4` (same category the Elmhurst mapper lodges via
# `_ELMHURST_HEATING_CATEGORY_HEAT_PUMP`). Without it a PCDB heat pump is
# billed as 100%-efficient direct electric.
_PASHUB_HEATING_CATEGORY_HEAT_PUMP: Final[int] = 4
# PasHub surveyed heat-pump control labels → their SAP 10.2 Table 4e Group 2
# code (PCDB `heat_pump_controls`, control type 2, temp adjustment 0.0).
_PASHUB_HEAT_PUMP_CONTROL_TO_SAP10: Dict[str, int] = {
"Programmer, room thermostat and TRVs": 2210,
}
# 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
@ -8013,9 +8041,10 @@ 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`),
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
Group 2 heat pump (`_PASHUB_HEAT_PUMP_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
@ -8025,6 +8054,8 @@ def _pashub_main_heating_control_code(
system = system_type.lower()
if system in _PASHUB_BOILER_SYSTEM_TYPES:
group_map = _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10
elif system in _PASHUB_HEAT_PUMP_SYSTEM_TYPES:
group_map = _PASHUB_HEAT_PUMP_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:
@ -8035,8 +8066,9 @@ def _pashub_main_heating_control_code(
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, Group 4 "
f"electric-storage + Group 6 room-heater codes are mapped)",
f"(only Table 4e Group 1 boiler, Group 2 heat-pump, Group 3 "
f"heat-network, Group 4 electric-storage + Group 6 room-heater "
f"codes are mapped)",
)
code = group_map.get(control_label)
if code is None: