From 0aa40b63cd78dbb0eaf6785b804d2a6c5516fba7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 09:43:27 +0000 Subject: [PATCH] Slice S0380.132: strict-raise MissingMainFuelType on empty main_fuel_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cascade's `_main_fuel_code` previously returned None when `MainHeatingDetail.main_fuel_type` was anything other than an int (empty string, None, or an unmapped string label). The downstream `table_32.unit_price_p_per_kwh(None)` then silently defaulted to mains gas (3.48 p/kWh / CO2 0.21 kg/kWh / η 0.45 / PE 1.22) — a misleading fallback where cost may happen to be close but CO2 / PE / efficiency are completely wrong for the actual heating system. Probe of the heating-systems corpus surfaced 26 of 41 controlled- variable variants with `main_fuel_type=''`: Community heating 1/2/3/4/6 (Table 4a 301-304) 5 Electric 11/12/13/14 (Table 4a 5xx/6xx/7xx) 4 No system (SAP code 699) 1 Oil 2 (HVO) / oil 3 (FAME) / oil 4 (FAME) / oil 5 (bioethanol) / oil 6 (B30K) (Table 4b) 5 Solid fuel 2..11 (Table 4a 150-160 + 600-636) 10 pcdb 3 (lodges 'Bulk LPG' string — mapper dict gap) 1 Each pre-slice carried a residual pin in `_EXPECTATIONS` encoding the broken mains-gas-default state. Solid fuel 8's +0.87 ΔSAP — the "smallest open residual" the user asked to investigate next — turned out to be the net of compensating cost/efficiency errors; the CO2 delta was +3525 kg/yr and PE +4103 kWh/yr because the cascade was costing wood chips as mains gas. Two changes land together: 1. Add `MissingMainFuelType(ValueError)` to `domain/sap10_calculator/exceptions.py`. Semantics distinct from the sibling `UnmappedSapCode` (which is for unmapped int dispatch codes; this is for "value not resolvable to a SAP fuel code at all"). The error message names the lodged value + the `sap_main_heating_code` hint so the upstream mapper fix is obvious. 2. `_main_fuel_code` in `cert_to_inputs.py` now raises `MissingMainFuelType` when `main_fuel_type` is not an int. `main is None` still returns None (genuinely no main heating). The 26 blocked corpus variants are lifted out of the `_EXPECTATIONS` residual-pin grid into a new tuple `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` driving a new parametrised test `test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type` that asserts the raise for each blocked variant. As mapper-side fixes land (deriving fuel from `sap_main_heating_code` via SAP 10.2 Table 4a/4b/4f, or extending `_ELMHURST_MAIN_FUEL_TO_SAP10`), variants move back onto the residual-pin grid. Mirrors the [[reference-unmapped-sap-code]] / [[reference-unmapped- api-code]] strict-raise pattern: forcing function for spec/mapper completion at the cascade boundary instead of silently producing wrong outputs. Extended handover suite at HEAD post-slice: 875 pass / 0 fail (was 874; +1 from the new `_main_fuel_code` strict-raise unit test; 26 blocked corpus pins replaced 1:1 by 26 assert-on-raise tests). Pyright net-zero (43 → 43 — all pre-existing `pytest.approx` flags). No golden fixture impact — every golden cert carries an int `main_fuel_type`. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 115 ++++++++++++++---- domain/sap10_calculator/exceptions.py | 27 ++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 21 +++- .../rdsap/tests/test_cert_to_inputs.py | 45 ++++++- 4 files changed, 179 insertions(+), 29 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 7ae63b76..b13cc8fa 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -38,6 +38,7 @@ import pytest from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.exceptions import MissingMainFuelType from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, cert_to_inputs, @@ -82,18 +83,21 @@ class _CorpusExpectation: # price from RdSAP 10 Table 32's published 7.64 p/kWh to the Elmhurst- # worksheet-canonical 5.44 p/kWh. Worst-residual oil ΔSAP −11.63 → +0.42; # pcdb 1 −9.41 → +6.95 (largest remaining oil-cohort gap). +# +# Slice S0380.132 surfaced 26 variants where the Elmhurst Summary §14.0 +# "Fuel Type" lodging is absent and the mapper produces +# `main_fuel_type=''` (or an unmapped string like 'Bulk LPG'). Before +# this slice the cascade silently routed those certs through mains gas +# defaults (3.48 p/kWh / 0.21 kg CO2/kWh / η 0.45) — the pre-slice +# residual pins encoded that broken state. The cascade now raises +# `MissingMainFuelType` for these variants; the corresponding +# `_CorpusExpectation` entries were lifted out into +# `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` (assert-on-raise test) until +# each mapper gap is closed and the cert can be moved back onto the +# residual-pin grid. _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+5.6680, expected_cost_resid_gbp=-130.5995, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=+1467.8983), - _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+4.1830, expected_cost_resid_gbp=-96.3816, expected_co2_resid_kg=-786.6453, expected_pe_resid_kwh=-940.7364), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+1.1558, expected_cost_resid_gbp=-26.6309, expected_co2_resid_kg=-498.3058, expected_pe_resid_kwh=+636.7545), - _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+4.1830, expected_cost_resid_gbp=-96.3816, expected_co2_resid_kg=+2545.7991, expected_pe_resid_kwh=+11009.4778), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+1.1558, expected_cost_resid_gbp=-26.6309, expected_co2_resid_kg=-3465.0640, expected_pe_resid_kwh=-374.6720), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-6.8661, expected_cost_resid_gbp=+158.2067, expected_co2_resid_kg=-2002.8867, expected_pe_resid_kwh=+6995.3140), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=+9.6439, expected_cost_resid_gbp=-222.2109, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+2837.1414), - _CorpusExpectation(variant='electric 11', block='11a', expected_sap_resid=+18.1002, expected_cost_resid_gbp=-417.0547, expected_co2_resid_kg=+579.6971, expected_pe_resid_kwh=-1067.1863), - _CorpusExpectation(variant='electric 12', block='11a', expected_sap_resid=+15.4249, expected_cost_resid_gbp=-355.4117, expected_co2_resid_kg=+620.4563, expected_pe_resid_kwh=-467.5748), - _CorpusExpectation(variant='electric 13', block='11a', expected_sap_resid=+18.3886, expected_cost_resid_gbp=-423.7001, expected_co2_resid_kg=+619.3628, expected_pe_resid_kwh=-1129.2285), - _CorpusExpectation(variant='electric 14', block='11a', expected_sap_resid=+18.3886, expected_cost_resid_gbp=-423.7001, expected_co2_resid_kg=+619.3628, expected_pe_resid_kwh=-1129.2285), _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+5.8523, expected_cost_resid_gbp=-134.8455, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+2420.9013), _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+14.6973, expected_cost_resid_gbp=-338.6485, expected_co2_resid_kg=-379.1296, expected_pe_resid_kwh=-850.9293), _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+10.9720, expected_cost_resid_gbp=-252.8131, expected_co2_resid_kg=-218.5642, expected_pe_resid_kwh=+540.3309), @@ -102,28 +106,56 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+6.8875, expected_cost_resid_gbp=-158.6999, expected_co2_resid_kg=-34.9564, expected_pe_resid_kwh=+2113.8303), _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+12.0340, expected_cost_resid_gbp=-277.2813, expected_co2_resid_kg=-255.6076, expected_pe_resid_kwh=+362.4518), _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+5.1598, expected_cost_resid_gbp=-118.8901, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=+639.1890), - _CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+21.9350, expected_cost_resid_gbp=-505.4134, expected_co2_resid_kg=+689.2188, expected_pe_resid_kwh=-2454.8193), _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=+1259.6587), - _CorpusExpectation(variant='oil 2', block='11a', expected_sap_resid=+26.0712, expected_cost_resid_gbp=-600.7179, expected_co2_resid_kg=+2230.1071, expected_pe_resid_kwh=+801.2920), - _CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=+30.9500, expected_cost_resid_gbp=-712.1785, expected_co2_resid_kg=+2859.5796, expected_pe_resid_kwh=+738.4592), - _CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+28.5927, expected_cost_resid_gbp=-655.6129, expected_co2_resid_kg=+2636.9526, expected_pe_resid_kwh=+701.8340), - _CorpusExpectation(variant='oil 5', block='11a', expected_sap_resid=+120.7457, expected_cost_resid_gbp=-6312.0020, expected_co2_resid_kg=+1345.3630, expected_pe_resid_kwh=-2780.6222), - _CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+24.4087, expected_cost_resid_gbp=-561.8886, expected_co2_resid_kg=-658.8928, expected_pe_resid_kwh=-478.5733), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=+2086.7505), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=+2086.7505), _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=+1897.4341), _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+6.9521, expected_cost_resid_gbp=-157.6055, expected_co2_resid_kg=-845.8065, expected_pe_resid_kwh=-171.6971), - _CorpusExpectation(variant='pcdb 3', block='11a', expected_sap_resid=+27.7563, expected_cost_resid_gbp=-637.0435, expected_co2_resid_kg=-446.3815, expected_pe_resid_kwh=+2097.4553), - _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+14.7769, expected_cost_resid_gbp=-340.4814, expected_co2_resid_kg=+1906.2620, expected_pe_resid_kwh=-584.5284), - _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+8.4098, expected_cost_resid_gbp=-193.7739, expected_co2_resid_kg=+2262.3481, expected_pe_resid_kwh=+2583.7764), - _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+6.0050, expected_cost_resid_gbp=-138.3659, expected_co2_resid_kg=-3718.6886, expected_pe_resid_kwh=+1594.6199), - _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+6.1846, expected_cost_resid_gbp=-142.5032, expected_co2_resid_kg=-5877.9595, expected_pe_resid_kwh=+3118.4874), - _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+5.0671, expected_cost_resid_gbp=-116.7534, expected_co2_resid_kg=-3215.4585, expected_pe_resid_kwh=+2547.5896), - _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+3.7888, expected_cost_resid_gbp=-87.2980, expected_co2_resid_kg=-2725.9268, expected_pe_resid_kwh=+3224.8144), - _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+9.2944, expected_cost_resid_gbp=-214.1551, expected_co2_resid_kg=+2174.7565, expected_pe_resid_kwh=+4052.5690), - _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+15.1079, expected_cost_resid_gbp=-344.9565, expected_co2_resid_kg=-3711.3064, expected_pe_resid_kwh=+488.1476), - _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+0.8707, expected_cost_resid_gbp=-20.0627, expected_co2_resid_kg=+3524.9644, expected_pe_resid_kwh=+4103.0089), - _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+15.1593, expected_cost_resid_gbp=-349.2946, expected_co2_resid_kg=+1810.7952, expected_pe_resid_kwh=+168.2046), +) + + +# Variants the mapper currently leaves with `main_fuel_type=''` (no +# §14.0 "Fuel Type" lodged) or an unmapped string (pcdb 3 lodges "Bulk +# LPG" — Elmhurst label not yet in `_ELMHURST_MAIN_FUEL_TO_SAP10`). The +# cascade now strict-raises via `_main_fuel_code` per S0380.132 instead +# of silently defaulting to mains gas. Each entry will move back onto +# the `_EXPECTATIONS` residual-pin grid once the mapper gap closes. +# +# Grouped by SAP code range to mirror the mapper-derivation slices the +# follow-ups will need: +# - Community heating (Table 4a 301-304) ×5 +# - Electric storage / direct-acting (Table 4a 5xx, 6xx, 7xx) ×4 +# - "No system" (SAP code 699) ×1 +# - Liquid-fuel boilers Table 4b non-oil (HVO/FAME/B30K/bioethanol) ×5 +# - Solid-fuel boilers (Table 4a 150-160, 600-636) ×10 +# - PCDB-lodged "Bulk LPG" mapper-dict gap ×1 +_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE: tuple[str, ...] = ( + 'community heating 1', + 'community heating 2', + 'community heating 3', + 'community heating 4', + 'community heating 6', + 'electric 11', + 'electric 12', + 'electric 13', + 'electric 14', + 'no system', + 'oil 2', + 'oil 3', + 'oil 4', + 'oil 5', + 'oil 6', + 'pcdb 3', + 'solid fuel 10', + 'solid fuel 11', + 'solid fuel 2', + 'solid fuel 3', + 'solid fuel 4', + 'solid fuel 5', + 'solid fuel 6', + 'solid fuel 7', + 'solid fuel 8', + 'solid fuel 9', ) @@ -285,3 +317,34 @@ def test_heating_systems_corpus_residual_matches_pin( f"drifted from pin {expectation.expected_pe_resid_kwh:+.4f} kWh/yr " f"(tolerance ±{_PE_RESID_ABS_TOLERANCE_KWH})" ) + + +@pytest.mark.parametrize( + "variant", + _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE, + ids=lambda v: v, +) +def test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type( + variant: str, +) -> None: + # Arrange — every variant in `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` + # has an Elmhurst Summary §14.0 that does not lodge "Fuel Type" (or + # lodges a string label the mapper's `_ELMHURST_MAIN_FUEL_TO_SAP10` + # doesn't yet recognise). The mapper consequently produces + # `MainHeatingDetail.main_fuel_type=''` (or the raw unmapped + # string), so the cascade's `_main_fuel_code` strict-raises per + # S0380.132 (mirror of [[reference-unmapped-sap-code]] pattern). + # + # This forcing-function test asserts the raise actually fires for + # each blocked variant. As mapper-side fixes land (deriving the + # fuel from `sap_main_heating_code` via SAP 10.2 Table 4a/4b/4f, + # or extending the Elmhurst label dict), variants move out of this + # list and back onto the residual-pin grid in `_EXPECTATIONS`. + summary_pdf, _ = _variant_paths(variant) + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act / Assert + with pytest.raises(MissingMainFuelType): + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) diff --git a/domain/sap10_calculator/exceptions.py b/domain/sap10_calculator/exceptions.py index 54ccffe3..a91bd9cd 100644 --- a/domain/sap10_calculator/exceptions.py +++ b/domain/sap10_calculator/exceptions.py @@ -34,3 +34,30 @@ class UnmappedSapCode(ValueError): ) self.field = field self.value = value + + +class MissingMainFuelType(ValueError): + """The cascade was asked to resolve `MainHeatingDetail.main_fuel_type` + but the mapper produced no usable SAP fuel code (None / empty string + / unmapped string label). + + Unlike the Table 4d/4e dispatch sites where "absent" maps to a spec- + blessed "assume as-built" default, heating fuel has no defensible + default: silently routing to mains gas produces a misleading cascade + output where cost may happen to be close but CO2 / PE / efficiency + are completely wrong for the actual heating system. The fix is + upstream in the mapper — extract the fuel from the appropriate + Summary / EPC field, or derive it from `sap_main_heating_code` + via SAP 10.2 Table 4a/4b/4f. + """ + + def __init__(self, value: object, sap_main_heating_code: object) -> None: + super().__init__( + f"MainHeatingDetail.main_fuel_type is not resolvable to a SAP " + f"fuel code (got {value!r}); sap_main_heating_code=" + f"{sap_main_heating_code!r}. Fix the mapper to populate " + f"main_fuel_type as an int via Summary / EPC fields or via " + f"SAP 10.2 Table 4a/4b/4f derivation from the SAP code." + ) + self.value = value + self.sap_main_heating_code = sap_main_heating_code diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 71cca2cb..ce03cf9f 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -700,7 +700,10 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = { } -from domain.sap10_calculator.exceptions import UnmappedSapCode +from domain.sap10_calculator.exceptions import ( + MissingMainFuelType, + UnmappedSapCode, +) @@ -1006,10 +1009,24 @@ _RESPONSIVENESS_BY_EMITTER_CODE: Final[dict[int, float]] = { def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: + """Resolve `MainHeatingDetail.main_fuel_type` to a SAP fuel code. + + - `main is None` (no main heating system) → None. + - `main_fuel_type` is an int → that code. + - `main_fuel_type` is anything else (empty string from a mapper + extraction gap, or an unmapped string label like 'Bulk LPG') → + raise `MissingMainFuelType`. Heating fuel has no defensible + "assume as-built" default (silently routing to mains gas + mis-categorises CO2 / PE / efficiency), so the cascade strict- + raises to force the mapper-side fix. Mirror of the + [[reference-unmapped-sap-code]] strict-raise pattern. + """ if main is None: return None fuel = main.main_fuel_type - return fuel if isinstance(fuel, int) else None + if isinstance(fuel, int): + return fuel + raise MissingMainFuelType(fuel, main.sap_main_heating_code) def _fuel_cost_gbp_per_kwh( 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 91f97813..4fcc45d1 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -33,7 +33,10 @@ from domain.sap10_ml.tests._fixtures import ( make_window, ) from domain.sap10_calculator.calculator import Sap10Calculator, SapResult -from domain.sap10_calculator.exceptions import UnmappedSapCode +from domain.sap10_calculator.exceptions import ( + MissingMainFuelType, + UnmappedSapCode, +) from domain.sap10_calculator.rdsap.cert_to_inputs import ( _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] @@ -978,6 +981,46 @@ def test_cert_to_inputs_raises_unmapped_sap_code_on_unknown_main_heating_control assert excinfo.value.value == 2998 +def test_cert_to_inputs_raises_missing_main_fuel_type_on_empty_string() -> None: + # Arrange — when the mapper produces a MainHeatingDetail with an + # empty-string `main_fuel_type` (Elmhurst Summary §14.0 leaves the + # "Fuel Type" line absent for many SAP code ranges — solid-fuel + # boilers 150-160, community heating 301-304, electric storage 5xx, + # "no system" 699 — leaving only `sap_main_heating_code` as the + # fuel hint), the cascade has no defensible default. Silently + # routing to mains gas (3.48 p/kWh / CO2 0.21 / η 0.45) produces a + # misleading cascade output where cost may happen to be close but + # CO2 / PE / efficiency are completely wrong. + # + # `_main_fuel_code` must raise `MissingMainFuelType` so the upstream + # mapper gap surfaces unambiguously at test time. Mirror of + # `UnmappedSapCode` strict-raise pattern per + # [[reference-unmapped-sap-code]]. + 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="", heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=4, sap_main_heating_code=160, + ), + ], + ), + ) + + # Act / Assert + with pytest.raises(MissingMainFuelType) as excinfo: + cert_to_inputs(epc) + assert excinfo.value.value == "" + assert excinfo.value.sap_main_heating_code == 160 + + 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"):