Slice S0380.137: extend Table 4a R-dispatch to electric storage / direct-acting / underfloor / ceiling (cluster)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 17:03:46 +00:00 committed by Jun-te Kim
parent 9427354d88
commit 2907b40ed9
3 changed files with 109 additions and 9 deletions

View file

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

View file

@ -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,
}

View file

@ -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