Cohort residual slice 6: Table 3c row 1 helper + DVF piecewise (M+L / M+S)

Implements SAP10.2 Appendix J Table 3c row 1 (Instantaneous combi, two-
profile EN 13203-2 / OPS 26 tests):
    (61)m = (45)m × [r1 + DVF × F3] × fu + [F2 × n_m]

DVF (Daily Volume Factor) is piecewise in V_d,m, gated on the test
profile pair: M+L (PCDF separate_dhw_tests=2) or M+S (=3). Helper
`_table_3c_dvf` keeps the spec's piecewise branches close to the
formula in `combi_loss_monthly_kwh_table_3c_two_profile_instantaneous`.

Tests:
  - 000477 element-wise LINE_61 pin via Table 3c (PCDB 18118 lodges
    r1=0.015, F2=0.0, F3=0.00014; profile_pair=M+L). Closes 000477's
    combi-loss component at abs=1e-3 against U985 PDF.
  - Parametrized DVF boundary table for M+L (V<100, V=100, V=199.8,
    V>199.8) and M+S (V<36, V∈[36,100.2], V>100.2) at abs=1e-9.

Citation fix: parser docstring updates the BRE PCDF Spec reference from
the placeholder "v1.0 §7.11" to the actual Rev 6b (12 May 2021) Gas and
Oil Boiler Table, pp. 14-15 (now landed at docs/sap-spec/). Notes that
PCDF field 48's encoding (1=schedule 2 → profile M; 2=schedules 2+3 →
M+L; 3=schedules 2+1 → M+S) drives the Table 3b/3c row selection, and
that r2 (field 55) is lodged but spec-excluded from SAP.

Table 3c rows 2-5 (storage-FGHRS / storage-combi variants) and Table
3b rows 2-5 stay deferred — symmetric "row 1 only" coverage until a
fixture exercises them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 13:37:44 +00:00
parent 6c966ffe2b
commit b01164a2b6
3 changed files with 187 additions and 11 deletions

View file

@ -64,18 +64,24 @@ class GasOilBoilerRecord:
comparative_hot_water_efficiency_pct: Optional[float]
output_kw_max: Optional[float]
final_year_of_manufacture: Optional[int]
# SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF
# Spec v1.0 §7.11 fields 48 / 51 / 52 / 56 / 57. Populated only for
# boilers EN 13203-2 / OPS 26 tested; SAP-default boilers leave them
# all blank → `separate_dhw_tests=0` and (61)m falls back to Table 3a.
# BRE PCDF Spec v1.0 §7.11 field 16 (0-idx 15): 0=normal, 1=integral
# FGHRS, 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the
# Table 3b/3c row selection — only `subsidiary_type=0` exercises the
# SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF Spec
# Rev 6b (12 May 2021), Gas and Oil Boiler Table, fields 48 / 51 / 52
# / 56 / 57 (see `docs/sap-spec/PCDF_Spec_Rev-06b_12_May_2021.pdf`
# pp. 14-15). Populated only for boilers EN 13203-2 / OPS 26 tested;
# SAP-default boilers leave them all blank → `separate_dhw_tests=0`
# and (61)m falls back to Table 3a. Field 48 encodes the test
# schedules: 0=none, 1=schedule 2 only (profile M → Table 3b row 1),
# 2=schedules 2 and 3 (profiles M+L → Table 3c), 3=schedules 2 and 1
# (profiles M+S → Table 3c). Field 55 (r2) is lodged but explicitly
# excluded from SAP assessments ("only r1") so it is not surfaced.
# PCDF Spec Rev 6b field 16 (0-idx 15): 0=normal, 1=integral FGHRS,
# 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the Table
# 3b/3c row selection — only `subsidiary_type=0` exercises the
# "Instantaneous with non-storage FGHRS or without FGHRS" row 1.
subsidiary_type: Optional[int]
# BRE PCDF Spec v1.0 §7.11 field 39 (0-idx 38): 0=not storage combi,
# 1=primary water store, 2=secondary store, 3=CPSU. Gates storage-
# combi rows in Table 3b/3c (deferred until a fixture exercises).
# PCDF Spec Rev 6b field 39 (0-idx 38): 0=not storage combi, 1=primary
# water store, 2=secondary store, 3=CPSU. Gates storage-combi rows in
# Table 3b/3c (deferred until a fixture exercises).
store_type: Optional[int]
separate_dhw_tests: Optional[int]
rejected_energy_proportion_r1: Optional[float]

View file

