From 0202b045de337085713cb67200fd32b266df8631 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 10 Jun 2026 22:01:35 +0000 Subject: [PATCH] fix(water-heating): 18-/24-hour immersion DHW bills 100% low-rate (Table 12a scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 12a (PDF p.191) is titled "High-rate fractions for systems using 7-hour and 10-hour tariffs"; its "Immersion water heater" row lists the tariff as "7-hour or 10-hour" only, routing to Table 13. An 18-hour or 24-hour tariff is OUTSIDE the table's scope — it provides at least 18 hours/day at the low rate, more than enough to heat any immersion cylinder off-peak, so the high-rate fraction is 0 (all DHW billed at the low rate). `electric_dhw_high_rate_fraction` previously mapped 18-/24-hour to the 10-hour equations (returning ~0.10 for a 110 L dual immersion) on an over-literal reading of Table 13 Note 1 ("at least 10 hours"). The Elmhurst dr87 worksheet for solid fuel 5 (cert 001431: 18-hour meter, 110 L dual immersion, WHC 903) refutes that: HW (245) high-rate = 0.0 kWh, (246) low-rate = 100%. Table 12a's title bounds the table to the two named tariffs; 18-/24-hour fall outside it. Resolves the Table-13 blocker on the immersion-extractor fix: once the Summary extractor captures the dual immersion, the 18-hour solid-fuel corpus certs stay at high_frac=0 (matching their worksheets) instead of regressing to the 10-hour-column 0.10. API SAP eval unchanged: 57.6% within 0.5, mean|err| 1.185, signed -0.165 (the cached sample has no 18-hour WHC-903 certs; one 24-hour cert shifts sub-threshold). Regression gate green (3 pre-existing fails unrelated). Co-Authored-By: Claude Opus 4.8 --- domain/sap10_calculator/tables/table_13.py | 40 ++++++++++++++----- .../domain/sap10_calculator/test_table_13.py | 23 +++++++---- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/domain/sap10_calculator/tables/table_13.py b/domain/sap10_calculator/tables/table_13.py index f96bd71d..c1dabe25 100644 --- a/domain/sap10_calculator/tables/table_13.py +++ b/domain/sap10_calculator/tables/table_13.py @@ -22,12 +22,24 @@ is needed: Single: [(14530 - 762 N) / (1.5 V) - 80 + 10 N] / 100 where V is the cylinder volume (litres) and N is the assumed occupancy -(Appendix J Table 1b). Per Note 2 the result is clamped to [0, 1]. Per -Note 1 the 10-hour equations apply to any tariff providing at least 10 -hours/day at the low rate (so 18-hour and 24-hour use the 10-hour -column). Heat pumps providing water heating only are treated as dual -immersion (Note 1) — out of scope of this helper (callers route those -via Table 12a). +(Appendix J Table 1b). Per Note 2 the result is clamped to [0, 1]. + +Table 12a (PDF p.191) — whose title reads "High-rate fractions for +systems using 7-hour and 10-hour tariffs" — routes the "Immersion water +heater" row to Table 13 for the tariff "7-hour or 10-hour" ONLY. An +18-hour or 24-hour tariff is outside Table 12a/13's scope: it provides +at least 18 hours/day at the low rate, more than enough to heat any +immersion cylinder off-peak, so the high-rate fraction is 0 (all DHW +billed at the low rate). The Elmhurst dr87 worksheet for solid fuel 5 +(cert 001431: 18-hour meter, 110 L dual immersion, WHC 903) confirms +this — HW (245) high-rate = 0.0 kWh, (246) low-rate = 100%. (An earlier +reading mapped 18-/24-hour to the 10-hour column via Note 1's "at least +10 hours"; the worksheet refutes it — Table 12a's title bounds the table +to the two named tariffs.) + +Heat pumps providing water heating only are treated as dual immersion +(Note 1) — out of scope of this helper (callers route those via Table +12a). """ from __future__ import annotations @@ -47,13 +59,19 @@ def electric_dhw_high_rate_fraction( `single_immersion` selects the single- vs dual-immersion equation (RdSAP10 §10.5 p.54: an immersion is assumed dual on a dual meter). - The 7-hour tariff uses the 7-hour equations; every other off-peak - tariff (10/18/24-hour, all >= 10 hours low-rate per Note 1) uses the - 10-hour equations. STANDARD has no off-peak split and is rejected — - callers must early-return before this fires. + The 7-hour tariff uses the 7-hour equations; the 10-hour tariff uses + the 10-hour equations. The 18-hour and 24-hour tariffs are outside + Table 12a/13's "7-hour and 10-hour" scope (PDF p.191 title) — they + provide >= 18 hours/day at the low rate, so the high-rate fraction is + 0. STANDARD has no off-peak split and is rejected — callers must + early-return before this fires. """ if tariff is Tariff.STANDARD: raise ValueError("Table 13 high-rate fraction is undefined for STANDARD") + if tariff in (Tariff.EIGHTEEN_HOUR, Tariff.TWENTY_FOUR_HOUR): + # Outside Table 12a's 7-hour/10-hour scope — >= 18 h/day low rate + # heats the cylinder entirely off-peak (high-rate fraction 0). + return 0.0 v = cylinder_volume_l n = occupancy_n if tariff is Tariff.SEVEN_HOUR: @@ -62,7 +80,7 @@ def electric_dhw_high_rate_fraction( else: fraction = ((6.8 - 0.024 * v) * n + 14 - 0.07 * v) / 100 else: - # >= 10 hours/day at the low rate (10/18/24-hour) — Note 1. + # 10-hour tariff (18-/24-hour handled above as out-of-scope). if single_immersion: fraction = ((14530 - 762 * n) / (1.5 * v) - 80 + 10 * n) / 100 else: diff --git a/tests/domain/sap10_calculator/test_table_13.py b/tests/domain/sap10_calculator/test_table_13.py index d6857cc2..1063fe15 100644 --- a/tests/domain/sap10_calculator/test_table_13.py +++ b/tests/domain/sap10_calculator/test_table_13.py @@ -83,21 +83,28 @@ def test_table_13_large_cylinder_single_immersion_clamps_to_zero() -> None: assert fraction == 0.0 -def test_table_13_eighteen_hour_uses_ten_hour_column() -> None: - # Arrange — SAP 10.2 Table 13 Note 1 (PDF p.197): the table applies - # "for tariffs providing at least 10 hours ... at the low rate", so an - # 18-hour tariff resolves to the 10-hour equations, not a separate - # column. +def test_table_13_eighteen_and_twenty_four_hour_bill_full_low_rate() -> None: + # Arrange — SAP 10.2 Table 12a (PDF p.191) is titled "High-rate + # fractions for systems using 7-hour and 10-hour tariffs"; its + # "Immersion water heater" row lists the tariff as "7-hour or 10-hour" + # only, routing to Table 13. An 18-hour / 24-hour tariff is OUTSIDE the + # table's scope: it provides at least 18 hours/day at the low rate, more + # than enough to heat any immersion cylinder off-peak, so the high-rate + # fraction is 0 (100% billed at the low rate). The Elmhurst dr87 + # worksheet for solid fuel 5 (cert 001431: 18-hour meter, 110 L dual + # immersion, WHC 903) bills HW (245) high-rate = 0.0 kWh, (246) low-rate + # = 100% — confirming high_frac = 0 for an 18-hour immersion DHW. # Act eighteen = electric_dhw_high_rate_fraction( cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, single_immersion=False, tariff=Tariff.EIGHTEEN_HOUR, ) - ten = electric_dhw_high_rate_fraction( + twenty_four = electric_dhw_high_rate_fraction( cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, - single_immersion=False, tariff=Tariff.TEN_HOUR, + single_immersion=False, tariff=Tariff.TWENTY_FOUR_HOUR, ) # Assert - assert abs(eighteen - ten) <= 1e-9 + assert eighteen == 0.0 + assert twenty_four == 0.0