mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Resolve PasHub control labels only for boiler systems, strict-raise otherwise 🟩
Table 4e is organised by heating-system family and the same control label recurs across groups under different 2xxx codes, so the boiler (Group 1) map is gated behind a boiler system_type; any other system strict-raises rather than silently taking a boiler code. Adds a guard that every mapped code exists in the calculator's _CONTROL_TYPE_BY_CODE inventory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
313ae5253a
commit
fdcf29a057
2 changed files with 69 additions and 14 deletions
|
|
@ -6080,7 +6080,9 @@ def _map_sap_heating(
|
|||
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_heating_control=_pashub_main_heating_control_code(
|
||||
main.controls, main.system_type
|
||||
),
|
||||
condensing=main.condensing,
|
||||
weather_compensator=main.weather_compensator,
|
||||
central_heating_pump_age_str=main.central_heating_pump_age,
|
||||
|
|
@ -7130,15 +7132,30 @@ def _pashub_main_fuel_code(fuel_label: str) -> Union[int, str]:
|
|||
return code
|
||||
|
||||
|
||||
# PasHub `system_type` labels that are SAP 10.2 Table 4e Group 1 boiler systems
|
||||
# — the only group whose control codes `_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10`
|
||||
# covers. This gate matters because Table 4e is organised by heating-system
|
||||
# family and the *same* control label recurs in every group under a different
|
||||
# 2xxx code (e.g. "Programmer, room thermostat and TRVs" is 2106 on a boiler but
|
||||
# 2210 on a heat pump, 23xx on a heat network). Resolving the label alone would
|
||||
# silently hand a non-boiler dwelling a boiler code, so any system_type not
|
||||
# recognised here strict-raises instead — the gap surfaces rather than
|
||||
# mis-rating. Lower-cased to match the electric-inference comparison above.
|
||||
_PASHUB_BOILER_SYSTEM_TYPES: frozenset[str] = frozenset({
|
||||
"boiler with radiators or underfloor heating",
|
||||
})
|
||||
|
||||
|
||||
# PasHub Site-Notes surveyed main-heating "Controls" labels mapped to their
|
||||
# SAP 10.2 Table 4e (PDF p.171) code, which the calculator's `_control_type`
|
||||
# keys off. Copying the raw label onto `main_heating_control` strict-raises
|
||||
# `UnmappedSapCode` deep in the calculator; resolving it here at the boundary
|
||||
# (ADR-0015) hands the cascade the int it expects. Seeded with the boiler
|
||||
# (Group 1) control labels confirmed in the live cohort. Codes are taken
|
||||
# verbatim from Table 4e Group 1; the control *type* is then the spec's — note
|
||||
# 2104 ("Programmer and room thermostat") is control type 1, not 2 (type 2
|
||||
# needs 2+ room thermostats or TRVs), so the map is not uniformly type 2.
|
||||
# SAP 10.2 Table 4e Group 1 (PDF p.171, boiler) code, which the calculator's
|
||||
# `_control_type` keys off. Copying the raw label onto `main_heating_control`
|
||||
# strict-raises `UnmappedSapCode` deep in the calculator; resolving it here at
|
||||
# the boundary (ADR-0015) hands the cascade the int it expects. Codes are taken
|
||||
# verbatim from Table 4e Group 1 and every one is in the calculator's
|
||||
# `_CONTROL_TYPE_BY_CODE` inventory; the control *type* is then the spec's —
|
||||
# note 2104 ("Programmer and room thermostat") is control type 1, not 2 (type 2
|
||||
# needs 2+ room thermostats or TRVs), so the map is not uniformly type 2. Only
|
||||
# valid once the system is a Group 1 boiler (see `_PASHUB_BOILER_SYSTEM_TYPES`).
|
||||
_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10: Dict[str, int] = {
|
||||
"Programmer and room thermostat": 2104,
|
||||
"Programmer, room thermostat and TRVs": 2106,
|
||||
|
|
@ -7148,15 +7165,28 @@ _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10: Dict[str, int] = {
|
|||
}
|
||||
|
||||
|
||||
def _pashub_main_heating_control_code(control_label: str) -> Union[int, str]:
|
||||
def _pashub_main_heating_control_code(
|
||||
control_label: str, system_type: str
|
||||
) -> Union[int, str]:
|
||||
"""Resolve a PasHub surveyed main-heating Controls label to its SAP 10.2
|
||||
Table 4e code at the mapper boundary (ADR-0015). A genuinely blank label is
|
||||
the "no controls lodged" shape and passes through unchanged; a non-empty
|
||||
label the lookup does not cover strict-raises `UnmappedPasHubLabel` so the
|
||||
gap is fixed here, never strict-raised deep in the calculator as
|
||||
`UnmappedSapCode`."""
|
||||
the "no controls lodged" shape and passes through unchanged.
|
||||
|
||||
The resolution is system-aware: the lookup holds only Group 1 (boiler)
|
||||
codes, and the same control label recurs across Table 4e's system groups
|
||||
under different 2xxx codes, so a non-boiler `system_type` strict-raises
|
||||
(naming the system) rather than silently taking a boiler code. A boiler
|
||||
system whose label the lookup does not cover likewise strict-raises — either
|
||||
way the gap is fixed here, never mis-rated or strict-raised deep in the
|
||||
calculator as `UnmappedSapCode`."""
|
||||
if not control_label:
|
||||
return control_label
|
||||
if system_type.lower() not in _PASHUB_BOILER_SYSTEM_TYPES:
|
||||
raise UnmappedPasHubLabel(
|
||||
"main heating control",
|
||||
f"{control_label!r} on non-boiler system {system_type!r} "
|
||||
f"(only Table 4e Group 1 boiler codes are mapped)",
|
||||
)
|
||||
code = _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10.get(control_label)
|
||||
if code is None:
|
||||
raise UnmappedPasHubLabel("main heating control", control_label)
|
||||
|
|
|
|||
|
|
@ -1012,6 +1012,11 @@ class TestPasHubUnmappedMainFuel:
|
|||
raw["heating_and_hot_water"]["main_heating"][
|
||||
"system_type"
|
||||
] = "Electric storage heaters"
|
||||
# This is a fuel-inference test; blank the controls so the (boiler-only)
|
||||
# control lookup passes the empty label through rather than strict-raising
|
||||
# on the electric system_type — an electric-storage dwelling wouldn't
|
||||
# lodge a boiler control anyway.
|
||||
raw["heating_and_hot_water"]["main_heating"]["controls"] = ""
|
||||
survey = from_dict(PasHubRdSapSiteNotes, raw)
|
||||
|
||||
# Act
|
||||
|
|
@ -1041,6 +1046,26 @@ class TestPasHubMainHeatingControlCoding:
|
|||
("TRVs and bypass", 2111, 2),
|
||||
]
|
||||
|
||||
def test_every_mapped_code_is_in_the_calculator_table_4e_inventory(self) -> None:
|
||||
# Every code the mapper emits must exist in the calculator's Table 4e
|
||||
# inventory `_CONTROL_TYPE_BY_CODE`; a code that isn't there would
|
||||
# strict-raise `UnmappedSapCode` in `_control_type`. This guards the
|
||||
# mapper against drifting out of sync with the calculator's coverage.
|
||||
from datatypes.epc.domain.mapper import ( # pyright: ignore[reportPrivateUsage]
|
||||
_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10,
|
||||
)
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import ( # pyright: ignore[reportPrivateUsage]
|
||||
_CONTROL_TYPE_BY_CODE,
|
||||
)
|
||||
|
||||
unknown = {
|
||||
code
|
||||
for code in _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10.values()
|
||||
if code not in _CONTROL_TYPE_BY_CODE
|
||||
}
|
||||
|
||||
assert not unknown
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label, code", [(lbl, code) for lbl, code, _ in _LABEL_CODE_TYPE]
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue