Slice S0380.88: full Table 4e dispatch + strict-raise on unmapped control codes

SAP 10.2 Table 4e (PDF p.171-174) "Heating system controls" — 8 groups
covering boiler / HP / heat-network / electric-storage / warm-air /
room-heater / other systems, ~40 codes total. Pre-S0380.88 the cascade
dispatch dict had spotty coverage:

  - Group 1 (BOILER): partial (12 of 13 codes)
  - Group 2 (HEAT PUMP): added in S0380.87 (10 codes)
  - Groups 3, 4, 5, 6, 7: completely missing

Codes from missing groups silently defaulted to type 2 — the same
failure mode that hid the cert 000565 2207→type-3 bug for ~22 slices
until S0380.87 surfaced it. Per the user-requested strict-raise
philosophy ("we keep debugging silent fallbacks"), this slice
forecloses the pattern at the dispatch boundary.

Corpus audit (full JSON sweep) at HEAD c0328f4e:

    2103, 2104, 2106, 2110, 2113  (Group 1 — covered)
    2206, 2207                    (Group 2 — covered after S0380.87)
    2307                          (Group 3 — silently mis-classified)
    2401                          (Group 4 — silently mis-classified)
    2603                          (Group 6 — silently mis-classified)

Three corpus codes (2307, 2401, 2603) were silently routed to type 2
when their spec types are 2 / 3 / 3 respectively.

Fix:

  - `_CONTROL_TYPE_BY_CODE` extended to full Table 4e coverage
    (Groups 0-7), with per-group spec citation in comments
  - New `UnmappedSapCode(ValueError)` exception class mirroring the
    `UnmappedApiCode` / `UnmappedElmhurstLabel` mapper-side pattern
    per [[reference-unmapped-api-code]]
  - `_control_type` flipped to strict-raise: lodging absent (None /
    0 / "") returns modal type 2 default; lodging present but
    unmapped raises `UnmappedSapCode("main_heating_control", code)`

The strict / not-strict distinction is principled: cascade-helper
value defaults (u_wall, u_floor, ...) stay total per RdSAP §6.2.3
"assume as-built if no evidence". Code-dispatch sites strict-raise
because an unmapped code means the spec table coverage is incomplete
— a forcing function for spec-completion slices rather than a
silent miscalculation.

Tests (3 new, AAA-structure):

  - `test_main_heating_control_code_table_4e_full_coverage_groups_0_through_7`
    pins ~20 codes across Groups 3-7 to their spec-correct control
    types (Table 4e PDF p.171-174 verbatim)
  - `test_cert_to_inputs_raises_unmapped_sap_code_on_unknown_main_heating_control`
    pins the strict-raise contract: lodging present but unmapped
    (e.g. test code 2998) raises `UnmappedSapCode` with the field
    name + value attached
  - `test_cert_to_inputs_does_not_raise_when_main_heating_control_is_missing`
    pins the absent-lodging contract: None / "" / 0 returns modal
    type 2 default — same behaviour as pre-S0380.88 for legitimately
    missing data

Test baseline: 564 pass (was 561 + 3 new) + 9 expected
`test_sap_result_pin[000565-*]` fails unchanged. Cohort + golden +
cert 9501 unaffected (their codes were all already covered or
silently routed to type 2 which is now explicit).

Pyright net-zero per touched file. The new `not code` absent-
lodging check replaces the original `code is None or code == "" or
code == 0` triple-check (pyright flagged `is None` as redundant given
`main_heating_control: Union[int, str]` annotation; runtime data
exhibits None / "" on Main 2 records that lack space-heating
controls — cert 000565 Main 2 is one such case).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 09:12:25 +00:00
parent c0328f4e18
commit 1b3bbbf783
2 changed files with 205 additions and 13 deletions

View file

