Slice S0380.153: SAP 10.2 Table 3 — not-separately-timed DHW for solid-fuel boilers

SAP 10.2 Table 3 (PDF p.160) provides three primary-loss rows keyed off
the DHW timing arrangement, the middle row giving winter h=5 / summer
h=3 for "Cylinder thermostat, water heating NOT separately timed".

Solid-fuel boiler systems (Table 4a codes 151-161 — independent boilers,
open-fire + back boilers, closed room heaters with boilers, range cooker
boilers, stoves with boilers) do not ship with dual programmers. Per
SAP 10.2 §9.2.4 (PDF p.27) these are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler" — the
appliance itself is the timer. DHW timing follows the burn schedule,
not a separate cylinder programmer, so the middle Table 3 row applies.

Pre-slice `_separately_timed_dhw` returned True for any cylinder +
non-electric HW fuel cert (the S0380.140 gate), routing solid-fuel
boilers through h=3 year-round (the third row, "Cylinder thermostat,
water heating separately timed"). That under-counted winter (59)m
by ~21 kWh/month × 8 winter months across the affected cohort, with
the under-counted water-heating gain propagating into MIT / SH / SAP.

New gate: `sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES`
(frozenset of {151, 153, 155, 156, 158, 159, 160, 161}) — added before
the existing cylinder-present fallback. The post-S0380.140 electric-
immersion / heat-pump / no-main branches are unchanged. Table 4b
liquid-fuel boilers (101-141) keep the True default — modern gas/oil
installations standardly include dual programmers and the worksheet
confirms `oil 1` / `oil pcdb 1..3` / `pcdb 1` are pinned exact at
h=3 year-round.

Worksheet evidence (heating-systems corpus property 001431):
  - solid fuel 3 (SAP code 160 range cooker boiler + WHC=901
    cylinder thermostat): worksheet (59)m winter = 64.58 (h=5, p=0)
    and summer = 41.92 / 43.31 (h=3, p=0). Cascade closes ΔSAP +0.30
    → −0.0000, Δcost −£6.84 → −0.00, ΔPE −214 → −0.00 (4-metric exact).
  - solid fuel 2 (SAP code 158 closed room heater + back boiler):
    same Table 3 fix narrows ΔSAP +2.06 → +1.86. Remaining ~1.86 SAP
    is the SAP 10.2 §12.4.4 immersion-in-summer rule for back-boilers
    (codes 156, 158) — the worksheet has summer (59)m = 0 because the
    Elmhurst P960 lodges `Summer Immersion: Yes` + the spec routes
    Jun-Sep HW through an electric immersion at η=100%. That's a
    bigger lift (monthly HW efficiency + fuel-split plumbing) and is
    a follow-up slice.

Other corpus variants: no impact (verified via cohort sweep). The
gate is narrow by SAP code so only the 2 affected variants move.

Extended handover suite: 897 pass / 0 fail (+1 from new AAA test).
Pyright net-zero (43 → 43, transient +1 fixed via `EpcPropertyData`
import on the new test's `_cylinder_epc_for` return annotation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 13:27:12 +00:00
parent 3a44ca89fb
commit e4bf4e70e8
3 changed files with 131 additions and 2 deletions

View file

@ -241,8 +241,8 @@ _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=+2.0649, expected_cost_resid_gbp=-47.5795, expected_co2_resid_kg=+295.4889, expected_pe_resid_kwh=-754.0879),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+0.2968, expected_cost_resid_gbp=-6.8392, expected_co2_resid_kg=-74.2162, expected_pe_resid_kwh=-214.2510),
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+1.8594, expected_cost_resid_gbp=-42.8447, expected_co2_resid_kg=+346.8694, expected_pe_resid_kwh=-605.7603),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762),
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604),

View file

