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,
TABLE_J1_TCOLD_FROM_MAINS_C,
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_3c_two_profile_instantaneous,
cylinder_storage_loss_factor_table_2,
@ -1789,39 +1791,30 @@ def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
class UnresolvedPcdbCombiLoss(ValueError):
"""Raised when a cert lodges a PCDB Table 105 combi without enough
metadata for the cascade to dispatch the correct SAP 10.2 Appendix
J Table 3a sub-row.
"""Raised when a cert lodges a PCDB Table 105 combi whose keep-hot
configuration falls outside the SAP 10.2 Table 3a rows the cascade
has implemented.
Trigger: `separate_dhw_tests` is 0 / None (no EN 13203-2 lab data
so Tables 3b/3c don't apply) AND `keep_hot_facility` is 0 / None
(the PCDB record lodges no keep-hot the cascade's only
implemented Table 3a row is "with keep-hot, time clock" 600 kWh/yr,
spec-wrong for no-keep-hot combis).
Current trigger: `keep_hot_facility {2, 3}` (keep-hot heated by
electricity, or a mix of electricity + fuel Table 3a Note 2 routes
the electric portion of the loss to worksheet (219)m rather than
leaving it in (61)m). The cascade does not yet split the loss across
fuels, so surface the gap rather than silently mis-route.
Cohort-2 cert 7800-1501-0922-7127-3563 (PCDF 15709 Potterton
Promax Combi 28 HE+A): worksheet (61) sums to ~428 kWh/yr via the
no-keep-hot sub-row formula vs the cascade's 600 → +172 kWh/yr
excess HW demand -0.24 SAP. 10 other cohort-2 certs (PCDF 15709
/ PCDF 10315) hit the same gap.
Cohort-1 cert 000490 (PCDF 10328 Vaillant Ecotec Pro 28): same
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.
Rows the cascade now handles (Slice S0380.21):
- `keep_hot_facility {0, None}` Table 3a row 1 (no keep-hot)
`600 × fu × n_m / 365` with `fu = min(1, V_d/100)`.
- `keep_hot_facility=1, keep_hot_timer=1` Table 3a row 3
(keep-hot, time-clock) `600 × n_m / 365` (cascade default).
- `keep_hot_facility=1, keep_hot_timer {0, None}` Table 3a
row 4 (keep-hot, no time clock) `900 × n_m / 365`.
"""
def __init__(self, *, pcdf_index: Optional[int], boiler: str) -> None:
def __init__(
self, *, pcdf_index: Optional[int], boiler: str, reason: str
) -> None:
super().__init__(
f"PCDB combi {boiler!r} (PCDF {pcdf_index}) lodges "
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."
f"PCDB combi {boiler!r} (PCDF {pcdf_index}): {reason}"
)
self.pcdf_index = pcdf_index
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
= 3 schedules 2 and 1 (profiles M + S) Table 3c, DVF = M+S
= 0 / None falls through to Table 3a, dispatched by the PCDB
keep-hot fields (`keep_hot_facility`, `keep_hot_timer`) raises
`UnresolvedPcdbCombiLoss` when no keep-hot is lodged because the
cascade's only implemented Table 3a row is the keep-hot one
(Slice S0380.20 strict-raise context).
keep-hot fields (`keep_hot_facility`, `keep_hot_timer`):
kh {0, None} row 1 (no keep-hot) 600 × fu × n/365
kh = 1, timer = 1 row 3 (time-clock) 600 × n / 365
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,
3} integral FGHRS / HP+boiler combinations; `store_type` {1, 2,
@ -1862,18 +1858,37 @@ def pcdb_combi_loss_override(
return None
sdt = pcdb_record.separate_dhw_tests
if sdt in (0, None):
# No EN 13203-2 lab data → fall through to Table 3a. Cascade's
# only implemented Table 3a row is "with keep-hot, time clock"
# (600 kWh/yr); use it only when the PCDB lodges keep-hot.
if pcdb_record.keep_hot_facility in (0, None):
raise UnresolvedPcdbCombiLoss(
pcdf_index=pcdb_record.pcdb_id,
boiler=(
f"{pcdb_record.brand_name} {pcdb_record.model_name} "
f"{pcdb_record.model_qualifier}".strip()
),
# No EN 13203-2 lab data → dispatch via Table 3a keep-hot fields.
kh = pcdb_record.keep_hot_facility
timer = pcdb_record.keep_hot_timer
if kh in (0, None):
# SAP 10.2 Table 3a row 1: 600 × fu × n_m / 365 (spec p.160).
return combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot(
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
)
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
if r1 is 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:
"""The override gate returns None — letting the worksheet fall back
to Table 3a whenever the PCDB record lodges keep-hot facility but
has insufficient EN 13203 lab data or sits in a storage / FGHRS row
(Table 3b/3c rows 2-5, deferred until a fixture exercises them).
to Table 3a row 3 (600 × n/365) whenever the PCDB record lodges
keep-hot with a time clock but has insufficient EN 13203 lab data,
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
keep_hot_facility {None, 0}, raises `UnresolvedPcdbCombiLoss`
instead of returning None the cascade's only implemented Table
3a row is "with keep-hot" (600 kWh/yr), which is the wrong spec
row for no-keep-hot combis (cohort-2 cert 7800 had ~+172 kWh/yr
over-prediction)."""
Per Slice S0380.21: keep_hot_facility {None, 0} dispatches to
Table 3a row 1 (`600 × fu × n/365`), keep_hot_facility=1 + no
timer dispatches to row 4 (`900 × n/365`). Only the electric
keep-hot variants (keep_hot_facility {2, 3}) now raise
`UnresolvedPcdbCombiLoss` Table 3a Note 2 fuel-split deferred."""
# Arrange — a minimal record skeleton, mutated per scenario via
# dataclasses.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
)
# separate_dhw_tests=0 + keep_hot_facility=1 → None (no PCDB DHW
# test data, but cascade's keep-hot row IS the right spec row).
# separate_dhw_tests=0 + keep_hot_facility=1 + timer=1 → None (no
# PCDB DHW test data, but cascade's row 3 default IS the right spec
# row → return None and let the cascade default fire).
assert (
pcdb_combi_loss_override(
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
)
# separate_dhw_tests=0 + keep_hot_facility=None → RAISES (cascade's
# keep-hot row is wrong for no-keep-hot combis; Table 3a no-keep-hot
# row not yet implemented per Slice S0380.20).
# separate_dhw_tests=0 + keep_hot_facility=None → Table 3a row 1
# (600 × fu × n/365) — Slice S0380.21 dispatch.
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:
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,
daily_hot_water_monthly_l_per_day=daily_hw,
)

