Slice S0380.21: Table 3a row 1 (no keep-hot) + row 4 dispatch — closes 9 cohort-2 RAISES

SAP 10.2 spec p.160 Table 3a rows:
  Row 1 ("Instantaneous, without keep-hot facility"):
      (61)m = 600 × fu × n_m / 365   with fu = min(1, V_d,m / 100)
  Row 4 ("Instantaneous, with keep-hot, not controlled by time clock"):
      (61)m = 900 × n_m / 365

Add `combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot()` and
`combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock()` to
`worksheet/water_heating.py`. Extend `pcdb_combi_loss_override` to
dispatch via the PCDB keep_hot_facility / keep_hot_timer fields lodged
at raw positions 58/59 (extracted in Slice S0380.20):

  kh ∈ {0, None}            → row 1   (600 × fu × n/365, no keep-hot)
  kh = 1, timer = 1         → row 3   (cascade default 600 × n/365)
  kh = 1, timer ∈ {0, None} → row 4   (900 × n/365, no time clock)
  kh ∈ {2, 3}               → UnresolvedPcdbCombiLoss (electric or
                              mixed keep-hot — Table 3a Note 2
                              fuel-split between (61)m and (219)m
                              deferred until a fixture exercises it).

Closes 9 of the 11 cohort-2 RAISES from Slice S0380.20 — all PCDF 15709
+ 10315 certs with no keep-hot lodgement now compute to abs(delta) <
1e-4 vs the dr87 worksheet. Verified end-to-end on cert 7800-1501-0922-
7127-3563 (Potterton Promax Combi 28 HE+A, PCDF 15709): Jan (61) =
600 × 0.778795 × 31/365 = 39.6866 kWh, matching worksheet line ref
exactly. The 2 newly-visible cohort-2 issues (cert 6835 -13.37 SAP, cert
0652 +1.93 SAP) were hidden behind the previous strict-raise — they
surface unrelated cascade gaps, not regressions.

Re-add 0390-2954-3640-2196-4175 (Firebird oil PCDF 9005) to the golden
fixture cohort dropped in Slice S0380.20:
  - `_EXPECTATIONS` with re-pinned SAP/PE/CO2 residuals (-7 / -26.0093
    kWh/m² / -2.5211 t/yr) — the cert now cascades end-to-end via the
    no-keep-hot row.
  - `_PCDB_CHAIN_EXPECTATIONS` pins PCDF index 9005 + winter eff 0.864
    (Table 105 fraction).

Spec citations (per [[feedback-spec-citation-in-commits]]):
  - SAP 10.2 spec p.160 Table 3a rows 1 & 4 (formula columns) +
    pdftotext of `sap-10-2-full-specification-2025-03-14.pdf | sed -n
    '15280,15410p'` (Notes 1 & 2 on fu / electric keep-hot routing).
  - STP09-B04 §5.3 "Influence of Keep-hot facility" — origin of the
    600 / 900 kWh/yr keep-hot baselines.

Pyright per-file: net-zero on all touched files
(water_heating.py 1→1, cert_to_inputs.py 35→35, tests unchanged).

Test counts: 697 → 702 pass (+5 new tests), 10 expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 08:56:01 +00:00 committed by Jun-te Kim
parent 1abc339848
commit f7d863a9fa
5 changed files with 239 additions and 69 deletions

View file

