§4 slice 7: combi loss (Table 3a time clock) + (62)m total demand

Two new public functions:

  combi_loss_monthly_kwh_table_3a_keep_hot_time_clock()
    Table 3a row "Instantaneous, with keep-hot facility controlled by
    time clock" → 600 × n_m / 365 kWh/month (flat 600 kWh/year prorated
    by month length, no fu adjustment).

  total_water_heating_demand_monthly_kwh(...)
    Spec formula (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m.
    (56)m storage loss is intentionally absent — folded into storage-
    system efficiency at the (64)m stage. (46)m distribution loss
    appears here AND in (65)m heat gains (weight 0.8), per spec.

000490 close end-to-end through (62)m: combi with time-clock keep-hot,
no storage, no solar, no primary loss → Jan = 0.85×187.86 + 28.18 + 0 +
0 + 50.96 = 238.82 matching the worksheet to 1e-3.

000474 deferred: its PCDF-listed Vaillant boiler uses Table 3b (tested
to EN 13203-2) which needs PCDB-backed r1 + F1 parameters. The (61)m
implementation for that branch lands in a future slice along with the
PCDB stub plumbing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 16:06:05 +00:00
parent a3c687f1b0
commit bfba610b70
3 changed files with 100 additions and 0 deletions

View file

@ -185,3 +185,14 @@ LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = (
28.1791, 24.7810, 26.0259, 22.2229, 21.0818, 18.5009,
17.9237, 18.9290, 19.4569, 22.2851, 24.4085, 27.7897,
)
LINE_56_M_STORAGE_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no cylinder
LINE_57_M_SOLAR_STORAGE_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW
LINE_59_M_PRIMARY_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no primary circuit
LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = (
50.9589, 46.0274, 50.9589, 49.3151, 50.9589, 49.3151,
50.9589, 50.9589, 49.3151, 50.9589, 49.3151, 50.9589,
)
LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = (
238.8196, 211.2342, 224.4651, 197.4680, 191.5045, 172.6544,
170.4499, 177.1525, 179.0276, 199.5259, 212.0383, 236.2237,
)

View file

@ -20,12 +20,14 @@ from domain.sap.worksheet.water_heating import (
annual_average_hot_water_l_per_day,
annual_average_hot_water_other_uses_l_per_day,
assumed_occupancy,
combi_loss_monthly_kwh_table_3a_keep_hot_time_clock,
distribution_loss_monthly_kwh,
energy_content_of_hot_water_monthly_kwh,
hot_water_baths_monthly_l_per_day,
hot_water_mixer_showers_monthly_l_per_day,
hot_water_other_uses_monthly_l_per_day,
total_hot_water_monthly_l_per_day,
total_water_heating_demand_monthly_kwh,
)
@ -457,6 +459,50 @@ def test_distribution_loss_zero_for_instantaneous_point_of_use_water_heating() -
assert all(v == pytest.approx(0.0, abs=1e-9) for v in loss)
def test_combi_loss_table_3a_time_clock_keep_hot_matches_elmhurst_000490() -> None:
"""SAP10.2 §4 line (61)m via Table 3a row "Instantaneous, with keep-hot
facility controlled by time clock":
(61)m = 600 × n_m / 365 [kWh/month]
No `fu` factor for this row the time-clock keep-hot loss is a flat
600 kWh/year prorated by month length. 000490's combi (Vaillant
Ecotec Pro, "Combi keep hot type = Gas/Oil, time clock") lands on
this row; Jan = 600 × 31 / 365 = 50.9589 kWh/month exactly.
"""
# Arrange / Act
monthly = combi_loss_monthly_kwh_table_3a_keep_hot_time_clock()
# Assert
for m, (actual, exp) in enumerate(zip(monthly, _w000490.LINE_61_M_COMBI_LOSS_KWH)):
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
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
Note (56)m water-storage loss is NOT in (62)m it's absorbed by the
storage-system efficiency elsewhere. (46)m distribution loss IS in
(62)m here, despite also being recycled into (65)m heat gains.
Validated for 000490 (combi, no storage, no solar):
Jan = 0.85 × 187.8607 + 28.1791 + 0 + 0 + 50.9589
= 159.6816 + 28.1791 + 50.9589 = 238.8196
"""
# Arrange / Act
monthly = total_water_heating_demand_monthly_kwh(
energy_content_monthly_kwh=_w000490.LINE_45_M_HW_ENERGY_CONTENT_KWH,
distribution_loss_monthly_kwh=_w000490.LINE_46_M_DISTRIBUTION_LOSS_KWH,
solar_storage_monthly_kwh=_w000490.LINE_57_M_SOLAR_STORAGE_KWH,
primary_loss_monthly_kwh=_w000490.LINE_59_M_PRIMARY_LOSS_KWH,
combi_loss_monthly_kwh=_w000490.LINE_61_M_COMBI_LOSS_KWH,
)
# Assert
for m, (actual, exp) in enumerate(zip(monthly, _w000490.LINE_62_M_TOTAL_WH_KWH)):
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
def test_assumed_occupancy_floor_at_n_eq_1_for_small_dwellings() -> None:
"""Appendix J piecewise definition: TFA ≤ 13.9 m² → N=1 exactly. A
tiny studio flat at the boundary is the most common trigger."""

View file

@ -229,6 +229,49 @@ def distribution_loss_monthly_kwh(
return tuple(0.15 * e for e in monthly_energy_content_kwh)
def combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() -> tuple[float, ...]:
"""SAP 10.2 §4 line (61)m — Table 3a row "Instantaneous, with keep-hot
facility controlled by time clock": 600 × n_m / 365 kWh/month.
A flat 600 kWh/year combi loss, prorated by month length. Unlike the
"without keep-hot" rows there is no `fu` adjustment for low-volume
draw the loss is constant. Suitable for any standard non-PCDB-
tested combi that the cert lodges as having a time-clock keep-hot
facility (Appendix D section D1.16).
"""
return tuple(600.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH)
def total_water_heating_demand_monthly_kwh(
*,
energy_content_monthly_kwh: tuple[float, ...],
distribution_loss_monthly_kwh: tuple[float, ...],
solar_storage_monthly_kwh: tuple[float, ...],
primary_loss_monthly_kwh: tuple[float, ...],
combi_loss_monthly_kwh: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (62)m via the spec's formula:
(62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m
Note (56)m water-storage loss does NOT appear here it's accounted
for through the storage system's efficiency in the (63a-d) inputs and
the (64)m output line. (46)m distribution loss appears in both (62)m
and (65)m heat gains (with weight 0.8); that's intentional per spec.
All five monthly arrays must be 12-tuples in calendar order Jan..Dec.
"""
return tuple(
0.85 * e + d + s + p + c
for e, d, s, p, c in zip(
energy_content_monthly_kwh,
distribution_loss_monthly_kwh,
solar_storage_monthly_kwh,
primary_loss_monthly_kwh,
combi_loss_monthly_kwh,
)
)
def _days_weighted_average(monthly: tuple[float, ...]) -> float:
"""Σ value[m] × n_m / 365 — used by Appendix J equations J4 and J9."""
return sum(v * d for v, d in zip(monthly, _DAYS_IN_MONTH)) / _DAYS_IN_YEAR