fix(water-heating): gate DHW separate-timing on programmer + boiler age (RdSAP 10 §10.5)

`_separately_timed_dhw` returned True for any boiler+cylinder+from-main
cert, applying the SAP 10.2 Table 2b note b) ×0.9 temperature-factor
reduction unconditionally. For the lpg-boiler "before" worksheet (pre-
1998 LPG boiler SAP code 115 + 210 L cylinder, NO cylinder thermostat,
control 2113 "Room thermostat and TRVs" — no programmer) this dropped
the (53) temperature factor to 0.702 (= 0.60 × 1.3 × 0.9) where the
worksheet lodges 0.78 (= 0.60 × 1.3), under-counting cylinder storage
loss (55) by ~119 kWh/yr and over-rating SAP by ~0.25.

RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed":
    No programmer, pre-1998 boiler → No
    Programmer, pre-1998 boiler    → Yes
    Post-1998 boiler               → Yes
DHW is therefore NOT separately timed only when a pre-1998 boiler is
paired with a no-programmer control. Add the two SAP 10.2 Table 4c(2) /
Table 4b lookups (controls without a programmer = {2101, 2103, 2111,
2113}; pre-1998 gas/LPG boilers 110-119 + oil 124/125/128) and return
False for that combination; every other boiler+cylinder cert keeps the
separately-timed default, so the change is confined to old low-control
stock and the heating corpus + goldens are unchanged.

Effect: the full chain (Summary PDF → extractor → mapper → cert_to_inputs
→ calculator) now reproduces the lpg-boiler worksheet's §11a unrounded
SAP -6.6499 at abs < 1e-4 (was -6.4013). Full regression suite green bar
the 3 pre-existing unrelated fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 10:07:27 +00:00
parent 90de1fc976
commit 5a74897fed
2 changed files with 62 additions and 1 deletions

View file

@ -202,6 +202,28 @@ def test_summary_001431_lpg_boiler_maps_main_fuel_to_bottled_lpg() -> None:
assert epc.sap_heating.water_heating_fuel == 3
def test_summary_001431_lpg_boiler_full_chain_sap_matches_worksheet_pdf() -> None:
# Arrange — the lpg-boiler "before" worksheet (P960-0001-001431):
# pre-1998 LPG boiler (SAP code 115, eff 61%) + 210 L cylinder, NO
# cylinder thermostat, control 2113 (room thermostat + TRVs, no
# programmer). RdSAP 10 §10.5 (p.57) "Hot water separately timed":
# a no-programmer + pre-1998 boiler is NOT separately timed, so the
# Table 2b temperature factor (53) is 0.78 (= 0.60 × 1.3), not
# 0.702 (× 0.9). Worksheet §11a lodges unrounded SAP -6.6499.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_LPG_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert
worksheet_unrounded_sap = -6.6499
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_001431_topfloor_flat_classified_as_top_floor() -> None:
# Arrange — the recommendation "after" Summary lodges §6.0 "Position
# of flat in block of flats: Top Floor": floor "A Another dwelling

View file

@ -5150,6 +5150,28 @@ _TABLE_4A_SOLID_FUEL_BOILER_CODES: Final[frozenset[int]] = frozenset(
)
# SAP 10.2 Table 4c(2) boiler controls (21xx) that carry NO programmer /
# time switch: 2101 "No time or thermostatic control", 2103 "Room
# thermostat only", 2111 "TRVs and bypass", 2113 "Room thermostat and
# TRVs". Every other 21xx control includes a programmer (2102/2104/2105/
# 2106 …) or time-and-temperature zone control (2110/2112). Used by the
# RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed" rule below.
_BOILER_CONTROLS_WITHOUT_PROGRAMMER: Final[frozenset[int]] = frozenset(
{2101, 2103, 2111, 2113}
)
# SAP 10.2 Table 4b (PDF p.168) gas/LPG/biogas boilers lodged pre-1998
# (fan-assisted flue 110-114 + balanced/open flue 115-119) plus the
# pre-1998 liquid-fuel boilers (124 pre-1985, 125 1985-1997, 128 combi
# pre-1998). Gas/LPG 101-109 and oil 126/127/129/130 are 1998-or-later.
# Used by the RdSAP 10 §10.5 separate-timing rule: a 1998-or-later boiler
# is always separately timed; a pre-1998 boiler only when a programmer
# is present.
_PRE_1998_BOILER_SAP_CODES: Final[frozenset[int]] = frozenset(
set(range(110, 120)) | {124, 125, 128}
)
def _separately_timed_dhw(
epc: EpcPropertyData, main: Optional[MainHeatingDetail],
) -> bool:
@ -5232,7 +5254,24 @@ def _separately_timed_dhw(
# DHW is not separately timed.
if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES:
return False
return bool(epc.has_hot_water_cylinder)
if not epc.has_hot_water_cylinder:
return False
# RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed":
# No programmer, pre-1998 boiler → No
# Programmer, pre-1998 boiler → Yes
# Post-1998 boiler → Yes
# i.e. DHW is NOT separately timed only when a pre-1998 boiler is
# paired with a no-programmer control (Table 4c(2): room-thermostat-
# only / TRV-only). Every other boiler+cylinder cert keeps the
# separately-timed default — so the change is confined to old, low-
# control stock (this lpg-boiler "before" worksheet: code 115 + 2113
# → (53) temperature factor 0.78, not 0.702).
if (
main.main_heating_control in _BOILER_CONTROLS_WITHOUT_PROGRAMMER
and main.sap_main_heating_code in _PRE_1998_BOILER_SAP_CODES
):
return False
return True
def _table_2b_note_b_multiplier_applies(