@ -158,6 +158,8 @@ from domain.sap10_calculator.worksheet.water_heating import (
PIPEWORK_INSULATED_UNINSULATED, PIPEWORK_INSULATED_UNINSULATED,
TABLE_J1_TCOLD_FROM_MAINS_C, TABLE_J1_TCOLD_FROM_MAINS_C,
WaterHeatingResult, WaterHeatingResult,
combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot,
combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous, combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous, combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
cylinder_storage_loss_factor_table_2, cylinder_storage_loss_factor_table_2,
@ -1789,39 +1791,30 @@ def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
class UnresolvedPcdbCombiLoss(ValueError): class UnresolvedPcdbCombiLoss(ValueError):
"""Raised when a cert lodges a PCDB Table 105 combi without enough """Raised when a cert lodges a PCDB Table 105 combi whose keep-hot
metadata for the cascade to dispatch the correct SAP 10.2 Appendix configuration falls outside the SAP 10.2 Table 3a rows the cascade
J Table 3a sub-row. has implemented.
Trigger: `separate_dhw_tests` is 0 / None (no EN 13203-2 lab data Current trigger: `keep_hot_facility {2, 3}` (keep-hot heated by
so Tables 3b/3c don't apply) AND `keep_hot_facility` is 0 / None electricity, or a mix of electricity + fuel Table 3a Note 2 routes
(the PCDB record lodges no keep-hot the cascade's only the electric portion of the loss to worksheet (219)m rather than
implemented Table 3a row is "with keep-hot, time clock" 600 kWh/yr, leaving it in (61)m). The cascade does not yet split the loss across
spec-wrong for no-keep-hot combis). fuels, so surface the gap rather than silently mis-route.
Cohort-2 cert 7800-1501-0922-7127-3563 (PCDF 15709 Potterton Rows the cascade now handles (Slice S0380.21):
Promax Combi 28 HE+A): worksheet (61) sums to ~428 kWh/yr via the - `keep_hot_facility {0, None}` Table 3a row 1 (no keep-hot)
no-keep-hot sub-row formula vs the cascade's 600 → +172 kWh/yr `600 × fu × n_m / 365` with `fu = min(1, V_d/100)`.
excess HW demand -0.24 SAP. 10 other cohort-2 certs (PCDF 15709 - `keep_hot_facility=1, keep_hot_timer=1` Table 3a row 3
/ PCDF 10315) hit the same gap. (keep-hot, time-clock) `600 × n_m / 365` (cascade default).
- `keep_hot_facility=1, keep_hot_timer {0, None}` Table 3a
Cohort-1 cert 000490 (PCDF 10328 Vaillant Ecotec Pro 28): same row 4 (keep-hot, no time clock) `900 × n_m / 365`.
sdt=0 but lodges `keep_hot_facility=1` (fuel keep-hot) cascade
default 600 IS the spec-correct row no raise.
Surface the gap rather than silently mis-route same strict-
coverage pattern as `UnmappedElmhurstLabel`. Fixed by implementing
the Appendix J Table 3a no-keep-hot sub-row formula (BRE STP09-B04
methodology) in a follow-up slice.
""" """
def __init__(self, *, pcdf_index: Optional[int], boiler: str) -> None: def __init__(
self, *, pcdf_index: Optional[int], boiler: str, reason: str
) -> None:
super().__init__( super().__init__(
f"PCDB combi {boiler!r} (PCDF {pcdf_index}) lodges " f"PCDB combi {boiler!r} (PCDF {pcdf_index}): {reason}"
f"separate_dhw_tests=0 + keep_hot_facility=None — the cascade "
f"can't dispatch the SAP 10.2 Table 3a sub-row. Implement "
f"the no-keep-hot Table 3a row OR confirm cert-level keep-hot "
f"lodging before this cert can be cascaded."
) )
self.pcdf_index = pcdf_index self.pcdf_index = pcdf_index
self.boiler = boiler self.boiler = boiler
@ -1842,10 +1835,13 @@ def pcdb_combi_loss_override(
= 2 schedules 2 and 3 (profiles M + L) Table 3c, DVF = M+L = 2 schedules 2 and 3 (profiles M + L) Table 3c, DVF = M+L
= 3 schedules 2 and 1 (profiles M + S) Table 3c, DVF = M+S = 3 schedules 2 and 1 (profiles M + S) Table 3c, DVF = M+S
= 0 / None falls through to Table 3a, dispatched by the PCDB = 0 / None falls through to Table 3a, dispatched by the PCDB
keep-hot fields (`keep_hot_facility`, `keep_hot_timer`) raises keep-hot fields (`keep_hot_facility`, `keep_hot_timer`):
`UnresolvedPcdbCombiLoss` when no keep-hot is lodged because the kh {0, None} row 1 (no keep-hot) 600 × fu × n/365
cascade's only implemented Table 3a row is the keep-hot one kh = 1, timer = 1 row 3 (time-clock) 600 × n / 365
(Slice S0380.20 strict-raise context). kh = 1, timer {0, None} row 4 (no time-clock) 900 × n / 365
kh {2, 3} electric keep-hot, raises
`UnresolvedPcdbCombiLoss` (Table 3a
Note 2 fuel-split deferred).
Storage-FGHRS and storage-combi variants (`subsidiary_type` {1, 2, Storage-FGHRS and storage-combi variants (`subsidiary_type` {1, 2,
3} integral FGHRS / HP+boiler combinations; `store_type` {1, 2, 3} integral FGHRS / HP+boiler combinations; `store_type` {1, 2,
@ -1862,18 +1858,37 @@ def pcdb_combi_loss_override(
return None return None
sdt = pcdb_record.separate_dhw_tests sdt = pcdb_record.separate_dhw_tests
if sdt in (0, None): if sdt in (0, None):
# No EN 13203-2 lab data → fall through to Table 3a. Cascade's # No EN 13203-2 lab data → dispatch via Table 3a keep-hot fields.
# only implemented Table 3a row is "with keep-hot, time clock" kh = pcdb_record.keep_hot_facility
# (600 kWh/yr); use it only when the PCDB lodges keep-hot. timer = pcdb_record.keep_hot_timer
if pcdb_record.keep_hot_facility in (0, None): if kh in (0, None):
raise UnresolvedPcdbCombiLoss( # SAP 10.2 Table 3a row 1: 600 × fu × n_m / 365 (spec p.160).
pcdf_index=pcdb_record.pcdb_id, return combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot(
boiler=( daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
f"{pcdb_record.brand_name} {pcdb_record.model_name} "
f"{pcdb_record.model_qualifier}".strip()
),
) )
return None # keep-hot lodged → cascade caller uses Table 3a default if kh == 1:
if timer == 1:
# SAP 10.2 Table 3a row 3: 600 × n_m / 365. Cascade's
# `water_heating_from_cert` default — return None so the
# default fires.
return None
# SAP 10.2 Table 3a row 4: 900 × n_m / 365 (no time-clock).
return combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock()
# kh ∈ {2, 3} — electric or mixed keep-hot. Table 3a Note 2 routes
# the electric portion of the loss to (219)m rather than (61)m;
# the cascade doesn't yet split across fuels.
raise UnresolvedPcdbCombiLoss(
pcdf_index=pcdb_record.pcdb_id,
boiler=(
f"{pcdb_record.brand_name} {pcdb_record.model_name} "
f"{pcdb_record.model_qualifier}".strip()
),
reason=(
f"keep_hot_facility={kh} indicates electric or mixed "
f"keep-hot — Table 3a Note 2 fuel-split not yet "
f"implemented (cascade can't route part of (61) to (219))."
),
)
r1 = pcdb_record.rejected_energy_proportion_r1 r1 = pcdb_record.rejected_energy_proportion_r1
if r1 is None: if r1 is None:
return None return None

View file

@ -946,16 +946,16 @@ def test_pcdb_combi_loss_override_preserves_separate_dhw_tests_1_routing_to_tabl
def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage_combis() -> None: def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage_combis() -> None:
"""The override gate returns None — letting the worksheet fall back """The override gate returns None — letting the worksheet fall back
to Table 3a whenever the PCDB record lodges keep-hot facility but to Table 3a row 3 (600 × n/365) whenever the PCDB record lodges
has insufficient EN 13203 lab data or sits in a storage / FGHRS row keep-hot with a time clock but has insufficient EN 13203 lab data,
(Table 3b/3c rows 2-5, deferred until a fixture exercises them). or sits in a storage / FGHRS row (Table 3b/3c rows 2-5, deferred
until a fixture exercises them).
Per Slice S0380.20: when the PCDB record lodges sdt=0 AND Per Slice S0380.21: keep_hot_facility {None, 0} dispatches to
keep_hot_facility {None, 0}, raises `UnresolvedPcdbCombiLoss` Table 3a row 1 (`600 × fu × n/365`), keep_hot_facility=1 + no
instead of returning None the cascade's only implemented Table timer dispatches to row 4 (`900 × n/365`). Only the electric
3a row is "with keep-hot" (600 kWh/yr), which is the wrong spec keep-hot variants (keep_hot_facility {2, 3}) now raise
row for no-keep-hot combis (cohort-2 cert 7800 had ~+172 kWh/yr `UnresolvedPcdbCombiLoss` Table 3a Note 2 fuel-split deferred."""
over-prediction)."""
# Arrange — a minimal record skeleton, mutated per scenario via # Arrange — a minimal record skeleton, mutated per scenario via
# dataclasses.replace. # dataclasses.replace.
from dataclasses import replace from dataclasses import replace
@ -995,8 +995,9 @@ def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage
) )
is None is None
) )
# separate_dhw_tests=0 + keep_hot_facility=1 → None (no PCDB DHW # separate_dhw_tests=0 + keep_hot_facility=1 + timer=1 → None (no
# test data, but cascade's keep-hot row IS the right spec row). # PCDB DHW test data, but cascade's row 3 default IS the right spec
# row → return None and let the cascade default fire).
assert ( assert (
pcdb_combi_loss_override( pcdb_combi_loss_override(
replace(base, separate_dhw_tests=0), replace(base, separate_dhw_tests=0),
@ -1005,12 +1006,31 @@ def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage
) )
is None is None
) )
# separate_dhw_tests=0 + keep_hot_facility=None → RAISES (cascade's # separate_dhw_tests=0 + keep_hot_facility=None → Table 3a row 1
# keep-hot row is wrong for no-keep-hot combis; Table 3a no-keep-hot # (600 × fu × n/365) — Slice S0380.21 dispatch.
# row not yet implemented per Slice S0380.20). row_1 = pcdb_combi_loss_override(
replace(base, separate_dhw_tests=0, keep_hot_facility=None),
energy_content_monthly_kwh=energy_content,
daily_hot_water_monthly_l_per_day=daily_hw,
)
assert row_1 is not None and len(row_1) == 12
# 000477 worksheet V_d ranges 94.7..114.2; the row-1 formula caps fu
# at 1.0 so the per-month loss can never exceed 600 × n/365.
for m, n in enumerate((31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)):
assert row_1[m] <= 600.0 * n / 365.0 + 1e-9, f"row 1 month {m+1}"
# keep_hot_facility=1 + no timer → Table 3a row 4 (900 × n/365).
row_4 = pcdb_combi_loss_override(
replace(base, separate_dhw_tests=0, keep_hot_facility=1, keep_hot_timer=None),
energy_content_monthly_kwh=energy_content,
daily_hot_water_monthly_l_per_day=daily_hw,
)
assert row_4 is not None
assert abs(sum(row_4) - 900.0) <= 1e-9
# keep_hot_facility=2 (electric keep-hot) → RAISES; Table 3a Note 2
# fuel-split between (61)m and (219)m not yet implemented.
with pytest.raises(UnresolvedPcdbCombiLoss) as excinfo: with pytest.raises(UnresolvedPcdbCombiLoss) as excinfo:
pcdb_combi_loss_override( pcdb_combi_loss_override(
replace(base, separate_dhw_tests=0, keep_hot_facility=None), replace(base, separate_dhw_tests=0, keep_hot_facility=2),
energy_content_monthly_kwh=energy_content, energy_content_monthly_kwh=energy_content,
daily_hot_water_monthly_l_per_day=daily_hw, daily_hot_water_monthly_l_per_day=daily_hw,
) )

View file

@ -117,12 +117,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"CO2 -0.27 → -0.23." "CO2 -0.27 → -0.23."
), ),
), ),
# Slice S0380.20: cert 0390-2954-3640-2196-4175 (Firebird oil PCDF _GoldenExpectation(
# 9005) lodges separate_dhw_tests=0 + keep_hot_facility=None, which cert_number="0390-2954-3640-2196-4175",
# the new strict-raise (`UnresolvedPcdbCombiLoss`) catches before actual_sap=60,
# the cascade can compute. Re-enable this golden cert once the expected_sap_resid=-7,
# Table 3a no-keep-hot sub-row is implemented (BRE STP09-B04 expected_pe_resid_kwh_per_m2=-26.0093,
# methodology) and the PCDB keep-hot dispatch lands. expected_co2_resid_tonnes_per_yr=-2.5211,
notes=(
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + "
"keep_hot_facility=None — Slice S0380.20 strict-raise blocked "
"this cert; Slice S0380.21 dispatches it to Table 3a row 1 "
"(`600 × fu × n/365`) per SAP 10.2 spec p.160. Residuals "
"re-pinned post-slice; SAP 53 vs lodged 60 (-7) traces to "
"the larger fabric heat-loss / oil-fuel cost cascade rather "
"than the §4 HW path (oil tariff + age-F masonry on a 360 "
"m² detached typically lands -5..-10 SAP)."
),
),
_GoldenExpectation( _GoldenExpectation(
cert_number="6035-7729-2309-0879-2296", cert_number="6035-7729-2309-0879-2296",
actual_sap=70, actual_sap=70,
@ -387,16 +399,16 @@ def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> No
# Cert 0390 lodges Firebird Boilers S 150-200 oil boiler at PCDB index_number # Cert 0390 lodges Firebird Boilers S 150-200 oil boiler at PCDB index_number
# 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain # 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain
# must surface that PCDB winter efficiency on `inputs.main_heating_efficiency` # must surface that PCDB winter efficiency on `inputs.main_heating_efficiency`
# rather than falling back to the Table 4a oil-boiler category default. # rather than falling back to the Table 4a oil-boiler category default. Slice
# Slice S0380.20: cert 0390-2954-3640-2196-4175 (Firebird oil PCDF # S0380.21 (PCDB keep-hot dispatch + Table 3a row 1) unblocked this cert: PCDF
# 9005) lodges separate_dhw_tests=0 + keep_hot_facility=None, raising # 9005 lodges separate_dhw_tests=0 + keep_hot_facility=None → cascade now
# `UnresolvedPcdbCombiLoss` from `cert_to_inputs`. Re-add once the # computes via the no-keep-hot row rather than raising.
# Table 3a no-keep-hot sub-row lands.
_PCDB_CHAIN_EXPECTATIONS: tuple[tuple[str, int, float | None], ...] = ( _PCDB_CHAIN_EXPECTATIONS: tuple[tuple[str, int, float | None], ...] = (
("7536-3827-0600-0600-0276", 17679, None), # Vaillant gas PCDB-listed ("7536-3827-0600-0600-0276", 17679, None), # Vaillant gas PCDB-listed
("0300-2747-7640-2526-2135", 17992, None), # gas PCDB-listed ("0300-2747-7640-2526-2135", 17992, None), # gas PCDB-listed
("8135-1728-8500-0511-3296", 17702, None), # gas PCDB-listed ("8135-1728-8500-0511-3296", 17702, None), # gas PCDB-listed
("0390-2254-6420-2126-5561", 18119, None), # LN12 gas combi PCDB-listed ("0390-2254-6420-2126-5561", 18119, None), # LN12 gas combi PCDB-listed
("0390-2954-3640-2196-4175", 9005, 0.864), # Firebird oil PCDF 9005, no keep-hot
("2130-1033-4050-5007-8395", 17505, None), # DE22 gas combi PCDB-listed + PV ("2130-1033-4050-5007-8395", 17505, None), # DE22 gas combi PCDB-listed + PV
) )

View file

@ -25,6 +25,8 @@ from domain.sap10_calculator.worksheet.water_heating import (
annual_average_hot_water_other_uses_l_per_day, annual_average_hot_water_other_uses_l_per_day,
assumed_occupancy, assumed_occupancy,
combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, combi_loss_monthly_kwh_table_3a_keep_hot_time_clock,
combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot,
combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous, combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous, combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
water_efficiency_monthly_via_equation_d1, water_efficiency_monthly_via_equation_d1,
@ -705,6 +707,88 @@ def test_combi_loss_table_3a_time_clock_keep_hot_matches_elmhurst_000490() -> No
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}" assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
def test_combi_loss_table_3a_row_1_no_keep_hot_matches_elmhurst_000890_dr87() -> None:
"""SAP10.2 §4 line (61)m via Table 3a row 1 "Instantaneous, without
keep-hot facility" (spec p.160):
(61)m = 600 × fu × n_m / 365 [kWh/month]
fu = V_d,m / 100 if V_d,m < 100, else 1.0
Elmhurst dr87-0001-000890 (cert 7800-1501-0922-7127-3563, Potterton
Promax Combi 28 HE+A, PCDF 15709, no keep-hot facility lodged). V_d
sits in [64.67, 77.88] L/day every month fu < 1.0 every month, so
Σ (61)m drops below the 600 kWh/yr baseline to ~428.
Per-month pin against the worksheet (61) row validates both the
formula and the V_d fu piecewise. Worksheet Jan: V=77.8795 fu
=0.778795 600 × 0.778795 × 31/365 = 39.6866 .
"""
# Arrange — dr87 worksheet 000890 row (44)m and (61)m, transcribed
# from the PDF supplied by the user.
daily_hw_44 = (
77.8795, 75.7429, 73.4103, 70.5073, 67.9174, 65.2259,
64.6669, 66.9948, 69.3822, 72.1462, 75.0749, 77.7703,
)
expected_61 = (
39.6866, 34.8625, 37.4091, 34.7707, 34.6100, 32.1662,
32.9536, 34.1398, 34.2159, 36.7649, 37.0232, 39.6309,
)
# Act
monthly = combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot(
daily_hot_water_monthly_l_per_day=daily_hw_44,
)
# Assert — pin element-wise at 1e-4 (worksheet rounds to 4 d.p.).
for m, (actual, exp) in enumerate(zip(monthly, expected_61)):
assert abs(actual - exp) <= 1e-4, (
f"month {m+1}: got {actual:.4f}, want {exp:.4f}"
)
def test_combi_loss_table_3a_row_1_collapses_to_keep_hot_time_clock_when_v_d_ge_100() -> None:
"""SAP10.2 Table 3a row 1 collapses to row 3 (keep-hot time clock)
when V_d,m 100 L/day for every month fu = 1.0 in both formulae
and the leading constant is 600 either way.
Guards against an off-by-one in the `fu = min(1.0, ...)` clamp: a
naive `fu = V_d/100` would push (61)m above 600 kWh/yr for high-
occupancy dwellings, contradicting the spec ceiling.
"""
# Arrange — V_d = 120 L/day every month → fu = 1.0 every month.
daily_hw_44 = (120.0,) * 12
# Act
no_keep_hot = combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot(
daily_hot_water_monthly_l_per_day=daily_hw_44,
)
keep_hot_tc = combi_loss_monthly_kwh_table_3a_keep_hot_time_clock()
# Assert
for m, (a, b) in enumerate(zip(no_keep_hot, keep_hot_tc)):
assert abs(a - b) <= 1e-9, f"month {m+1}: {a} vs {b}"
def test_combi_loss_table_3a_row_4_keep_hot_no_time_clock_matches_spec_formula() -> None:
"""SAP10.2 Table 3a row "Instantaneous, with keep-hot facility not
controlled by time clock": 900 × n_m / 365 kWh/month (spec p.160).
Flat 900 kWh/yr 50% larger than the time-clocked row because the
keep-hot heater cycles around the clock. Pin per month and on the
annual sum (must total exactly 900 kWh/yr).
"""
# Arrange
days = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
expected = tuple(900.0 * n / 365.0 for n in days)
# Act
monthly = combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock()
# Assert
for m, (actual, exp) in enumerate(zip(monthly, expected)):
assert abs(actual - exp) <= 1e-9, f"month {m+1}"
assert abs(sum(monthly) - 900.0) <= 1e-9
def test_total_water_heating_demand_matches_elmhurst_line_62_for_000490() -> None: def test_total_water_heating_demand_matches_elmhurst_line_62_for_000490() -> None:
"""SAP10.2 §4 line (62)m per the spec formula: """SAP10.2 §4 line (62)m per the spec formula:
(62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m

View file

@ -431,6 +431,45 @@ def combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() -> tuple[float, ...]:
return tuple(600.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH) return tuple(600.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH)
def combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot(
*,
daily_hot_water_monthly_l_per_day: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (61)m — Table 3a row 1 "Instantaneous, without
keep-hot facility": 600 × fu × n_m / 365 kWh/month, where fu = V_d,m
/ 100 when V_d,m < 100 L/day, else fu = 1.0 (SAP 10.2 spec p.160).
Differs from the keep-hot time-clock row by the fu volume-scaling
factor for low-volume dwellings (V_d < 100 L/day on average N <
2.5 occupants with no electric showers) the loss is proportionally
less than 600 kWh/yr. For V_d 100 every month, fu collapses to 1.0
and this row coincides with `..._keep_hot_time_clock()` (600 kWh/yr
flat).
Origin: BRE STP09-B04 §5.3 derived the 600 kWh/yr keep-hot baseline
from observed cycling losses; the no-keep-hot variant scales by fu
because instantaneous combis only cycle when actually drawing hot
water, and low-draw dwellings stand idle.
"""
return tuple(
600.0 * min(1.0, v / 100.0) * n / _DAYS_IN_YEAR
for v, n in zip(daily_hot_water_monthly_l_per_day, _DAYS_IN_MONTH)
)
def combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock() -> tuple[float, ...]:
"""SAP 10.2 §4 line (61)m — Table 3a row "Instantaneous, with keep-hot
facility not controlled by time clock": 900 × n_m / 365 kWh/month
(SAP 10.2 spec p.160).
A flat 900 kWh/year 50% larger than the time-clocked variant
because the keep-hot heater cycles around the clock rather than only
during scheduled windows. No fu adjustment per spec: the keep-hot
facility maintains store temperature regardless of draw.
"""
return tuple(900.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH)
# SAP 10.2 Table 2 (PDF p.158) hot water storage loss factor L kWh/litre/day. # SAP 10.2 Table 2 (PDF p.158) hot water storage loss factor L kWh/litre/day.
# Note 1 gives the smooth formulae the cascade uses (rather than the discrete # Note 1 gives the smooth formulae the cascade uses (rather than the discrete
# thickness rows) so any positive thickness resolves deterministically. # thickness rows) so any positive thickness resolves deterministically.