mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
1abc339848
commit
f7d863a9fa
5 changed files with 239 additions and 69 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue