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, ), ],