Slice S0380.135: dispatch responsiveness via Table 4a SAP code (solid-fuel cluster)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 13:11:36 +00:00
parent 7530ed3f4a
commit 829a3318dc
3 changed files with 128 additions and 11 deletions

View file

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

View file

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

View file

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