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(