diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 1121b30e..e14702e5 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 57b22d10..a5fce393 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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, ) diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index b9ce4886..02988ba3 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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 ) diff --git a/domain/sap10_calculator/worksheet/tests/test_water_heating.py b/domain/sap10_calculator/worksheet/tests/test_water_heating.py index de31188f..bc3cdcdf 100644 --- a/domain/sap10_calculator/worksheet/tests/test_water_heating.py +++ b/domain/sap10_calculator/worksheet/tests/test_water_heating.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index d183cd2c..aec76b2d 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -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.