View file

@ -117,12 +117,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"CO2 -0.27 → -0.23."
),
),
# Slice S0380.20: cert 0390-2954-3640-2196-4175 (Firebird oil PCDF
# 9005) lodges separate_dhw_tests=0 + keep_hot_facility=None, which
# the new strict-raise (`UnresolvedPcdbCombiLoss`) catches before
# the cascade can compute. Re-enable this golden cert once the
# Table 3a no-keep-hot sub-row is implemented (BRE STP09-B04
# methodology) and the PCDB keep-hot dispatch lands.
_GoldenExpectation(
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=-7,
expected_pe_resid_kwh_per_m2=-26.0093,
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(
cert_number="6035-7729-2309-0879-2296",
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
# 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`
# rather than falling back to the Table 4a oil-boiler category default.
# Slice S0380.20: cert 0390-2954-3640-2196-4175 (Firebird oil PCDF
# 9005) lodges separate_dhw_tests=0 + keep_hot_facility=None, raising
# `UnresolvedPcdbCombiLoss` from `cert_to_inputs`. Re-add once the
# Table 3a no-keep-hot sub-row lands.
# rather than falling back to the Table 4a oil-boiler category default. Slice
# S0380.21 (PCDB keep-hot dispatch + Table 3a row 1) unblocked this cert: PCDF
# 9005 lodges separate_dhw_tests=0 + keep_hot_facility=None → cascade now
# computes via the no-keep-hot row rather than raising.
_PCDB_CHAIN_EXPECTATIONS: tuple[tuple[str, int, float | None], ...] = (
("7536-3827-0600-0600-0276", 17679, None), # Vaillant gas PCDB-listed
("0300-2747-7640-2526-2135", 17992, 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-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
)

View file

@ -25,6 +25,8 @@ from domain.sap10_calculator.worksheet.water_heating import (
annual_average_hot_water_other_uses_l_per_day,
assumed_occupancy,
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_3c_two_profile_instantaneous,
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}"
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:
"""SAP10.2 §4 line (62)m per the spec formula:
(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)
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.
# Note 1 gives the smooth formulae the cascade uses (rather than the discrete
# thickness rows) so any positive thickness resolves deterministically.