mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Cohort residual slice 7: PCDB override routes separate_dhw_tests∈{2,3} through Table 3c
Renames `_pcdb_table_3b_combi_loss_override` → `pcdb_combi_loss_override`
(drop the underscore now that it has a unit-testable contract; helper
is now a public boundary of cert_to_inputs). The gate routes on PCDF
Spec Rev 6b field 48:
= 1 → Table 3b row 1 (profile M only) — existing
= 2 → Table 3c row 1 with DVF branch "M+L" — new (schedules 2+3)
= 3 → Table 3c row 1 with DVF branch "M+S" — new (schedules 2+1)
other / missing factors → None (Table 3a)
Storage-FGHRS (subsidiary_type ∈ {1, 2, 3}) and storage-combi
(store_type ∈ {1, 2, 3}) configurations stay rejected — they gate
Rows 2-5 of both Tables 3b and 3c, deferred until a fixture exercises
them.
Tests (4 new):
- PCDB 18118 (Vaillant ecoTEC sustain 24, sep_dhw=2) routes through
Table 3c with M+L. Element-wise match at abs=1e-12 against direct
Table 3c invocation with the same inputs.
- PCDB 16952 (Fondital Itaca KC 24, sep_dhw=3 — the M+S branch) routes
through Table 3c with M+S. No Elmhurst fixture lodges this record;
borrow 000477's monthly inputs as the deterministic vehicle.
- PCDB 16839 (sep_dhw=1) preserves the existing Table 3b row 1 path —
regression guard.
- Synthetic skeleton record exercises None-returning branches:
null record, sep_dhw=0, integral FGHRS subsidiary_type=1, primary
store store_type=1, missing F2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b01164a2b6
commit
62bbf863ff
3 changed files with 254 additions and 23 deletions
|
|
@ -37,7 +37,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.3 specification
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Final, Optional
|
||||
from typing import Callable, Final, Literal, Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
EpcPropertyData,
|
||||
|
|
@ -100,6 +100,7 @@ from domain.sap.worksheet.water_heating import (
|
|||
TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
WaterHeatingResult,
|
||||
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_heating_from_cert,
|
||||
)
|
||||
|
|
@ -722,38 +723,68 @@ def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
|
|||
return n is None or n >= 1
|
||||
|
||||
|
||||
def _pcdb_table_3b_combi_loss_override(
|
||||
def pcdb_combi_loss_override(
|
||||
pcdb_record: Optional[GasOilBoilerRecord],
|
||||
*,
|
||||
energy_content_monthly_kwh: tuple[float, ...],
|
||||
daily_hot_water_monthly_l_per_day: tuple[float, ...],
|
||||
) -> Optional[tuple[float, ...]]:
|
||||
"""Build a Table 3b row-1 combi-loss override when the PCDB record
|
||||
lodges single-profile (Profile M only) test data for an instantaneous
|
||||
combi with non-storage FGHRS or without FGHRS. Returns None for
|
||||
every other PCDB combi configuration so the worksheet falls back to
|
||||
the Table 3a default. Other Table 3b/3c rows (storage variants,
|
||||
integral FGHRS, two-profile Table 3c) are deferred until a fixture
|
||||
exercises them — defaulting to Table 3a is safe (matches the pre-
|
||||
§4 behaviour) but loses spec accuracy for those configurations."""
|
||||
"""Route a PCDB combi record to the matching SAP10.2 Appendix J row.
|
||||
|
||||
PCDF Spec Rev 6b field 48 (`separate_dhw_tests`) encodes which EN
|
||||
13203-2 / OPS 26 schedules the lab tested under, and that selects
|
||||
the SAP Table:
|
||||
= 1 → schedule 2 only (profile M) → Table 3b row 1
|
||||
= 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
|
||||
Any other value (0, None, or insufficient r1/F factors lodged)
|
||||
returns None so the worksheet falls back to the Table 3a default.
|
||||
|
||||
Storage-FGHRS and storage-combi variants (`subsidiary_type` ∈ {1, 2,
|
||||
3} → integral FGHRS / HP+boiler combinations; `store_type` ∈ {1, 2,
|
||||
3} → primary / secondary store / CPSU) gate Rows 2-5 of both Tables
|
||||
3b and 3c. Those rows are deferred until a fixture exercises them
|
||||
— defaulting to Table 3a is safe (matches the pre-§4 behaviour) but
|
||||
loses spec accuracy for those configurations.
|
||||
"""
|
||||
if pcdb_record is None:
|
||||
return None
|
||||
if pcdb_record.separate_dhw_tests != 1:
|
||||
return None
|
||||
if pcdb_record.subsidiary_type not in (None, 0):
|
||||
return None
|
||||
if pcdb_record.store_type not in (None, 0):
|
||||
return None
|
||||
r1 = pcdb_record.rejected_energy_proportion_r1
|
||||
f1 = pcdb_record.loss_factor_f1_kwh_per_day
|
||||
if r1 is None or f1 is None:
|
||||
if r1 is None:
|
||||
return None
|
||||
return combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
|
||||
rejected_energy_proportion_r1=r1,
|
||||
loss_factor_f1_kwh_per_day=f1,
|
||||
energy_content_monthly_kwh=energy_content_monthly_kwh,
|
||||
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
|
||||
)
|
||||
match pcdb_record.separate_dhw_tests:
|
||||
case 1:
|
||||
f1 = pcdb_record.loss_factor_f1_kwh_per_day
|
||||
if f1 is None:
|
||||
return None
|
||||
return combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
|
||||
rejected_energy_proportion_r1=r1,
|
||||
loss_factor_f1_kwh_per_day=f1,
|
||||
energy_content_monthly_kwh=energy_content_monthly_kwh,
|
||||
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
|
||||
)
|
||||
case 2 | 3:
|
||||
f2 = pcdb_record.loss_factor_f2_kwh_per_day
|
||||
f3 = pcdb_record.rejected_factor_f3_per_litre
|
||||
if f2 is None or f3 is None:
|
||||
return None
|
||||
profile_pair: Literal["M+L", "M+S"] = (
|
||||
"M+L" if pcdb_record.separate_dhw_tests == 2 else "M+S"
|
||||
)
|
||||
return 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=profile_pair,
|
||||
energy_content_monthly_kwh=energy_content_monthly_kwh,
|
||||
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
|
||||
)
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _water_heating_worksheet_and_gains(
|
||||
|
|
@ -781,7 +812,7 @@ def _water_heating_worksheet_and_gains(
|
|||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
low_water_use=False,
|
||||
)
|
||||
combi_loss_override = _pcdb_table_3b_combi_loss_override(
|
||||
combi_loss_override = pcdb_combi_loss_override(
|
||||
pcdb_record,
|
||||
energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh,
|
||||
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,13 @@ from domain.ml.tests._fixtures import (
|
|||
make_window,
|
||||
)
|
||||
from domain.sap.calculator import Sap10Calculator, SapResult
|
||||
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs
|
||||
from domain.sap.rdsap.cert_to_inputs import pcdb_combi_loss_override, cert_to_inputs
|
||||
from domain.sap.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record
|
||||
from domain.sap.worksheet.tests import _elmhurst_worksheet_000477 as _w000477
|
||||
from domain.sap.worksheet.water_heating import (
|
||||
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
|
||||
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
|
||||
)
|
||||
|
||||
|
||||
def _gas_boiler_detail(sap_main_heating_code: int = 102) -> MainHeatingDetail:
|
||||
|
|
@ -720,3 +726,197 @@ def test_detached_house_dwelling_type_keeps_full_envelope_exposed() -> None:
|
|||
# Assert
|
||||
assert inputs.heat_transmission.floor_w_per_k > 0
|
||||
assert inputs.heat_transmission.roof_w_per_k > 0
|
||||
|
||||
|
||||
def test_pcdb_combi_loss_override_routes_separate_dhw_tests_2_through_table_3c_m_plus_l() -> None:
|
||||
"""PCDF Spec Rev 6b (12 May 2021) field 48 = 2 encodes EN 13203-2 /
|
||||
OPS 26 testing under schedules 2 and 3 = profiles M and L. The
|
||||
override gate must route those PCDB records through SAP10.2 Appendix
|
||||
J Table 3c with the M+L DVF branch — not the existing Table 3b row
|
||||
1 path (which is profile M only)."""
|
||||
# Arrange — PCDB 18118 (Vaillant ecoTEC sustain 24) lodges
|
||||
# separate_dhw_tests=2, r1=0.015, F2=0.0, F3=0.00014; 000477 ships
|
||||
# the matching (44)m / (45)m worksheet inputs.
|
||||
pcdb = gas_oil_boiler_record(18118)
|
||||
assert pcdb is not None
|
||||
assert pcdb.separate_dhw_tests == 2
|
||||
energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH
|
||||
daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L
|
||||
|
||||
# Act
|
||||
override = pcdb_combi_loss_override(
|
||||
pcdb,
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
|
||||
# Assert — override is exactly the Table 3c M+L output (no
|
||||
# double-rounding, no transposed F1↔F2). Element-wise.
|
||||
expected = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
|
||||
rejected_energy_proportion_r1=0.015,
|
||||
loss_factor_f2_kwh_per_day=0.0,
|
||||
rejected_factor_f3_per_litre=0.00014,
|
||||
profile_pair="M+L",
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
assert override is not None
|
||||
for month_idx, (got, want) in enumerate(zip(override, expected)):
|
||||
assert got == pytest.approx(want, abs=1e-12), (
|
||||
f"month {month_idx}: got {got!r}, want {want!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_pcdb_combi_loss_override_routes_separate_dhw_tests_3_through_table_3c_m_plus_s() -> None:
|
||||
"""PCDF Spec Rev 6b field 48 = 3 encodes EN 13203-2 / OPS 26 testing
|
||||
under schedules 2 and 1 = profiles M and S. The override gate must
|
||||
route those PCDB records through Table 3c with the M+S DVF branch.
|
||||
"""
|
||||
# Arrange — PCDB 16952 (Fondital Itaca KC 24) is one of the three
|
||||
# boilers in pcdb10.dat with separate_dhw_tests=3. Borrow 000477's
|
||||
# monthly inputs purely as a deterministic vehicle for the formula —
|
||||
# there is no Elmhurst fixture lodging this PCDB record.
|
||||
pcdb = gas_oil_boiler_record(16952)
|
||||
assert pcdb is not None
|
||||
assert pcdb.separate_dhw_tests == 3
|
||||
energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH
|
||||
daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L
|
||||
|
||||
# Act
|
||||
override = pcdb_combi_loss_override(
|
||||
pcdb,
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
|
||||
# Assert — override matches Table 3c with profile_pair="M+S".
|
||||
assert pcdb.rejected_energy_proportion_r1 is not None
|
||||
assert pcdb.loss_factor_f2_kwh_per_day is not None
|
||||
assert pcdb.rejected_factor_f3_per_litre is not None
|
||||
expected = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
|
||||
rejected_energy_proportion_r1=pcdb.rejected_energy_proportion_r1,
|
||||
loss_factor_f2_kwh_per_day=pcdb.loss_factor_f2_kwh_per_day,
|
||||
rejected_factor_f3_per_litre=pcdb.rejected_factor_f3_per_litre,
|
||||
profile_pair="M+S",
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
assert override is not None
|
||||
for month_idx, (got, want) in enumerate(zip(override, expected)):
|
||||
assert got == pytest.approx(want, abs=1e-12), (
|
||||
f"month {month_idx}: got {got!r}, want {want!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_pcdb_combi_loss_override_preserves_separate_dhw_tests_1_routing_to_table_3b() -> None:
|
||||
"""Regression guard: separate_dhw_tests=1 (profile M only) must
|
||||
continue to route through Table 3b row 1 — same path that closes
|
||||
000474 to delta=0 today. The Slice 6+7 Table 3c work must not
|
||||
perturb this branch."""
|
||||
# Arrange — PCDB 16839 (Vaillant ecoTEC pro 28, used by 000474).
|
||||
pcdb = gas_oil_boiler_record(16839)
|
||||
assert pcdb is not None
|
||||
assert pcdb.separate_dhw_tests == 1
|
||||
energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH
|
||||
daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L
|
||||
|
||||
# Act
|
||||
override = pcdb_combi_loss_override(
|
||||
pcdb,
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
|
||||
# Assert — override matches Table 3b row 1.
|
||||
assert pcdb.rejected_energy_proportion_r1 is not None
|
||||
assert pcdb.loss_factor_f1_kwh_per_day is not None
|
||||
expected = combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
|
||||
rejected_energy_proportion_r1=pcdb.rejected_energy_proportion_r1,
|
||||
loss_factor_f1_kwh_per_day=pcdb.loss_factor_f1_kwh_per_day,
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
assert override is not None
|
||||
for month_idx, (got, want) in enumerate(zip(override, expected)):
|
||||
assert got == pytest.approx(want, abs=1e-12), (
|
||||
f"month {month_idx}: got {got!r}, want {want!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis() -> None:
|
||||
"""The override gate returns None — letting the worksheet fall back
|
||||
to Table 3a — whenever the PCDB record is missing test data (field
|
||||
48 ∈ {0, None}), lodges insufficient lab factors, or sits in a
|
||||
storage / FGHRS row (Table 3b/3c rows 2-5, deferred until a fixture
|
||||
exercises them)."""
|
||||
# Arrange — a minimal record skeleton, mutated per scenario via
|
||||
# dataclasses.replace.
|
||||
from dataclasses import replace
|
||||
|
||||
energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH
|
||||
daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L
|
||||
base = GasOilBoilerRecord(
|
||||
pcdb_id=99999,
|
||||
brand_name="X",
|
||||
model_name="Y",
|
||||
model_qualifier="",
|
||||
winter_efficiency_pct=88.0,
|
||||
summer_efficiency_pct=80.0,
|
||||
comparative_hot_water_efficiency_pct=75.0,
|
||||
output_kw_max=24.0,
|
||||
final_year_of_manufacture=None,
|
||||
subsidiary_type=0,
|
||||
store_type=0,
|
||||
separate_dhw_tests=2,
|
||||
rejected_energy_proportion_r1=0.015,
|
||||
loss_factor_f1_kwh_per_day=0.5,
|
||||
loss_factor_f2_kwh_per_day=0.001,
|
||||
rejected_factor_f3_per_litre=0.00014,
|
||||
raw=(),
|
||||
)
|
||||
|
||||
# Act / Assert — None record → None.
|
||||
assert (
|
||||
pcdb_combi_loss_override(
|
||||
None,
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# separate_dhw_tests=0 → None (no PCDB test data).
|
||||
assert (
|
||||
pcdb_combi_loss_override(
|
||||
replace(base, separate_dhw_tests=0),
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# Integral FGHRS (subsidiary_type=1) → row 2/3 deferred → None.
|
||||
assert (
|
||||
pcdb_combi_loss_override(
|
||||
replace(base, subsidiary_type=1),
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# Storage combi (store_type=1 primary store) → row 4/5 deferred → None.
|
||||
assert (
|
||||
pcdb_combi_loss_override(
|
||||
replace(base, store_type=1),
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# separate_dhw_tests=2 with F2 missing → insufficient lab data → None.
|
||||
assert (
|
||||
pcdb_combi_loss_override(
|
||||
replace(base, loss_factor_f2_kwh_per_day=None),
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
daily_hot_water_monthly_l_per_day=daily_hw,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ def combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
|
|||
|
||||
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`).
|
||||
coverage of Table 3b (see `pcdb_combi_loss_override`).
|
||||
"""
|
||||
return tuple(
|
||||
e
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue