mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
e59b10e971
commit
0001c7d11f
3 changed files with 131 additions and 2 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue