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] comparative_hot_water_efficiency_pct: Optional[float]
output_kw_max: Optional[float] output_kw_max: Optional[float]
final_year_of_manufacture: Optional[int] final_year_of_manufacture: Optional[int]
# SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF # SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF Spec
# Spec v1.0 §7.11 fields 48 / 51 / 52 / 56 / 57. Populated only for # Rev 6b (12 May 2021), Gas and Oil Boiler Table, fields 48 / 51 / 52
# boilers EN 13203-2 / OPS 26 tested; SAP-default boilers leave them # / 56 / 57 (see `docs/sap-spec/PCDF_Spec_Rev-06b_12_May_2021.pdf`
# all blank → `separate_dhw_tests=0` and (61)m falls back to Table 3a. # pp. 14-15). Populated only for boilers EN 13203-2 / OPS 26 tested;
# BRE PCDF Spec v1.0 §7.11 field 16 (0-idx 15): 0=normal, 1=integral # SAP-default boilers leave them all blank → `separate_dhw_tests=0`
# FGHRS, 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the # and (61)m falls back to Table 3a. Field 48 encodes the test
# Table 3b/3c row selection — only `subsidiary_type=0` exercises the # 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. # "Instantaneous with non-storage FGHRS or without FGHRS" row 1.
subsidiary_type: Optional[int] subsidiary_type: Optional[int]
# BRE PCDF Spec v1.0 §7.11 field 39 (0-idx 38): 0=not storage combi, # PCDF Spec Rev 6b field 39 (0-idx 38): 0=not storage combi, 1=primary
# 1=primary water store, 2=secondary store, 3=CPSU. Gates storage- # water store, 2=secondary store, 3=CPSU. Gates storage-combi rows in
# combi rows in Table 3b/3c (deferred until a fixture exercises). # Table 3b/3c (deferred until a fixture exercises).
store_type: Optional[int] store_type: Optional[int]
separate_dhw_tests: Optional[int] separate_dhw_tests: Optional[int]
rejected_energy_proportion_r1: Optional[float] rejected_energy_proportion_r1: Optional[float]

View file

@ -14,6 +14,7 @@ import pytest
from domain.sap.worksheet.tests import ( from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474, _elmhurst_worksheet_000474 as _w000474,
_elmhurst_worksheet_000477 as _w000477,
_elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000490 as _w000490,
) )
from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id 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, 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_3b_row_1_instantaneous, combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
water_efficiency_monthly_via_equation_d1, water_efficiency_monthly_via_equation_d1,
distribution_loss_monthly_kwh, distribution_loss_monthly_kwh,
energy_content_of_hot_water_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) 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: def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_month() -> None:
"""SAP10.2 Appendix D §D2.1 (2) Equation D1: """SAP10.2 Appendix D §D2.1 (2) Equation D1:

View file

@ -25,7 +25,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from math import exp from math import exp
from typing import Final, Optional from typing import Final, Literal, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData 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, ...]: 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 """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. facility controlled by time clock": 600 × n_m / 365 kWh/month.