@ -512,29 +512,79 @@ SAP_10_2_SPEC_PRICES: Final[PriceTable] = RDSAP_10_TABLE_32_PRICES
# Type 3: time-and-temperature zone control (separate living-zone
# schedule via plumbing/electrical arrangement or PCDB device).
_CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
# Group 1 — BOILER SYSTEMS WITH RADIATORS OR UNDERFLOOR HEATING
# SAP 10.2 Table 4e (PDF p.171-174) full coverage — strict-raise
# gated by `_control_type` per [[reference-unmapped-api-code]].
#
# Group 1 — BOILER SYSTEMS WITH RADIATORS OR UNDERFLOOR HEATING (p.171)
# "Not applicable (boiler DHW only)" 2100 — not in dispatch; cert that
# lodges 2100 has DHW-only on this main and space heating from another
# main / secondary, so the control type should come from that other
# source. Treat 2100 as type 2 default (modal RdSAP) since the cascade
# picks a control type from `_first_main_heating` regardless of role.
2100: 2,
2101: 1, 2102: 1, 2103: 1, 2104: 1,
2105: 2, 2106: 2, 2107: 2, 2108: 2, 2109: 2,
2110: 3,
2111: 2, # TRVs and bypass — Table 4e row "2 0"
2112: 3,
2113: 2, # Room thermostat and TRVs — Table 4e row "2 0"
# Group 2 — HEAT PUMPS WITH RADIATORS OR UNDERFLOOR HEATING
# (SAP 10.2 Table 4e PDF p.172-173). Pre-S0380.87 this group was
# missing entirely; HP control codes fell through to the default
# `return 2`, silently dropping control-type-3 zone control on cert
# 000565 (main_heating_control=2207). Mis-classifying 2207 as type
# 2 swapped elsewhere off-hours from (9, 8) → (7, 8), raising
# MIT_elsewhere by ~+0.5 °C and driving the ~+4500 kWh SH over-
# count surfaced after S0380.86 closed the BP main-wall gap.
# Group 2 — HEAT PUMPS WITH RADIATORS OR UNDERFLOOR HEATING (p.172-173)
# Pre-S0380.87 this group was missing; HP control 2207 silently
# defaulted to type 2 (cert 000565 over-counted SH by ~+4500 kWh).
2201: 1, 2202: 1, 2203: 1, 2204: 1,
2205: 2, 2206: 2,
2207: 3, # Time + temp zone control by plumbing/electrical (§9.4.14)
2208: 3, # Time + temp zone control by PCDB device (§9.4.14)
2209: 2, 2210: 2,
# Group 3 — HEAT NETWORKS (p.173). Pre-S0380.88 this group was
# missing; corpus has cert(s) lodging 2307 silently mis-classified.
2301: 1, 2302: 1, 2303: 1, 2304: 1,
2305: 2, 2307: 2, 2308: 2, 2309: 2, 2311: 2, 2313: 2,
2306: 3, 2310: 3, 2312: 3, 2314: 3,
# Group 4 — ELECTRIC STORAGE SYSTEMS (p.173). All type 3 per spec.
2401: 3, 2402: 3, 2403: 3, 2404: 3,
# Group 5 — WARM AIR SYSTEMS (incl. HP with warm air dist.) (p.173)
2501: 1, 2502: 1, 2503: 1, 2504: 1,
2505: 2,
2506: 3,
# Group 6 — ROOM HEATER SYSTEMS (p.173). Codes 2602-2605 type 3 per
# spec; 2601 is type 2.
2601: 2,
2602: 3, 2603: 3, 2604: 3, 2605: 3,
# Group 7 — OTHER SYSTEMS (p.173)
2701: 1, 2702: 1, 2703: 1, 2704: 1,
2705: 2,
2706: 3,
# Group 0 — NO HEATING SYSTEM PRESENT (p.171). Single code only.
2699: 2,
}
class UnmappedSapCode(ValueError):
"""A SAP/Table integer code lodged on the cert that the calculator
does not yet know how to translate to a dispatch result.
Raised by strict cascade-dispatch helpers (Table 4e control codes,
future Table 4d emitter codes, etc.) to surface spec-coverage gaps
at the cascade boundary instead of silently defaulting to a
fallback value. Mirrors `UnmappedApiCode` and `UnmappedElmhurstLabel`
on the mapper side same forcing-function philosophy for the
calculator side.
Distinguish "lodging absent" (code is None cascade default OK,
spec "assume as-built" applies) from "lodging present but unmapped"
(raise fixture exposes a dispatch-dict gap that needs an entry).
"""
def __init__(self, field: str, value: object) -> None:
super().__init__(
f"unmapped SAP code in {field}: {value!r}; "
f"add an entry to the corresponding cascade dispatch dict"
)
self.field = field
self.value = value
def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
@ -767,15 +817,28 @@ def _main_heating_efficiency(epc: EpcPropertyData) -> float:
def _control_type(main: Optional[MainHeatingDetail]) -> int:
"""SAP 10.2 §7.1 / Table 9 control type 1/2/3 from the
`main_heating_control` code on `MainHeatingDetail`. Defaults to 2
(programmer + room thermostat) when the code is missing the modal
RdSAP case."""
`main_heating_control` code on `MainHeatingDetail`.
Strict-dispatch per [[reference-unmapped-api-code]]: distinguish
"lodging absent" (return modal default type 2) from "lodging
present but unmapped" (raise `UnmappedSapCode` so the spec-coverage
gap surfaces at test time instead of silently defaulting and
hiding bugs like S0380.87 HP control 2207 silently routed to
type 2 for ~22 slices). The cascade is "total" per RdSAP §6.2.3
for *value* defaults but strict for *dispatch* coverage.
"""
if main is None:
return 2
code = main.main_heating_control
# `not code` catches the absent-lodging sentinels (None / 0 / "")
# that the datatype's Union[int, str] declaration nominally
# forbids but runtime data exhibits (e.g. cert 000565 Main 2 has
# `main_heating_control=""`). Cascade defaults to modal type 2.
if not code:
return 2
if isinstance(code, int) and code in _CONTROL_TYPE_BY_CODE:
return _CONTROL_TYPE_BY_CODE[code]
return 2
raise UnmappedSapCode("main_heating_control", code)
def _responsiveness(main: Optional[MainHeatingDetail]) -> float:

View file

@ -36,6 +36,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
UnmappedSapCode,
cert_to_demand_inputs,
cert_to_inputs,
pcdb_combi_loss_override,
@ -806,6 +807,134 @@ def test_main_heating_control_code_maps_to_sap_control_type() -> None:
assert type_2_via_2113.control_type == 2
def test_main_heating_control_code_table_4e_full_coverage_groups_0_through_7() -> None:
# Arrange — SAP 10.2 Table 4e (PDF p.171-174) defines control codes
# across 8 groups (0: no heating, 1: boiler, 2: HP, 3: heat network,
# 4: electric storage, 5: warm air, 6: room heater, 7: other).
# Pre-S0380.88 only Group 1 (and Group 2 after S0380.87) had dispatch
# entries; codes from Groups 3-7 silently fell through to the default
# `return 2`. Corpus audit (full JSON sweep) found cohort certs lodging
# codes from Groups 3 (2307), 4 (2401), and 6 (2603) — all silently
# mis-classified before this slice.
#
# This pin asserts every Table 4e code routes to its spec-correct
# control type. Mirror of [[reference-unmapped-api-code]] strict-raise
# pattern; cf. S0380.87 (Group 2 dispatch closure) for the bug-pattern
# this slice forecloses against.
def _epc_with_control(code: int):
return make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
sap_building_parts=[make_building_part(
floor_dimensions=[make_floor_dimension(total_floor_area_m2=90.0, floor=0)],
)],
sap_heating=make_sap_heating(
main_heating_details=[
MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=code,
main_heating_category=2, sap_main_heating_code=102,
),
],
),
)
# Act / Assert — Table 4e per-group spec types (PDF p.171-174)
# Group 3 (Heat Network)
assert cert_to_inputs(_epc_with_control(2301)).control_type == 1
assert cert_to_inputs(_epc_with_control(2304)).control_type == 1
assert cert_to_inputs(_epc_with_control(2305)).control_type == 2
assert cert_to_inputs(_epc_with_control(2307)).control_type == 2 # corpus
assert cert_to_inputs(_epc_with_control(2311)).control_type == 2
assert cert_to_inputs(_epc_with_control(2310)).control_type == 3
assert cert_to_inputs(_epc_with_control(2314)).control_type == 3
# Group 4 (Electric Storage) — all type 3
assert cert_to_inputs(_epc_with_control(2401)).control_type == 3 # corpus
assert cert_to_inputs(_epc_with_control(2402)).control_type == 3
assert cert_to_inputs(_epc_with_control(2403)).control_type == 3
assert cert_to_inputs(_epc_with_control(2404)).control_type == 3
# Group 5 (Warm Air)
assert cert_to_inputs(_epc_with_control(2501)).control_type == 1
assert cert_to_inputs(_epc_with_control(2505)).control_type == 2
assert cert_to_inputs(_epc_with_control(2506)).control_type == 3
# Group 6 (Room Heater)
assert cert_to_inputs(_epc_with_control(2601)).control_type == 2
assert cert_to_inputs(_epc_with_control(2603)).control_type == 3 # corpus
assert cert_to_inputs(_epc_with_control(2605)).control_type == 3
# Group 7 (Other)
assert cert_to_inputs(_epc_with_control(2701)).control_type == 1
assert cert_to_inputs(_epc_with_control(2705)).control_type == 2
assert cert_to_inputs(_epc_with_control(2706)).control_type == 3
def test_cert_to_inputs_raises_unmapped_sap_code_on_unknown_main_heating_control() -> None:
# Arrange — when the cert lodges a `main_heating_control` integer
# that doesn't appear in the spec-derived dispatch dict, the cascade
# must raise `UnmappedSapCode` rather than silently defaulting to
# type 2. This is the forcing function that surfaces spec-coverage
# gaps (like S0380.87's 22XX absence) at test time instead of
# masquerading as a downstream test-pin residual.
#
# Mirrors the [[reference-unmapped-api-code]] mapper pattern.
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
sap_building_parts=[make_building_part(
floor_dimensions=[make_floor_dimension(total_floor_area_m2=90.0, floor=0)],
)],
sap_heating=make_sap_heating(
main_heating_details=[
MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2998, # not in Table 4e
main_heating_category=2, sap_main_heating_code=102,
),
],
),
)
# Act / Assert
with pytest.raises(UnmappedSapCode) as excinfo:
cert_to_inputs(epc)
assert excinfo.value.field == "main_heating_control"
assert excinfo.value.value == 2998
def test_cert_to_inputs_does_not_raise_when_main_heating_control_is_missing() -> None:
# Arrange — distinguish "lodging absent" (cert didn't lodge a control
# code at all → cascade default OK) from "lodging present but unmapped"
# (raise). Per the strict-raise contract documented in
# [[reference-unmapped-api-code]]: None means "no evidence", which is
# the spec's "assume as-built / Table 9 default" branch — silent
# default returns the modal type 2 (programmer + room thermostat).
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
sap_building_parts=[make_building_part(
floor_dimensions=[make_floor_dimension(total_floor_area_m2=90.0, floor=0)],
)],
sap_heating=make_sap_heating(
main_heating_details=[
MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=None,
main_heating_category=2, sap_main_heating_code=102,
),
],
),
)
# Act — must not raise
inputs = cert_to_inputs(epc)
# Assert — modal type 2 default per cascade docstring
assert inputs.control_type == 2
def test_heat_pump_control_code_2207_maps_to_sap_control_type_3_per_table_4e_group_2() -> None:
# Arrange — SAP 10.2 Table 4e GROUP 2 (PDF p.172-173, "HEAT PUMPS
# WITH RADIATORS OR UNDERFLOOR HEATING"):