diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index dccec930..1759873f 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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: diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 469cf164..8180574b 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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"):