From 6d02d205c53e0ac2d99265322b0d0f09e5b548fd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 09:21:37 +0000 Subject: [PATCH] Slice S0380.89: Table 4d responsiveness dispatch + screed-UFH bug fix + strict raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 4d (PDF p.170) "Heating type and responsiveness ... depending on heat emitter": Heat emitter R ------------------------------------------------- ----- Systems with radiators 1.0 Underfloor (wet) — pipes in insulated timber floor 1.0 Underfloor (wet) — pipes in screed above insulation 0.75 Underfloor (wet) — pipes in concrete slab 0.25 Warm air via fan coil units 1.0 Pre-S0380.89 the cascade `_responsiveness` had: if isinstance(emitter, int) and emitter == 2: return 0.25 return 1.0 But the Elmhurst cert-side enum (`_ELMHURST_HEAT_EMITTER_TO_SAP10` at `datatypes/epc/domain/mapper.py:3646`) maps: 1 = Radiators 2 = Underfloor (in screed) ← spec R=0.75, NOT 0.25 3 = Underfloor (timber floor) 4 = Warm air 5 = Fan coils The cascade silently treated screed UFH (Elmhurst code 2) as concrete-slab UFH (R=0.25 — Table 4d's most thermally massive UFH variant). The bug halved the actual spec responsiveness — for a screed UFH cert, off-period temperature reduction was computed with R=0.25 instead of R=0.75, materially under-counting MIT drop and over-counting SH demand. The bug is latent on cohort + golden because `_first_main_heating` picks main[0] and almost all certs lodge radiators (emitter=1) there. Corpus audit (full JSON sweep): emitter=2 appears on 2 records and in both cases on a secondary main slot (e.g. golden cert 0240-0200-5706-2365-8010 main[1]) — never on the selected first main. The fix preempts the next cert that lodges screed UFH on main[0]. Fix: - New `_RESPONSIVENESS_BY_EMITTER_CODE` dispatch dict reflecting Table 4d per the Elmhurst cert-side enum (1: 1.0, 2: 0.75, 3: 1.0, 4: 1.0, 5: 1.0). "Concrete slab UFH" (Table 4d R=0.25) has no cert-side enum entry — that variant would need a new mapper code before the cascade can dispatch it. - `_responsiveness` flipped to strict-raise per [[reference- unmapped-sap-code]]: absent lodging (None / 0 / "") returns modal R=1.0 default; lodging present but unmapped raises `UnmappedSapCode("heat_emitter_type", value)`. Tests (4 new, AAA-structure): - `test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_per_table_4d` pins the bug fix: emitter=2 → R=0.75 (was 0.25) - `test_heat_emitter_code_dispatch_table_4d_full_coverage` pins all 5 Elmhurst emitter codes to spec-correct R - `test_responsiveness_raises_unmapped_sap_code_on_unknown_emitter` pins the strict-raise contract (hypothetical code 99 raises) - `test_responsiveness_default_1p0_when_emitter_lodging_absent` pins the absent-lodging contract (None / 0 / "" → 1.0) Test baseline: 568 pass (was 564 + 4 new) + 9 expected `test_sap_result_pin[000565-*]` fails unchanged. Cohort + golden unaffected (all use emitter=1 on main[0]). Pyright net-zero per touched file (one `pyright: ignore` added on the absent-lodging test where `main_heating_control=None` is passed to a dataclass declaring `Union[int, str]` — runtime data exhibits None on certs lacking space-heating controls, so the test covers a real codepath the type system doesn't model). Per the user-requested "we keep debugging silent fallbacks" mandate, this is the second slice (after S0380.88) in the strict-raise series. Next candidates per [[reference-unmapped-sap-code]]: PV pitch + overshading, meter→tariff, heat-network DLF, secondary-heating fraction by category. Co-Authored-By: Claude Opus 4.7 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 45 ++++++- .../rdsap/tests/test_cert_to_inputs.py | 113 +++++++++++++++++- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 1759873f..dbdb4d01 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -842,14 +842,49 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int: def _responsiveness(main: Optional[MainHeatingDetail]) -> float: - """SAP 10.2 Table 9b responsiveness R ∈ [0, 1]. Radiators ≈ 1.0; - underfloor ≈ 0.25. Defaults to radiators.""" + """SAP 10.2 Table 4d (PDF p.170) heat-emitter responsiveness R ∈ [0, 1]. + + Cert-side heat_emitter_type enum (per `_ELMHURST_HEAT_EMITTER_TO_SAP10` + at datatypes/epc/domain/mapper.py:3646): + 1 = Radiators → R = 1.0 + 2 = Underfloor (in screed above insulation) → R = 0.75 + 3 = Underfloor (timber floor) → R = 1.0 + 4 = Warm air → R = 1.0 + 5 = Fan coils → R = 1.0 + + "Concrete slab" UFH (Table 4d R=0.25) has no cert-side enum entry + yet — that variant would need a new mapper code before the cascade + can dispatch it. + + Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging + (None / 0 / "") returns modal default R=1.0 (radiators); lodging + present but unmapped raises `UnmappedSapCode` so the spec-coverage + gap surfaces at test time. + """ if main is None: return 1.0 emitter = main.heat_emitter_type - if isinstance(emitter, int) and emitter == 2: - return 0.25 - return 1.0 + if not emitter: + return 1.0 + if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE: + return _RESPONSIVENESS_BY_EMITTER_CODE[emitter] + raise UnmappedSapCode("heat_emitter_type", emitter) + + +# SAP 10.2 Table 4d (PDF p.170) — heat-emitter responsiveness R. +# Keyed on the Elmhurst-mapper cert-side integer enum (mirrored by the +# API mapper which passes the integer through directly). Pre-S0380.89 +# the cascade had `if emitter == 2: return 0.25` — silently mis-treating +# screed UFH (spec R=0.75) as concrete-slab UFH (spec R=0.25). The +# spec R-table is keyed on physical emitter category, not on a single +# "underfloor" lumping. +_RESPONSIVENESS_BY_EMITTER_CODE: Final[dict[int, float]] = { + 1: 1.0, # Radiators + 2: 0.75, # Underfloor (in screed above insulation) + 3: 1.0, # Underfloor (timber floor) + 4: 1.0, # Warm air + 5: 1.0, # Fan coils +} def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: 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 8180574b..48dac2f9 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -35,6 +35,7 @@ from domain.sap10_calculator.calculator import Sap10Calculator, SapResult 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] + _responsiveness, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] UnmappedSapCode, cert_to_demand_inputs, @@ -903,6 +904,115 @@ def test_cert_to_inputs_raises_unmapped_sap_code_on_unknown_main_heating_control assert excinfo.value.value == 2998 +def test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_per_table_4d() -> None: + # Arrange — SAP 10.2 Table 4d (PDF p.170, "Heating type and + # responsiveness ... depending on heat emitter"): + # + # Underfloor heating (wet system): + # pipes in insulated timber floor R = 1.0 + # pipes in screed above insulation R = 0.75 ← Elmhurst code 2 + # pipes in concrete slab R = 0.25 + # + # The Elmhurst mapper at `_ELMHURST_HEAT_EMITTER_TO_SAP10` defines + # the cert-side integer enum: 1=Radiators, 2=Underfloor (in screed), + # 3=Underfloor (timber floor), 4=Warm air, 5=Fan coils. Pre-S0380.89 + # `_responsiveness` had `if emitter == 2: return 0.25` — silently + # treating screed UFH as concrete-slab UFH (R=0.25 instead of the + # spec-correct R=0.75). Underfloor in screed has 3× the + # responsiveness the cascade was assigning it. + # + # The bug is mostly latent because (a) `_first_main_heating` picks + # main[0] and most certs lodge radiators there, (b) emitter=2 only + # appears on main[1] in the corpus (e.g. golden cert + # 0240-0200-5706-2365-8010 — but that cert's main[0] is emitter=1 + # so the bug doesn't surface). Fixing it preempts the next cert that + # legitimately lodges screed UFH on main[0]. + + main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, heat_emitter_type=2, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=102, + ) + + # Act + result = _responsiveness(main) + + # Assert — SAP 10.2 Table 4d "pipes in screed above insulation" R=0.75 + assert abs(result - 0.75) <= 1e-9 + + +def test_heat_emitter_code_dispatch_table_4d_full_coverage() -> None: + # Arrange — SAP 10.2 Table 4d responsiveness by Elmhurst-mapper + # heat_emitter_type code (per `_ELMHURST_HEAT_EMITTER_TO_SAP10` at + # datatypes/epc/domain/mapper.py:3646): + # + # Code Emitter type R + # ---- ---------------------------- ---- + # 1 Radiators 1.0 + # 2 Underfloor (in screed) 0.75 + # 3 Underfloor (timber floor) 1.0 + # 4 Warm air 1.0 + # 5 Fan coils 1.0 + # + # No Elmhurst code maps to "concrete slab UFH" (R=0.25 per Table 4d). + # That category would need a new cert-side enum entry first. + + def _main_with(emitter_code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, heat_emitter_type=emitter_code, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=102, + ) + + # Act / Assert + assert abs(_responsiveness(_main_with(1)) - 1.0) <= 1e-9 + assert abs(_responsiveness(_main_with(2)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_main_with(3)) - 1.0) <= 1e-9 + assert abs(_responsiveness(_main_with(4)) - 1.0) <= 1e-9 + assert abs(_responsiveness(_main_with(5)) - 1.0) <= 1e-9 + + +def test_responsiveness_raises_unmapped_sap_code_on_unknown_emitter() -> None: + # Arrange — same strict-raise contract as `_control_type` per + # [[reference-unmapped-sap-code]]: lodging present but unmapped + # raises so the spec-coverage gap surfaces at test time. A code + # outside the dispatch dict (e.g. hypothetical code 9 = "Solid + # fuel stove" or future enum value) must raise, not silently + # return the radiators default. + main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, heat_emitter_type=99, # not in dispatch + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=102, + ) + + # Act / Assert + with pytest.raises(UnmappedSapCode) as excinfo: + _responsiveness(main) + assert excinfo.value.field == "heat_emitter_type" + assert excinfo.value.value == 99 + + +def test_responsiveness_default_1p0_when_emitter_lodging_absent() -> None: + # Arrange — emitter lodging absent (None / 0 / "") returns modal + # default R=1.0 (radiators). Corpus has 4 certs lodging emitter=0 + # as the "no main heating system present" sentinel. Mirror of the + # control-type absent-lodging contract from S0380.88. + + def _main_with(emitter_value: object) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, + heat_emitter_type=emitter_value, # type: ignore[arg-type] + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=102, + ) + + # Act / Assert + assert _responsiveness(None) == 1.0 + assert _responsiveness(_main_with(0)) == 1.0 + assert _responsiveness(_main_with("")) == 1.0 + assert _responsiveness(_main_with(None)) == 1.0 + + 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" @@ -921,7 +1031,8 @@ def test_cert_to_inputs_does_not_raise_when_main_heating_control_is_missing() -> main_heating_details=[ MainHeatingDetail( has_fghrs=False, main_fuel_type=26, heat_emitter_type=1, - emitter_temperature=1, main_heating_control=None, + emitter_temperature=1, + main_heating_control=None, # pyright: ignore[reportArgumentType] main_heating_category=2, sap_main_heating_code=102, ), ],