mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Merge pull request #1581 from Hestia-Homes/feature/pashub-map-main-heating-control-1557
PasHub mapper: resolve main_heating_control label → SAP Table 4e code (#1557)
This commit is contained in:
commit
533f33d62b
4 changed files with 216 additions and 8 deletions
|
|
@ -64,12 +64,20 @@ _FIXTURES_DIR = Path(__file__).parent / "fixtures" / "pashub_accuracy"
|
|||
_MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
|
||||
|
||||
# --- Ratchets (never loosen), mirroring test_sap_accuracy_corpus.py. ----------
|
||||
# TODO(pashub-control-code): every fixture is currently blocked on the pashub
|
||||
# main-heating-control mapper gap, so the aggregate xfails and these bootstrap
|
||||
# values are never asserted. Once that mapper fix lands and the aggregate first
|
||||
# computes, ratchet these to the observed within-0.5 and MAE.
|
||||
_MIN_WITHIN_HALF: float = 0.0
|
||||
_MAX_SAP_MAE: float = 100.0
|
||||
# First real aggregate: the main-heating control-code mapper fix (#1557) is the
|
||||
# gap that unblocks computation for the cohort. Before it, every fixture raised
|
||||
# `UnmappedSapCode: main_heating_control` (0/205 computed, aggregate xfailed);
|
||||
# with it, 201/205 compute (4 still blocked on the residual fuel-code gap,
|
||||
# e.g. Bulk LPG). Observed on this branch, with the control fix layered on the
|
||||
# full merged sibling stack to date (fabric #1559-#1562 plus secondary-fuel
|
||||
# #1564 and roof/country #1566): SAP within-0.5 = 12.4% (25/201), MAE = 2.560.
|
||||
# The within-0.5 count is one distributional metric that moved *down* a touch
|
||||
# (13.4% -> 12.4%) as those later fixes landed even though MAE fell (2.65 ->
|
||||
# 2.56) and is not loosened relative to the current integrated state — it is set
|
||||
# to what the current codebase actually produces. Ratchet up as later field
|
||||
# fixes land, per #1568.
|
||||
_MIN_WITHIN_HALF: float = 0.124
|
||||
_MAX_SAP_MAE: float = 2.561
|
||||
|
||||
_KNOWN_GAP_REASON = (
|
||||
"pashub `from_site_notes` mapper does not int-code a main-heating "
|
||||
|
|
|
|||
|
|
@ -6100,7 +6100,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=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,
|
||||
|
|
@ -7194,6 +7196,67 @@ 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 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,
|
||||
"Programmer, TRVs and bypass": 2107,
|
||||
"Room thermostat and TRVs": 2113,
|
||||
"TRVs and bypass": 2111,
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
return code
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -534,6 +534,13 @@ class TestFromSiteNotesExample1:
|
|||
# at the mapper boundary to SAP10 emitter code 1 (matching Elmhurst).
|
||||
assert result.sap_heating.main_heating_details[0].heat_emitter_type == 1
|
||||
|
||||
def test_main_heating_control(self, result: EpcPropertyData) -> None:
|
||||
# heating_and_hot_water.main_heating.controls:
|
||||
# "Programmer, room thermostat and TRVs" is resolved at the mapper
|
||||
# boundary to its SAP 10.2 Table 4e code (2106), not the raw label —
|
||||
# the calculator's `_control_type` keys off the int code.
|
||||
assert result.sap_heating.main_heating_details[0].main_heating_control == 2106
|
||||
|
||||
def test_main_heating_no_fghrs(self, result: EpcPropertyData) -> None:
|
||||
# heating_and_hot_water.main_heating.flue_gas_heat_recovery_system: false
|
||||
assert result.sap_heating.main_heating_details[0].has_fghrs is False
|
||||
|
|
@ -748,7 +755,7 @@ class TestFromSiteNotesExample1:
|
|||
heat_emitter_type=1,
|
||||
emitter_temperature="Unknown",
|
||||
fan_flue_present=True,
|
||||
main_heating_control="Programmer, room thermostat and TRVs",
|
||||
main_heating_control=2106,
|
||||
condensing=True,
|
||||
weather_compensator=False,
|
||||
central_heating_pump_age_str="Unknown",
|
||||
|
|
@ -1220,6 +1227,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
|
||||
|
|
@ -1229,6 +1241,124 @@ class TestPasHubUnmappedMainFuel:
|
|||
assert result.sap_heating.main_heating_details[0].main_fuel_type == 30
|
||||
|
||||
|
||||
class TestPasHubMainHeatingControlCoding:
|
||||
"""Each main-heating Controls label the Guinness cohort lodges resolves to
|
||||
its SAP 10.2 Table 4e Group 1 (PDF p.171) boiler-control code. The mapper
|
||||
emits the int code (not the raw label) so the calculator's `_control_type`
|
||||
keys off it instead of strict-raising `UnmappedSapCode` deep in the cascade.
|
||||
|
||||
The resulting control type is the spec's, not a blanket "type 2": a
|
||||
programmer + a single room thermostat (2104) is type 1; type 2 needs more
|
||||
(2+ room thermostats, or TRVs).
|
||||
"""
|
||||
|
||||
# (label, Table 4e code, Table 9 control type) — codes/types straight from
|
||||
# SAP 10.2 Table 4e Group 1.
|
||||
_LABEL_CODE_TYPE = [
|
||||
("Programmer and room thermostat", 2104, 1),
|
||||
("Programmer, TRVs and bypass", 2107, 2),
|
||||
("Room thermostat and TRVs", 2113, 2),
|
||||
("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]
|
||||
)
|
||||
def test_control_label_maps_to_sap_code(self, label: str, code: int) -> None:
|
||||
# Arrange — the example fixture with its Controls cell overridden.
|
||||
raw = load("pashub_rdsap_site_notes_example1.json")
|
||||
raw["heating_and_hot_water"]["main_heating"]["controls"] = label
|
||||
survey = from_dict(PasHubRdSapSiteNotes, raw)
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_site_notes(survey)
|
||||
|
||||
# Assert
|
||||
assert (
|
||||
result.sap_heating.main_heating_details[0].main_heating_control == code
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label, control_type", [(lbl, ct) for lbl, _, ct in _LABEL_CODE_TYPE]
|
||||
)
|
||||
def test_mapped_control_yields_spec_control_type(
|
||||
self, label: str, control_type: int
|
||||
) -> None:
|
||||
# Arrange — drive the mapped code through the calculator's control-type
|
||||
# classifier to pin the end-to-end control type per Table 4e.
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import ( # pyright: ignore[reportPrivateUsage]
|
||||
_control_type,
|
||||
)
|
||||
|
||||
raw = load("pashub_rdsap_site_notes_example1.json")
|
||||
raw["heating_and_hot_water"]["main_heating"]["controls"] = label
|
||||
survey = from_dict(PasHubRdSapSiteNotes, raw)
|
||||
main = EpcPropertyDataMapper.from_site_notes(
|
||||
survey
|
||||
).sap_heating.main_heating_details[0]
|
||||
|
||||
# Act / Assert
|
||||
assert _control_type(main) == control_type
|
||||
|
||||
def test_unrecognised_control_label_raises_naming_the_label(self) -> None:
|
||||
# Arrange — a Controls label the boiler table does not cover (e.g. the
|
||||
# cohort's lone community-charging control). It must strict-raise
|
||||
# `UnmappedPasHubLabel` at the boundary, naming the label, so the gap is
|
||||
# a one-line lookup addition here rather than a raw string resurfacing
|
||||
# as the calculator's `UnmappedSapCode`.
|
||||
from datatypes.epc.domain.mapper import UnmappedPasHubLabel
|
||||
|
||||
raw = load("pashub_rdsap_site_notes_example1.json")
|
||||
raw["heating_and_hot_water"]["main_heating"][
|
||||
"controls"
|
||||
] = "Charging system linked to use of community heating"
|
||||
survey = from_dict(PasHubRdSapSiteNotes, raw)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(
|
||||
UnmappedPasHubLabel, match="Charging system linked to use of community"
|
||||
):
|
||||
EpcPropertyDataMapper.from_site_notes(survey)
|
||||
|
||||
def test_shared_control_label_on_non_boiler_system_strict_raises(self) -> None:
|
||||
# Arrange — the boiler table's codes are SAP 10.2 Table 4e Group 1 (21xx);
|
||||
# the *same* control label appears in every system group under a different
|
||||
# 2xxx code (e.g. "Programmer, room thermostat and TRVs" is 2106 on a
|
||||
# boiler but 2210/2306/… on a heat pump / heat network). Resolving the
|
||||
# label alone would silently give a non-boiler dwelling the boiler code,
|
||||
# so a non-boiler system_type must strict-raise, naming the system.
|
||||
raw = load("pashub_rdsap_site_notes_example1.json")
|
||||
raw["heating_and_hot_water"]["main_heating"]["system_type"] = "Heat pump"
|
||||
raw["heating_and_hot_water"]["main_heating"][
|
||||
"controls"
|
||||
] = "Programmer, room thermostat and TRVs"
|
||||
survey = from_dict(PasHubRdSapSiteNotes, raw)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedPasHubLabel, match="Heat pump"):
|
||||
EpcPropertyDataMapper.from_site_notes(survey)
|
||||
|
||||
|
||||
class TestPasHubUnmappedHeatEmitter:
|
||||
"""A PasHub survey whose surveyed main-heating emitter label the mapper does
|
||||
not yet cover must strict-raise `UnmappedPasHubLabel` at the boundary,
|
||||
|
|
|
|||
|
|
@ -2559,6 +2559,12 @@ def test_main_heating_control_code_maps_to_sap_control_type() -> None:
|
|||
# by ~0.67 °C → cert 0652 +1.93 SAP / cert 6835 +0.72 SAP.
|
||||
type_2_via_2111 = cert_to_inputs(_epc_with_control(2111))
|
||||
type_2_via_2113 = cert_to_inputs(_epc_with_control(2113))
|
||||
# 2104 ("Programmer and room thermostat") is control type 1 per SAP 10.2
|
||||
# Table 4e Group 1 (PDF p.171): a programmer + a single room thermostat is
|
||||
# type 1, NOT type 2 — type 2 needs more (2+ room thermostats = 2105, or
|
||||
# TRVs = 2106/2113). Pinned here because the label reads like time-and-
|
||||
# temperature control; the spec's own classification is the ground truth.
|
||||
type_1_via_2104 = cert_to_inputs(_epc_with_control(2104))
|
||||
|
||||
# Assert
|
||||
assert type_1.control_type == 1
|
||||
|
|
@ -2566,6 +2572,7 @@ def test_main_heating_control_code_maps_to_sap_control_type() -> None:
|
|||
assert type_3.control_type == 3
|
||||
assert type_2_via_2111.control_type == 2
|
||||
assert type_2_via_2113.control_type == 2
|
||||
assert type_1_via_2104.control_type == 1
|
||||
|
||||
|
||||
def test_main_heating_control_code_table_4e_full_coverage_groups_0_through_7() -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue