From 3542186f18e06c6ecde234ce555647f7fe44a965 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 17:03:46 +0000 Subject: [PATCH] Slice S0380.137: extend Table 4a R-dispatch to electric storage / direct-acting / underfloor / ceiling (cluster) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuation of S0380.135's Table 4a per-heating-system responsiveness dispatch (`_RESPONSIVENESS_BY_SAP_CODE` in cert_to_inputs.py). The solid-fuel coverage closed 10 corpus variants; this slice extends the dispatch to the electric heating SAP code ranges from SAP 10.2 Table 4a (PDF p.170): 401 Old (large volume) storage heaters R=0.00 402 Slimline storage heaters R=0.20 403 Convector storage heaters R=0.20 404 Fan storage heaters R=0.40 405 Slimline storage heaters + Celect-type ctrl R=0.40 407 Fan storage heaters + Celect-type ctrl R=0.60 408 Integrated storage+direct-acting heater R=0.60 409 High heat retention storage heaters (§9.2.8) R=0.80 421 In concrete slab (off-peak only) R=0.00 422 Integrated (storage+direct-acting) R=0.25 423 Integrated with low off-peak R=0.50 424 In screed above insulation R=0.75 425 In timber floor / immediately below covering R=1.00 515 Electricaire system R=0.75 691 Panel, convector or radiant heaters R=1.00 694 Water- or oil-filled radiators R=1.00 701 Electric ceiling heating R=0.75 A few electric storage codes (402, 403, 405, 407) carry a *different* R value in the 24-hour tariff section of Table 4a vs the off-peak section (e.g. Slimline 402 = R=0.20 off-peak / R=0.40 24-hour). This dict captures the off-peak value as the default because the 24-hour tariff is rare in the corpus (no variant lodges it). If a 24-hour- tariff cert surfaces with one of these codes the dispatch needs to be promoted to a (sap_code, tariff) lookup; until then the off-peak default applies. Heating-systems corpus impact — 6 electric corpus variants re-pinned: variant SAP R ΔSAP was ΔPE was electric 3 401 0.00 +9.43 +14.70 -1059 -3189 electric 5 402 0.20 +6.76 +10.97 -96 -1798 electric 6 404 0.40 +7.82 +10.97 -494 -1770 electric 7 408 0.60 +7.58 +9.68 -428 -1277 electric 8 409 0.80 +5.84 +6.89 +200 -224 electric 9 421 0.00 +6.77 +12.03 +154 -1976 3/6 PE residuals close to ±200 kWh (electric 5/8/9). The remaining +5..+9 SAP residuals across all electric variants suggest a separate shared cascade gap (likely Table 12a high/low-rate fraction or pumps/ fans electric handling — fuel cost is consistently under-counted by ~£100-£220 across the cluster). Queued for follow-up. electric 1 (SAP 191 Direct acting electric boiler) and electric 2 (SAP 524 Air source heat pump) unchanged — both have spec R=1.0 already (matched the Table 4d emitter fallback). Extended handover suite: 880 pass / 0 fail (+1 new AAA test covering the 17 electric R-dispatch entries). Pyright net-zero on touched files (43 → 43). No golden fixture impact — no golden cert lodges a covered electric SAP code via the cascade path that would shift residuals. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 24 ++++++--- .../sap10_calculator/rdsap/cert_to_inputs.py | 40 ++++++++++++-- .../rdsap/tests/test_cert_to_inputs.py | 54 +++++++++++++++++++ 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 9e036869..26ff5e00 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -143,16 +143,28 @@ class _CorpusExpectation: # 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. +# +# Slice S0380.136 fixed the dual-fuel cascade bug — solid fuel 6 +# closed -11.37 → +1.95 (cost £268 → -£45) by routing +# `_is_electric_main` through the canonical T32-first normaliser +# instead of a literal {10, 25, 29} ∪ {30..40} mixed-enum check. +# +# Slice S0380.137 extended the Table 4a R-dispatch to electric storage +# / direct-acting / underfloor / ceiling SAP codes (401-409, 421-425, +# 515, 691, 694, 701). Six electric corpus variants re-pinned: PE +# residuals dropped from -1.3..-3.2k to -1.1k..+200 kWh; SAP +# residuals from +6.9..+14.7 to +5.8..+9.4. electric 5/8/9 close to +# ±200 PE. _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), _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=+970.7570), - _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=-3189.2203), - _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=-1797.9601), - _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+10.9720, expected_cost_resid_gbp=-252.8131, expected_co2_resid_kg=-209.8689, expected_pe_resid_kwh=-1769.8410), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+9.6834, expected_cost_resid_gbp=-223.1212, expected_co2_resid_kg=-137.9832, expected_pe_resid_kwh=-1276.9603), - _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=-224.4607), - _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=-1975.8392), + _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+9.4332, expected_cost_resid_gbp=-217.3549, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875), + _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+6.7642, expected_cost_resid_gbp=-155.8576, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333), + _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+7.8189, expected_cost_resid_gbp=-180.1606, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960), + _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+7.5834, expected_cost_resid_gbp=-174.7323, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932), + _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+5.8386, expected_cost_resid_gbp=-134.5304, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233), + _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+6.7699, expected_cost_resid_gbp=-155.9877, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936), _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=-454.5023), _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=-1050.4919), _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=-83.8239), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index e1530bdc..8f89ddd8 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1020,9 +1020,19 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float: # 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. +# Slice S0380.135 added the solid-fuel rows; S0380.137 added electric +# storage / direct-acting / underfloor / electric ceiling rows. More +# entries are added as fixtures surface them. SAP codes not in this +# dict fall through to Table 4d. +# +# A few electric storage codes (402, 403, 405, 407) carry a *different* +# R value in the 24-hour tariff section vs the off-peak section (e.g. +# Slimline 402 = R=0.2 off-peak / R=0.4 24-hour). This dict captures +# the off-peak value as the default because the 24-hour tariff is rare +# in the corpus (no variant lodges it). If a 24-hour-tariff cert +# surfaces with one of these codes the dispatch needs to be promoted +# to a (sap_code, tariff) lookup; until then the off-peak default +# applies (under-shoots R for the 24-hour case). _RESPONSIVENESS_BY_SAP_CODE: Final[dict[int, float]] = { # Solid-fuel independent boilers (Table 4a p.169): 151: 0.75, # Manual feed independent boiler @@ -1043,6 +1053,30 @@ _RESPONSIVENESS_BY_SAP_CODE: Final[dict[int, float]] = { 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) + # Electric storage heaters off-peak tariff (Table 4a p.170): + 401: 0.00, # Old (large volume) storage heaters + 402: 0.20, # Slimline storage heaters (24-hr tariff: 0.40) + 403: 0.20, # Convector storage heaters (24-hr tariff: 0.40) + 404: 0.40, # Fan storage heaters + 405: 0.40, # Slimline storage heaters with Celect-type control + # (24-hr tariff: 0.60) + 407: 0.60, # Fan storage heaters with Celect-type control + # (24-hr tariff: 0.60 — same) + 408: 0.60, # Integrated storage+direct-acting heater + 409: 0.80, # High heat retention storage heaters (§9.2.8) + # Electric underfloor heating off-peak / standard tariffs: + 421: 0.00, # In concrete slab (off-peak only) + 422: 0.25, # Integrated (storage+direct-acting) + 423: 0.50, # Integrated (storage+direct-acting) low off-peak + 424: 0.75, # In screed above insulation + 425: 1.00, # In timber floor / immediately below floor covering + # Electric warm air: + 515: 0.75, # Electricaire system + # Electric direct-acting room heaters (Table 4a p.170): + 691: 1.00, # Panel, convector or radiant heaters + 694: 1.00, # Water- or oil-filled radiators + # Electric ceiling heating (Table 4a Group 7 dispatch): + 701: 0.75, } 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 6cf66365..ea4a87a8 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1185,6 +1185,60 @@ def test_responsiveness_solid_fuel_sap_code_160_returns_0p50_per_table_4a() -> N assert abs(_responsiveness(_solid_fuel_main(102)) - 1.0) <= 1e-9 +def test_responsiveness_electric_storage_sap_codes_use_table_4a_off_peak_rows() -> None: + # Arrange — SAP 10.2 Table 4a (PDF p.170, electric storage / direct- + # acting / underfloor / ceiling rows): + # + # SAP code Description R (off-peak) + # -------- ----------------------------------------- ------------ + # 401 Old (large volume) storage heaters 0.00 + # 402 Slimline storage heaters 0.20 + # 404 Fan storage heaters 0.40 + # 408 Integrated storage+direct-acting 0.60 + # 409 High heat retention storage (§9.2.8) 0.80 + # 421 In concrete slab (off-peak only) 0.00 + # 422 Integrated (storage+direct-acting) 0.25 + # 423 Integrated low off-peak 0.50 + # 424 In screed above insulation 0.75 + # 515 Electricaire system 0.75 + # 691 Panel/convector/radiant heaters 1.00 + # 701 Electric ceiling heating 0.75 + # + # S0380.137 closes the same pattern as S0380.135 (solid-fuel) for + # electric heating SAP codes — pre-slice the cascade ignored the + # Table 4a per-system R and used Table 4d emitter R=1.0 (radiators) + # everywhere, over-estimating heating system response and under- + # estimating demand by ~5-15% across the electric corpus cluster. + # See `_RESPONSIVENESS_BY_SAP_CODE` for the off-peak vs 24-hour + # tariff caveat on codes 402/403/405. + + def _electric_main(sap_code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, main_fuel_type=30, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=2, sap_main_heating_code=sap_code, + ) + + # Act / Assert — full electric storage / direct-acting / UFH coverage + assert abs(_responsiveness(_electric_main(401)) - 0.00) <= 1e-9 + assert abs(_responsiveness(_electric_main(402)) - 0.20) <= 1e-9 + assert abs(_responsiveness(_electric_main(403)) - 0.20) <= 1e-9 + assert abs(_responsiveness(_electric_main(404)) - 0.40) <= 1e-9 + assert abs(_responsiveness(_electric_main(405)) - 0.40) <= 1e-9 + assert abs(_responsiveness(_electric_main(407)) - 0.60) <= 1e-9 + assert abs(_responsiveness(_electric_main(408)) - 0.60) <= 1e-9 + assert abs(_responsiveness(_electric_main(409)) - 0.80) <= 1e-9 + assert abs(_responsiveness(_electric_main(421)) - 0.00) <= 1e-9 + assert abs(_responsiveness(_electric_main(422)) - 0.25) <= 1e-9 + assert abs(_responsiveness(_electric_main(423)) - 0.50) <= 1e-9 + assert abs(_responsiveness(_electric_main(424)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_electric_main(425)) - 1.00) <= 1e-9 + assert abs(_responsiveness(_electric_main(515)) - 0.75) <= 1e-9 + assert abs(_responsiveness(_electric_main(691)) - 1.00) <= 1e-9 + assert abs(_responsiveness(_electric_main(694)) - 1.00) <= 1e-9 + assert abs(_responsiveness(_electric_main(701)) - 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