@ -3762,6 +3762,17 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
# SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent
# boilers (151, 153, 155, 159), open-fire + back boiler (156), closed
# room heater + back boiler (158), range cooker boiler (160, 161).
# Per the structure described in §9.2.4 these systems do not ship with
# dual programmers; DHW timing follows the appliance burn schedule, NOT
# a separate cylinder programmer.
_TABLE_4A_SOLID_FUEL_BOILER_CODES: Final[frozenset[int]] = frozenset(
{151, 153, 155, 156, 158, 159, 160, 161}
)
def _separately_timed_dhw(
epc: EpcPropertyData, main: Optional[MainHeatingDetail],
) -> bool:
@ -3773,11 +3784,27 @@ def _separately_timed_dhw(
must NOT apply when the water-heating fuel is electric (whether
on a standard meter or off-peak immersion timer).
Same flag drives SAP 10.2 Table 3 (PDF p.160) primary-loss row
selection: "Cylinder thermostat, water heating separately timed"
gives winter h=3 / summer h=3; "not separately timed" gives winter
h=5 / summer h=3.
RdSAP §3 default: when a hot-water cylinder is lodged AND the
cylinder is fed by a boiler / warm-air / HP, DHW timing is separate
from space heat the cylinder is heated on its own programmer /
overnight boost regardless of which heat generator feeds it.
Solid-fuel boilers (Table 4a codes 151-161) are the exception. Per
SAP 10.2 §9.2.4 these systems are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler"
the appliance itself is the timer. DHW timing follows the burn
schedule, NOT a separate cylinder programmer, so the middle Table
3 row applies (winter h=5 / summer h=3). Worksheet evidence from
the heating-systems corpus property 001431: solid fuel 3 (code
160 + WHC=901 + cylinder thermostat) lodges (59)m winter = 64.58
(h=5, p=0) and (59)m summer = 41.92 / 43.31 (h=3, p=0). Pre-slice
the cascade returned True here, routing through h=3 year-round.
Combi-only dwellings (no cylinder) skip the multiplier DHW is
instantaneous and shares the boiler's space-heating cycle, so
there's no separate timer. Heat pumps (cat 4) keep their existing
@ -3797,6 +3824,8 @@ def _separately_timed_dhw(
return True
if _is_electric_water(epc.sap_heating.water_heating_fuel):
return False
if main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES:
return False
return bool(epc.has_hot_water_cylinder)

View file

@ -19,6 +19,7 @@ from typing import Final
import pytest
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
PhotovoltaicArray,
SapFloorDimension,
@ -1627,6 +1628,105 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b()
assert sep_immersion is False
def test_separately_timed_dhw_solid_fuel_boiler_codes_per_sap_10_2_table_3() -> None:
# Arrange — SAP 10.2 Table 3 (PDF p.160) gives three primary-loss
# rows keyed off the DHW timing arrangement:
#
# Hot water controls Winter Summer
# No cylinder thermostat 11 3
# Cylinder thermostat, water heating NOT separately timed 5 3
# Cylinder thermostat, water heating separately timed 3 3
#
# Solid-fuel boiler systems (Table 4a codes 151-161 — independent
# boilers, open-fire + back boilers, closed room heaters with
# boilers, range cooker boilers, stoves with boilers) do not ship
# with dual programmers — the appliance itself is the timer (the
# fire/cooker burns or it doesn't). DHW timing is therefore tied to
# the main heating burn schedule, NOT separately timed. The
# worksheet bears this out for the heating-systems corpus: solid
# fuel 3 (code 160 + WHC=901 + cylinder thermostat) lodges
# winter (59)m = 64.58 (h=5, p=0) and summer (59)m = 41.92 / 43.31
# (h=3, p=0) — exactly the middle row above.
#
# The pre-slice cascade returned True from `_separately_timed_dhw`
# for any cylinder + non-electric HW fuel (the post-S0380.140
# gate), which routed solid-fuel-boiler certs through the h=3
# year-round bottom row. That under-counted winter (59) by ~22
# kWh/month × 8 winter months ≈ 170 kWh/yr per affected cert, and
# the under-counted water-heating gain propagated through to MIT /
# SH / SAP. Cohort impact (heating-systems corpus, property 001431):
# solid fuel 3 closes to ΔSAP ±1e-4 (was +0.30); solid fuel 2
# narrows from +2.06 to +1.86 (the remaining residual is the
# §12.4.4 immersion-in-summer rule for back-boilers, a follow-up).
#
# Discriminator: SAP code in Table 4a solid-fuel-boiler range
# 151-161. Liquid-fuel / gas Table 4b boilers (codes 101-141) are
# NOT covered — modern gas/oil installations standardly include a
# cylinder thermostat + separate DHW programmer; the
# `_separately_timed_dhw=True` default is correct for them.
def _solid_fuel_boiler_main(sap_code: int) -> MainHeatingDetail:
return MainHeatingDetail(
has_fghrs=False,
main_fuel_type=15, # Table 32 code 15 = anthracite
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2103,
sap_main_heating_code=sap_code,
)
def _cylinder_epc_for(main: MainHeatingDetail) -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
sap_heating=make_sap_heating(
main_heating_details=[main],
water_heating_fuel=15,
water_heating_code=901, # HW from main heating
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=38,
),
)
# Act / Assert — every Table 4a solid-fuel boiler code 151..161
# routes through the not-separately-timed branch (False).
for code in (151, 153, 155, 156, 158, 159, 160, 161):
main = _solid_fuel_boiler_main(code)
epc = _cylinder_epc_for(main)
assert _separately_timed_dhw(epc, main) is False, (
f"SAP code {code}: solid-fuel boiler should NOT be separately "
f"timed (Table 3 middle row, winter h=5 / summer h=3)"
)
# Liquid-fuel Table 4b boiler (code 102 = gas combi) stays on the
# `separately_timed_dhw=True` default — modern gas installations
# ship with dual programmers and the post-S0380.140 logic is
# correct here.
gas_main = MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2106,
main_heating_category=2, sap_main_heating_code=102,
)
gas_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
sap_heating=make_sap_heating(
main_heating_details=[gas_main],
water_heating_fuel=26,
water_heating_code=901,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=38,
),
)
assert _separately_timed_dhw(gas_epc, gas_main) is True
def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None:
# Arrange — an electric storage heater (SAP code 401) on an 18-hour
# tariff. `_table_12a_system_for_main` returns None for storage