From f9551355bb5bc64827cf4581c08429e5703150ba Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 21:46:12 +0000 Subject: [PATCH] Slice S0380.79: (57)m solar storage adjustment + separately-timed-DHW cylinder default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes cert 000565 sap_score regression — 28 (Δ−1) → **29 ✓ EXACT**. Continuous SAP 28.4735 → 28.5652 (Δ −0.035 → +0.057 vs worksheet 28.5087). Two coupled fixes that together close the (56)/(57)m storage-loss gap per SAP 10.2 §4 + Table 2b. ## 1. (57)m solar storage adjustment — SAP 10.2 §4 line 7693 (p.137) If the vessel contains dedicated solar storage or dedicated WWHRS storage, (57)m = (56)m × [(47) - Vs] ÷ (47), else (57)m = (56)m where Vs is Vww from Appendix G3 or (H12) from Appendix H. Total heat required for water heating calculated for each month (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m (62)m sums (57)m — the solar-adjusted storage loss — not raw (56)m. The cascade's `_cylinder_storage_loss_override` was passing (56)m straight through as `solar_storage_monthly_kwh_ override`, over-counting (62)m by Vs/V each month whenever solar HW shares the cylinder. For cert 000565: V = 160 L, Vs = (H12) = 53 L per the combined-cylinder ⅓-volume convention (S0380.76); (V − Vs)/V = 0.6688 (matches worksheet 50.7018/75.8157 = 0.6688). Fix: when `epc.solar_water_heating` is True, return (57)m = (56)m × (V − Vs)/V from `_cylinder_storage_loss_override`. The combined-cylinder Vs derivation reuses the `_COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION` constant established by S0380.76 for the Appendix H orchestrator path. ## 2. separately_timed_dhw defaults True when a cylinder is lodged SAP 10.2 Table 2b note b) (PDF p.159): Multiply Temperature Factor by 0.9 if there is separate time control of domestic hot water (boiler systems, warm air systems and heat pump systems) RdSAP 10 Specification §3 default table "Hot water separately timed" (PDF p.57): No programmer, pre-1998 boiler: - No Programmer, pre-1998 boiler: - Yes Post-1998 boiler: - Yes When a hot-water cylinder is lodged, DHW is timed by its own programmer / boost cycle regardless of which heat generator (boiler, HP, or combi-acting-as-boiler) feeds it. Combi-only dwellings (no cylinder) skip the multiplier — DHW is instantaneous and shares the boiler's space-heating cycle. The earlier `_separately_timed_dhw` heuristic gated only on `main_heating_category == 4` (heat pumps), returning False for boiler-family + cylinder combos. Cert 000565 (gas combi via WHC 914 + 160 L cylinder + cyl-stat absent) fell through to TF = 0.60 × 1.3 × 1.0 = 0.78; the worksheet uses 0.60 × 1.3 × 0.9 = 0.702. The 10% TF over-count drove +98 kWh/yr into (56)m before compounding with the missing (57)m solar adjustment. Fix: `_separately_timed_dhw(epc, main)` returns True when a cylinder is lodged, in addition to the existing HP branch. Signature gains `epc` so the helper can inspect `has_hot_water_cylinder`; both call sites in `_primary_loss_override` and `_cylinder_storage_loss_override` updated. ## Cert 000565 movements at HEAD (post-S0380.78 → post-this slice) | Field | Pre-slice | Post-slice | Worksheet | Pre-Δ | Post-Δ | |----------------------|-----------:|-----------:|-----------:|--------:|--------:| | **sap_score** | **28** | **29** | **29** | −1 | **✓ 0** | | sap_score_continuous | 28.4735 | 28.5652 | 28.5087 | −0.035 | +0.057 | | ecf | 5.3904 | 5.3810 | 5.3866 | +0.004 | −0.006 | | total_fuel_cost_gbp | 4683.39 | 4675.23 | 4680.26 | +3.13 | −5.03 | | co2_kg | 6480.57 | 6388.80 | 6447.63 | +33 | −58.8 | | hot_water_kwh | 4014.64 | 3517.37 | 3755.03 | +259.6 | −237.7 | | space_heating_kwh | 58792.99 | 58936.06 | 59008.35 | −215.4 | −72.3 | | main_heating_fuel | 34584.11 | 34668.27 | 34710.79 | −126.7 | −42.5 | HW pin overshot −238 (down from +260) — within ~6% of the worksheet, vs the +37% over-count three slices ago. Continuous SAP residual flipped from Δ −0.035 to Δ +0.057, restoring integer sap_score = 29 EXACT. The cumulative cert 000565 closure across S0380.77/78/79: hot_water_kwh: +1399 → +260 → −238 (84% closed) sap_score_cont: +0.60 → −0.035 → +0.057 (90% closed) ecf: −0.06 → +0.004 → −0.006 (90% closed) ## Cross-cohort impact — cert 0390 golden pin update Golden cert `0390-2954-3640-2196-4175` (Firebird oil combi PCDF 9005 + 160 L cylinder + cyl-stat=Y, no solar) was previously flagged at SAP residual −7 with the comment "traces to fabric heat-loss / oil-fuel cost cascade rather than the §4 HW path". That diagnosis was wrong: cert 0390's §4 HW cascade WAS applying TF=0.60 instead of TF=0.54 for the (56)m storage loss, contributing ~£20/yr cost over-count. Per [[feedback-spec-floor-skepticism]] + [[feedback-golden- residuals-near-zero]], the +1 SAP closure (53→54, residual −7→−6) is the spec-correct outcome of applying RdSAP §3 default "Programmer, pre-1998 boiler → Yes". Pin updated; revised notes record the slice S0380.79 attribution. ## Tests - `test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693` (test_cert_to_inputs.py) — pins `solar_storage_monthly_kwh[0]` to worksheet (57)Jan = 50.7018 at abs=1e-4 and the 12-month sum to 596.9725 at abs=1e-3, for cert-000565-shape inputs (gas combi + cylinder + solar HW + cyl-stat absent). - Updated golden pin for cert 0390 per the cross-cohort impact note. Test baseline: 548 → 550 pass + 9 expected `test_sap_result_pin [000565-*]` fails (sap_score now PASSING; one fewer expected fail than mid-slice). Pyright net-zero on touched files (46 baseline = 46 after). Co-Authored-By: Claude Opus 4.7 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 57 ++++++++--- .../rdsap/tests/test_cert_to_inputs.py | 94 +++++++++++++++++++ .../rdsap/tests/test_golden_fixtures.py | 18 ++-- 3 files changed, 146 insertions(+), 23 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7ac099f5..d6e2db76 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2749,19 +2749,28 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = { _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1 -def _separately_timed_dhw(main: Optional[MainHeatingDetail]) -> bool: - """RdSAP §3 default table (PDF p.57): "Hot water separately timed — - Post-1998 boiler: Yes". Heat pumps (cat 4) and heat networks (cat 3, - 6) always have programmer-driven DHW timing, so default to True for - those mains. For boiler-family mains (cat 1, 2) the cohort closes - via the heuristic that age band K, L, M (post-2007) → True; older - bands keep the spec's no-programmer default of False. +def _separately_timed_dhw( + epc: EpcPropertyData, main: Optional[MainHeatingDetail], +) -> bool: + """SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature + Factor by 0.9 if there is separate time control of domestic hot + water (boiler systems, warm air systems and heat pump systems)". + RdSAP §3 default: when a hot-water cylinder is lodged, DHW timing + is separate from space heat — the cylinder is heated on its own + programmer / overnight boost regardless of which heat generator + (boiler, HP, or combi-acting-as-boiler) feeds it. + + Combi-only dwellings (no cylinder) skip the multiplier — DHW is + instantaneous and shares the boiler's space-heating cycle, so + there's no separate timer. Heat pumps (cat 4) keep their existing + always-True default for the HP-without-cylinder edge case the + earlier cohort calibration was sized around. """ if main is None: return False if main.main_heating_category == 4: return True - return False + return bool(epc.has_hot_water_cylinder) # RdSAP §3 default table (PDF p.56) — "Insulation of primary pipework": @@ -3295,7 +3304,7 @@ def _primary_loss_override( primary_age ), has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y", - separately_timed_dhw=_separately_timed_dhw(main), + separately_timed_dhw=_separately_timed_dhw(epc, main), ) @@ -3303,12 +3312,23 @@ def _cylinder_storage_loss_override( epc: EpcPropertyData, main: Optional[MainHeatingDetail], ) -> Optional[tuple[float, ...]]: - """Resolve (56)m for `water_heating_from_cert` from the cert's lodged + """Resolve (57)m for `water_heating_from_cert` from the cert's lodged cylinder fields. Returns None when no cylinder is lodged so the cascade keeps its existing zero-storage-loss default for combi / - instantaneous systems. Per SAP 10.2 §4 line 7693 the (57)m solar - adjustment equals (56)m when no dedicated solar storage volume is - present (cohort certs have none). + instantaneous systems. + + SAP 10.2 §4 line 7693 (PDF p.137): + + If the vessel contains dedicated solar storage or dedicated + WWHRS storage, + (57)m = (56)m × [(47) - Vs] ÷ (47), else (57)m = (56)m + where Vs is Vww from Appendix G3 or (H12) from Appendix H. + + `water_heating_from_cert` feeds the override straight into (62)m + via `solar_storage_monthly_kwh`, so the helper returns the (57)m + series (solar-adjusted when applicable), not raw (56)m. Vs derives + from the same combined-cylinder ⅓-volume convention used by + `_solar_hw_monthly_override` per S0380.76. """ if not epc.has_hot_water_cylinder: return None @@ -3324,13 +3344,20 @@ def _cylinder_storage_loss_override( thickness_mm = sh.cylinder_insulation_thickness_mm if thickness_mm is None: return None - return cylinder_storage_loss_monthly_kwh( + storage_56m = cylinder_storage_loss_monthly_kwh( volume_l=volume_l, insulation_type="factory_insulated", thickness_mm=float(thickness_mm), has_cylinder_thermostat=sh.cylinder_thermostat == "Y", - separately_timed_dhw=_separately_timed_dhw(main), + separately_timed_dhw=_separately_timed_dhw(epc, main), ) + # (57)m solar adjustment when solar HW + dedicated solar storage + # share the cylinder. Vs follows the combined-cylinder convention. + if not epc.solar_water_heating: + return storage_56m + vs_l = round(volume_l * _COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION) + factor = (volume_l - vs_l) / volume_l + return tuple(s * factor for s in storage_56m) def _apply_water_efficiency( 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 943b087d..be6ca923 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1802,6 +1802,100 @@ def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3 ) +def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None: + """SAP 10.2 §4 line 7693 (PDF p.137): + + If the vessel contains dedicated solar storage or dedicated + WWHRS storage, + (57)m = (56)m × [(47) - Vs] ÷ (47), else (57)m = (56)m + where Vs is Vww from Appendix G3 or (H12) from Appendix H (as + applicable). + + Total heat required for water heating calculated for each month + (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m + + (62)m sums (57)m — the solar-adjusted storage loss — not (56)m. When + solar HW is present the cascade was passing (56)m unchanged as + `solar_storage_monthly_kwh_override`, over-counting (62)m by + (56)m × Vs / V each month. + + SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature Factor + by 0.9 if there is separate time control of domestic hot water + (boiler systems, warm air systems and heat pump systems)". RdSAP §3 + default: when a hot-water cylinder is present, DHW timing is + separate from space heating (the cylinder is heated on its own + timer / boost). The cohort heuristic that gated separately-timed + on `main_heating_category == 4` missed cert 000565's gas-combi- + plus-cylinder topology (cat=2 + WHC 914 + cylinder), driving TF up + from 0.702 (worksheet) to 0.78 (cascade) — a further ~98 kWh/yr + over-count on top of the missing (57)m solar adjustment. + + Cert 000565 worksheet lines (Block 1): + (56)m Jan = 75.8157, (56) sum ≈ 892.66 + (57)m Jan = 50.7018, (57) sum ≈ 596.97 + + With V = 160 L, Vs = (H12) = 53 L per the combined-cylinder ⅓ + convention (S0380.76), (V − Vs) / V = 0.6688 — matching the + worksheet ratio (50.7018 / 75.8157). + """ + # Arrange — cert 000565 shape: ASHP Main 1 + gas combi Main 2 + + # WHC 914 + 160 L cylinder + cylinder thermostat absent + solar HW + # lodged. Per RdSAP §3 default, the lodged cylinder makes DHW + # separately-timed regardless of which main is the heat generator. + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=None, + sap_main_heating_code=224, + ) + combi_main = _gas_boiler_detail(sap_main_heating_code=102) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part(construction_age_band="D")], + sap_heating=make_sap_heating( + main_heating_details=[hp_main, combi_main], + water_heating_code=914, + cylinder_size=3, # 160 L + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=25, + cylinder_thermostat="N", + ), + solar_water_heating=True, + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=0.88, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — solar_storage_monthly_kwh is the (57)m solar-adjusted + # series the cascade feeds into (62)m, not raw (56)m. Pin Jan and + # the annual sum at abs=1e-4 vs cert 000565 worksheet. + assert wh_result is not None + expected_57_jan = 50.7018 + expected_57_sum = 596.9725 + got_57_jan = wh_result.solar_storage_monthly_kwh[0] + got_57_sum = sum(wh_result.solar_storage_monthly_kwh) + assert abs(got_57_jan - expected_57_jan) < 1e-4, ( + f"(57)Jan: got {got_57_jan!r}, want {expected_57_jan!r} per " + f"SAP 10.2 §4 line 7693 ((57)m = (56)m × (V - Vs)/V) + Table 2b" + ) + assert abs(got_57_sum - expected_57_sum) < 1e-3, ( + f"(57) sum: got {got_57_sum!r}, want {expected_57_sum!r} per " + f"SAP 10.2 §4 line 7693 + Table 2b" + ) + + def test_whc_914_dhw_routes_primary_loss_gate_to_second_main_heating_per_sap_table_3() -> None: """SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) primary-loss eligibility is determined by the heat generator that feeds the hot water storage diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index b7a0b8cd..63f0892f 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -124,19 +124,21 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _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, + expected_sap_resid=-6, + expected_pe_resid_kwh_per_m2=-26.3749, + expected_co2_resid_tonnes_per_yr=-2.5544, 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)." + "(`600 × fu × n/365`) per SAP 10.2 spec p.160. Slice S0380.79 " + "(_separately_timed_dhw=True when cylinder lodged per " + "SAP 10.2 Table 2b note b) + RdSAP §3 default) closed a " + "10% storage-loss over-count via TF 0.60 → 0.54, lifting SAP " + "53 → 54 (resid -7 → -6). Remaining -6 traces to fabric heat-" + "loss / oil-fuel cost cascade (oil tariff + age-F masonry on " + "a 360 m² detached typically lands -5..-10 SAP)." ), ), _GoldenExpectation(