From 829a3318dc53894c86136a7a5c021c24a9ed0f28 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 13:11:36 +0000 Subject: [PATCH] Slice S0380.135: dispatch responsiveness via Table 4a SAP code (solid-fuel cluster) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 spec line 15271: "R = responsiveness of main heating system (Table 4a or Table 4d)" The cascade's `_responsiveness` was keyed solely on `heat_emitter_type` (Table 4d), which is correct for systems whose responsiveness is determined by the emitter (gas / oil / HP boilers feeding radiators or UFH). But for systems with intrinsically low responsiveness — solid- fuel room heaters, range cookers, independent solid-fuel boilers — the spec lodges R directly in Table 4a against the heating-system SAP code, and that value overrides any emitter-based lookup. For solid fuel 8 (SAP code 160 = "Range cooker boiler (integral oven and boiler)", lodged with radiators emitter), pre-slice the cascade returned R = 1.0 (radiators) instead of the spec-correct R = 0.50 (Table 4a p.169). The Table 9b mean-internal-temperature adjustment then over-estimated heating-system response, under-estimating space heating demand by ~10% (cascade demand 6874.80 kWh vs worksheet EPC implied 7566 kWh). The fix adds a new dispatch `_RESPONSIVENESS_BY_SAP_CODE` consulted first in `_responsiveness`; SAP codes not in the dict fall through to the existing Table 4d emitter lookup. Table 4a entries added (SAP 10.2 PDF p.169-170): 151 Manual feed independent boiler R=0.75 153 Auto (gravity) feed independent boiler R=0.75 155 Wood chip/pellet independent boiler R=0.75 156 Open fire with back boiler to radiators R=0.50 158 Closed room heater with boiler to radiators R=0.50 159 Stove (pellet-fired) with boiler to radiators R=0.75 160 Range cooker boiler (integral oven+boiler) R=0.50 161 Range cooker boiler (independent oven+boiler) R=0.50 631 Open fire in grate R=0.50 632 Open fire with back boiler (no radiators) R=0.50 633 Closed room heater R=0.50 634 Closed room heater with boiler (no radiators) R=0.50 635 Stove (pellet fired) R=0.75 636 Stove (pellet fired) with boiler (no rads) R=0.75 Heating-systems corpus impact — 10 solid-fuel variants re-pinned: variant ΔSAP was Δcost was ΔPE was solid fuel 2 +2.64 +4.79 -£60 -£110 -1211 -2292 solid fuel 3 +1.32 +4.43 -£30 -£102 -935 -2496 solid fuel 4 +1.59 +4.13 -£37 -£95 +151 -1097 solid fuel 5 +1.70 +2.71 -£39 -£62 +160 -331 solid fuel 6 -11.37 -7.38 +£268 +£168 +87 -1313 ← see below solid fuel 7 +2.04 +5.82 -£47 -£131 +44 -1638 solid fuel 8 +1.81 +4.24 -£42 -£98 +88 -1308 solid fuel 9 +1.71 +3.44 -£39 -£79 +155 -510 solid fuel 10 +1.75 +5.14 -£40 -£118 +120 -1315 solid fuel 11 +1.62 +4.35 -£37 -£100 +171 -962 7/10 PE residuals close to ±220 kWh (down from -331..-2496). 9/10 SAP residuals tighten to +1.32..+2.64 (down from +2.71..+5.82). solid fuel 6 (Dual Fuel Anthracite Wood, SAP 160) SAP residual regresses -7.38 → -11.37 while PE closes +87. The dual-fuel cascade has a separate bug now exposed by the more-accurate demand calc; queued for a follow-up slice. Non-solid-fuel variants (15) unchanged — their SAP codes aren't in the new dispatch dict so they fall through to Table 4d as before. Electric storage Table 4a rows (193-196, 422-424, 515, 701) and the spec's other low-responsiveness codes can be added in follow-up slices as electric corpus variants are unblocked. Extended handover suite: 877 pass / 0 fail (+1 new responsiveness AAA test). Pyright net-zero on touched files (43 → 43). No golden fixture impact — no golden cert lodges a solid-fuel SAP code via the cascade path. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 31 +++++++---- .../sap10_calculator/rdsap/cert_to_inputs.py | 55 ++++++++++++++++++- .../rdsap/tests/test_cert_to_inputs.py | 53 ++++++++++++++++++ 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 314a12b1..e4857632 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -132,6 +132,17 @@ class _CorpusExpectation: # +165; electric 8 +2114 → -224); others surfaced larger demand- # mode residuals that were hidden by the block mismatch (electric # 3/5/6/7/9, pcdb 1, solid fuel 2-11). +# +# Slice S0380.135 added Table 4a per-heating-system responsiveness +# dispatch keyed on `sap_main_heating_code` per SAP 10.2 spec line +# 15271 ("R = responsiveness of main heating system (Table 4a or +# Table 4d)"). Pre-slice `_responsiveness` only consulted Table 4d +# (emitter-based) — for solid-fuel + radiators it returned R=1.0 +# instead of the spec-correct R=0.50 / 0.75. The MIT calc (Table 9b) +# then under-estimated space heating demand by ~10% across all 10 +# solid-fuel corpus variants. All 10 re-pinned: 7/10 close to ±220 +# PE, dual-fuel solid fuel 6 SAP regressed -7.38 → -11.37 (PE +# closed +87) — exposed a separate dual-fuel cascade bug. _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=-11.8017), _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=+164.9052), @@ -155,16 +166,16 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # cost / CO2 / PE all route via the correct Table 32 fuel code. # Remaining residuals are likely heating-system efficiency or # control-type gaps — separate slices. - _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+4.7910, expected_cost_resid_gbp=-110.3933, expected_co2_resid_kg=-484.3578, expected_pe_resid_kwh=-2292.4679), - _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+4.4310, expected_cost_resid_gbp=-102.0983, expected_co2_resid_kg=-1206.1483, expected_pe_resid_kwh=-2496.1951), - _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+4.1283, expected_cost_resid_gbp=-95.1230, expected_co2_resid_kg=-714.4446, expected_pe_resid_kwh=-1097.3549), - _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+2.7081, expected_cost_resid_gbp=-62.3977, expected_co2_resid_kg=-301.4166, expected_pe_resid_kwh=-330.8371), - _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-7.3846, expected_cost_resid_gbp=+168.2332, expected_co2_resid_kg=-153.6470, expected_pe_resid_kwh=-1312.5322), - _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+5.8242, expected_cost_resid_gbp=-131.0462, expected_co2_resid_kg=-758.2093, expected_pe_resid_kwh=-1638.1589), - _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+4.2391, expected_cost_resid_gbp=-97.6761, expected_co2_resid_kg=-14.9661, expected_pe_resid_kwh=-1307.9243), - _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+3.4416, expected_cost_resid_gbp=-79.3010, expected_co2_resid_kg=-8.4751, expected_pe_resid_kwh=-510.4162), - _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+5.1366, expected_cost_resid_gbp=-118.3539, expected_co2_resid_kg=-52.9522, expected_pe_resid_kwh=-1315.3508), - _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+4.3479, expected_cost_resid_gbp=-100.1809, expected_co2_resid_kg=-8.8428, expected_pe_resid_kwh=-962.4251), + _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624), + _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983), + _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+1.5867, expected_cost_resid_gbp=-36.5606, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685), + _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+1.7045, expected_cost_resid_gbp=-39.2732, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328), + _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-11.3731, expected_cost_resid_gbp=+268.4432, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778), + _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+2.0439, expected_cost_resid_gbp=-47.0520, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084), + _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+1.8115, expected_cost_resid_gbp=-41.7407, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830), + _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+1.7052, expected_cost_resid_gbp=-39.2906, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673), + _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+1.7463, expected_cost_resid_gbp=-40.2377, expected_co2_resid_kg=+25.7581, expected_pe_resid_kwh=+119.8372), + _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+1.6215, expected_cost_resid_gbp=-37.3612, expected_co2_resid_kg=+32.7399, expected_pe_resid_kwh=+170.5611), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index ce03cf9f..6dce0a8f 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -963,7 +963,25 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int: def _responsiveness(main: Optional[MainHeatingDetail]) -> float: - """SAP 10.2 Table 4d (PDF p.170) heat-emitter responsiveness R ∈ [0, 1]. + """SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271: + + "R = responsiveness of main heating system (Table 4a or + Table 4d)" + + Two sources, applied in order: + + 1. Table 4a (PDF p.163-170) — per-heating-system R for systems + whose responsiveness is intrinsic to the appliance (typically + lower than 1.0). Solid-fuel room heaters / range cookers / + independent boilers, electric storage / ceiling systems, range + cookers etc. all have spec-lodged R < 1.0 that overrides any + emitter-based lookup. Keyed on `sap_main_heating_code`. + + 2. Table 4d (PDF p.170) — heat-emitter R for systems whose + responsiveness is determined by the emitter type (e.g. gas / + oil / HP boilers feeding radiators or UFH). Keyed on + `heat_emitter_type`. Used as the fallback when the SAP code + isn't in the Table 4a dispatch dict. Cert-side heat_emitter_type enum (per `_ELMHURST_HEAT_EMITTER_TO_SAP10` at datatypes/epc/domain/mapper.py:3646): @@ -984,6 +1002,11 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float: """ if main is None: return 1.0 + # Table 4a — per-heating-system R (overrides emitter lookup). + sap_code = main.sap_main_heating_code + if sap_code is not None and sap_code in _RESPONSIVENESS_BY_SAP_CODE: + return _RESPONSIVENESS_BY_SAP_CODE[sap_code] + # Table 4d — fallback per emitter type. emitter = main.heat_emitter_type if not emitter: return 1.0 @@ -992,6 +1015,36 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float: raise UnmappedSapCode("heat_emitter_type", emitter) +# SAP 10.2 Table 4a (PDF p.163-170) — per-heating-system responsiveness R. +# These rows override the emitter-based Table 4d lookup because the spec +# explicitly lists R against the heating system (the system's intrinsic +# response time dominates over the emitter's distribution dynamics). +# Slice S0380.135 added the solid-fuel rows (151-161 + 631-636); more +# entries are added as fixtures surface them (electric storage / range +# cookers / etc.). SAP codes not in this dict fall through to Table 4d. +_RESPONSIVENESS_BY_SAP_CODE: Final[dict[int, float]] = { + # Solid-fuel independent boilers (Table 4a p.169): + 151: 0.75, # Manual feed independent boiler + 153: 0.75, # Auto (gravity) feed independent boiler + 155: 0.75, # Wood chip/pellet independent boiler + # Solid-fuel room heaters with boiler to radiators (p.169): + 156: 0.50, # Open fire with back boiler to radiators + 158: 0.50, # Closed room heater with boiler to radiators + 159: 0.75, # Stove (pellet-fired) with boiler to radiators + # Range cooker boilers (p.169): + 160: 0.50, # Range cooker boiler (integral oven and boiler) + 161: 0.50, # Range cooker boiler (independent oven and boiler) + # Solid-fuel room heaters without radiators (p.170 — alternative + # SAP code range for the same physical appliances): + 631: 0.50, # Open fire in grate + 632: 0.50, # Open fire with back boiler (no radiators) + 633: 0.50, # Closed room heater + 634: 0.50, # Closed room heater with boiler (no radiators) + 635: 0.75, # Stove (pellet fired) + 636: 0.75, # Stove (pellet fired) with boiler (no radiators) +} + + # 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 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 4fcc45d1..4f7f879b 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1058,6 +1058,59 @@ def test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_ assert abs(result - 0.75) <= 1e-9 +def test_responsiveness_solid_fuel_sap_code_160_returns_0p50_per_table_4a() -> None: + # Arrange — SAP 10.2 Table 4a (PDF p.169, "Heating systems"): + # + # SAP code Description R + # -------- ----------------------------------------- ---- + # 160 Range cooker boiler (integral oven) 0.50 + # 158 Closed room heater with boiler to rads 0.50 + # 633 Closed room heater 0.50 + # 634 Closed room heater with boiler (no rads) 0.50 + # 636 Stove (pellet fired) with boiler (no rads) 0.75 + # + # Spec line 15271: "R = responsiveness of main heating system + # (Table 4a or Table 4d)". Table 4a's per-heating-system R + # overrides the emitter-based Table 4d lookup for low-responsiveness + # systems (solid-fuel room heaters / range cookers / independent + # boilers) where the appliance's intrinsic response time dominates + # over the emitter's distribution dynamics. + # + # Pre-S0380.135 `_responsiveness` only consulted Table 4d (keyed on + # heat_emitter_type). For solid fuel + radiators (SAP 160) it + # returned R=1.0 (radiators emitter). The mean-internal-temperature + # adjustment in Table 9b then over-estimated heating system + # response, under-estimating space heating demand by ~10% across the + # 10 solid-fuel corpus variants. + + def _solid_fuel_main(sap_code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, main_fuel_type=21, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=4, sap_main_heating_code=sap_code, + ) + + # Act / Assert — Table 4a low-responsiveness rows (R=0.50) + assert abs(_responsiveness(_solid_fuel_main(156)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(158)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(160)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(161)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(631)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(632)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(633)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(634)) - 0.50) <= 1e-9 + # Table 4a higher-responsiveness rows (R=0.75) + assert abs(_responsiveness(_solid_fuel_main(151)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(153)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(155)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(159)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(635)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_solid_fuel_main(636)) - 0.75) <= 1e-9 + # SAP codes NOT in Table 4a dispatch fall through to Table 4d + # emitter lookup (radiators → R=1.0). + assert abs(_responsiveness(_solid_fuel_main(102)) - 1.0) <= 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