@ -14,6 +14,7 @@ import pytest
from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474,
_elmhurst_worksheet_000477 as _w000477,
_elmhurst_worksheet_000490 as _w000490,
)
from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id
@ -25,6 +26,7 @@ from domain.sap.worksheet.water_heating import (
assumed_occupancy,
combi_loss_monthly_kwh_table_3a_keep_hot_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,
distribution_loss_monthly_kwh,
energy_content_of_hot_water_monthly_kwh,
@ -509,6 +511,98 @@ def test_combi_loss_table_3b_row_1_matches_elmhurst_000474_pcdb_arithmetic() ->
assert monthly[0] == pytest.approx(_w000474.LINE_61_M_COMBI_LOSS_KWH[0], abs=0.05)
def test_combi_loss_table_3c_two_profile_matches_elmhurst_000477_lodged_line_61() -> None:
"""SAP10.2 Appendix J Table 3c row 1 (Instantaneous combi with non-
storage FGHRS or without FGHRS), two-profile tests:
(61)m = (45)m × [r1 + DVF × F3] × fu + [F2 × n_m]
For 000477 (Vaillant ecoTEC sustain 24, PCDB 18118, separate_dhw_tests
= 2 boiler tested under EN 13203-2 schedules 2 and 3, i.e. profiles
M and L per PCDF Spec Rev 6b field 48 encoding): r1 = 0.015, F2 = 0.0
kWh/day, F3 = 0.00014 L¹.
V_d,m [94.7, 114.2] L/day for 000477 straddles the M+L DVF cutoff
at V=100. Months with V<100 get DVF=0; months with V[100, 199.8] get
DVF=100.2-V (negative, reducing r1's contribution). Element-wise pin
against the U985 worksheet line ref (61)m at abs=1e-3 validates the
full piecewise + formula."""
# Arrange
r1 = 0.015
f2 = 0.0
f3 = 0.00014
energy_content_45 = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH
daily_hw_44 = _w000477.LINE_44_M_DAILY_HW_USAGE_L
# Act
monthly = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
rejected_energy_proportion_r1=r1,
loss_factor_f2_kwh_per_day=f2,
rejected_factor_f3_per_litre=f3,
profile_pair="M+L",
energy_content_monthly_kwh=energy_content_45,
daily_hot_water_monthly_l_per_day=daily_hw_44,
)
# Assert — element-wise pin against U985 line ref (61)m.
for month_idx, (got, want) in enumerate(
zip(monthly, _w000477.LINE_61_M_COMBI_LOSS_KWH)
):
assert got == pytest.approx(want, abs=1e-3), (
f"month {month_idx}: got {got:.4f}, want {want:.4f}"
)
@pytest.mark.parametrize(
"profile_pair, daily_hot_water_l_per_day, expected_dvf",
[
# M+L: 0 for V<100, 100.2-V for V∈[100, 199.8], -99.6 for V>199.8.
("M+L", 50.0, 0.0),
("M+L", 99.99, 0.0),
("M+L", 100.0, 0.2),
("M+L", 150.0, -49.8),
("M+L", 199.8, -99.6),
("M+L", 200.0, -99.6),
# M+S: 64.2 for V<36, 100.2-V for V∈[36, 100.2], 0 for V>100.2.
("M+S", 20.0, 64.2),
("M+S", 35.99, 64.2),
("M+S", 36.0, 64.2),
("M+S", 80.0, 20.2),
("M+S", 100.2, 0.0),
("M+S", 110.0, 0.0),
],
)
def test_combi_loss_table_3c_dvf_branches_match_spec_piecewise(
profile_pair: str,
daily_hot_water_l_per_day: float,
expected_dvf: float,
) -> None:
"""SAP10.2 Appendix J Table 3c — Daily Volume Factor (DVF) is piecewise
in V_d,m, gated by the profile pair (M+L for separate_dhw_tests=2,
M+S for separate_dhw_tests=3). The DVF helper is private; this test
exercises it through the public Table 3c orchestrator by choosing
r1=0, F2=0, (45)m=1.0, V_d,m so that (61)m collapses to fu × F3 × DVF
(with fu=1.0 when V100). Solving (61)m for DVF recovers the helper's
output one branch at a time."""
# Arrange — collapse the formula so (61)m == fu × F3 × DVF.
f3 = 1.0
energy_content = (1.0,) * 12
daily_hw_monthly = (daily_hot_water_l_per_day,) * 12
fu = daily_hot_water_l_per_day / 100.0 if daily_hot_water_l_per_day < 100.0 else 1.0
# Act
monthly = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
rejected_energy_proportion_r1=0.0,
loss_factor_f2_kwh_per_day=0.0,
rejected_factor_f3_per_litre=f3,
profile_pair=profile_pair, # type: ignore[arg-type]
energy_content_monthly_kwh=energy_content,
daily_hot_water_monthly_l_per_day=daily_hw_monthly,
)
# Assert — recovered DVF matches the spec's piecewise output.
assert monthly[0] == pytest.approx(fu * f3 * expected_dvf, abs=1e-9)
def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_month() -> None:
"""SAP10.2 Appendix D §D2.1 (2) Equation D1:

View file

@ -25,7 +25,7 @@ from __future__ import annotations
from dataclasses import dataclass
from math import exp
from typing import Final, Optional
from typing import Final, Literal, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
@ -338,6 +338,82 @@ def combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
)
_DVF_M_AND_L_LOWER_V_L_PER_DAY: Final[float] = 100.0
_DVF_M_AND_L_UPPER_V_L_PER_DAY: Final[float] = 199.8
_DVF_M_AND_L_UPPER_CLAMP: Final[float] = -99.6
_DVF_M_AND_S_LOWER_V_L_PER_DAY: Final[float] = 36.0
_DVF_M_AND_S_UPPER_V_L_PER_DAY: Final[float] = 100.2
_DVF_M_AND_S_LOWER_CLAMP: Final[float] = 64.2
_DVF_LINEAR_INTERCEPT: Final[float] = 100.2
def _table_3c_dvf(
daily_hot_water_l_per_day: float,
profile_pair: Literal["M+L", "M+S"],
) -> float:
"""SAP 10.2 Appendix J Table 3c — Daily Volume Factor.
Piecewise function of V_d,m gated on which two EN 13203-2 / OPS 26
profiles the combi was tested with (encoded in PCDF Table 105 field
48 = separate_dhw_tests: 2 schedules 2 and 3 = M+L; 3 schedules
2 and 1 = M+S).
"""
v = daily_hot_water_l_per_day
if profile_pair == "M+L":
if v < _DVF_M_AND_L_LOWER_V_L_PER_DAY:
return 0.0
if v > _DVF_M_AND_L_UPPER_V_L_PER_DAY:
return _DVF_M_AND_L_UPPER_CLAMP
return _DVF_LINEAR_INTERCEPT - v
if v < _DVF_M_AND_S_LOWER_V_L_PER_DAY:
return _DVF_M_AND_S_LOWER_CLAMP
if v > _DVF_M_AND_S_UPPER_V_L_PER_DAY:
return 0.0
return _DVF_LINEAR_INTERCEPT - v
def combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
*,
rejected_energy_proportion_r1: float,
loss_factor_f2_kwh_per_day: float,
rejected_factor_f3_per_litre: float,
profile_pair: Literal["M+L", "M+S"],
energy_content_monthly_kwh: tuple[float, ...],
daily_hot_water_monthly_l_per_day: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 Appendix J Table 3c row 1 (Instantaneous combi with non-
storage FGHRS or without FGHRS), two-profile tests:
(61)m = (45)m × [r1 + DVF × F3] × fu + [F2 × n_m]
where r1, F2 (kWh/day), F3 (litres¹, can be negative) are PCDF
Table 105 fields 51 / 56 / 57, DVF is the per-month Daily Volume
Factor from `_table_3c_dvf`, and fu = V_d,m / 100 when V_d,m < 100
else 1.0.
Applies to PCDB combis with separate_dhw_tests {2, 3}: =2 means
schedules 2 and 3 (profiles M and L); =3 means schedules 2 and 1
(profiles M and S). The profile pair selects the DVF piecewise
branch see `_table_3c_dvf`. r2 (PCDF field 55) is lodged but the
spec explicitly excludes it from SAP assessments ("only r1").
Storage-FGHRS / storage-combi variants (Table 3c rows 2-5) are
deferred until a fixture exercises them mirrors the row-1-only
coverage of Table 3b (see `_pcdb_combi_loss_override`).
"""
return tuple(
e
* (rejected_energy_proportion_r1 + _table_3c_dvf(v, profile_pair) * rejected_factor_f3_per_litre)
* (v / 100.0 if v < 100.0 else 1.0)
+ loss_factor_f2_kwh_per_day * n
for e, v, n in zip(
energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day,
_DAYS_IN_MONTH,
)
)
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.