diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9e81ae1b..7ac099f5 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3216,8 +3216,11 @@ def _water_heating_worksheet_and_gains( storage_loss_override = _cylinder_storage_loss_override(epc, main) # SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss # (59)m. Only fires for indirect cylinders; HPs with integral - # vessels and combi boilers are in the spec's zero list. - primary_loss_override = _primary_loss_override(epc, main, primary_age) + # vessels and combi boilers are in the spec's zero list. The gate + # keys off the *DHW* main (`_water_heating_main`) so WHC 914 ("from + # second main system") routes the primary-loss eligibility check + # to the heat generator that actually feeds the cylinder. + primary_loss_override = _primary_loss_override(epc, primary_age) # SAP 10.2 Appendix H — solar HW contribution (63c)m. Only fires # when the cert lodges solar HW; orchestrator drives off lodged # collector geometry + RdSAP 10 §10.11 Table 29 defaults for @@ -3260,7 +3263,6 @@ def _water_heating_worksheet_and_gains( def _primary_loss_override( epc: EpcPropertyData, - main: Optional[MainHeatingDetail], primary_age: Optional[str], ) -> Optional[tuple[float, ...]]: """Resolve (59)m for `water_heating_from_cert` from the cert + PCDB @@ -3270,7 +3272,18 @@ def _primary_loss_override( comes from RdSAP §3 age-band default (no API field); circulation hours h come from Table 3 keyed on cylinder thermostat + separately- timed-DHW lodgement. + + The gate keys off the DHW main resolved via `_water_heating_main` + (the WHC-914 "from second main system" routing) rather than + `_first_main_heating`. SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) + define primary loss as the loss between the *heat generator that + heats the water* and the storage vessel — so the eligibility check + must follow the DHW routing. Cert 000565 (ASHP Main 1 + gas combi + Main 2 + WHC 914 + 160 L cylinder) is the cohort case: Main 1's + HP record is irrelevant; Main 2's combi feeds the cylinder via + primary pipework and incurs the loss. """ + main = _water_heating_main(epc) cylinder_present = bool(epc.has_hot_water_cylinder) hp_record: Optional[HeatPumpRecord] = None if main is not None and main.main_heating_index_number is not 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 2d9cc2cf..943b087d 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,83 @@ def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3 ) +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 + vessel, not by the space-heating main. Cert 000565 lodges Main 1 = ASHP + (SAP 224) + Main 2 = gas combi (PCDB 15100) + water_heating_code 914 + ("from second main system") + 160 L combined cylinder + cylinder + thermostat absent ("N"). The cylinder is heated by Main 2 via uninsulated + primary pipework, so the Table 3 formula applies with p=0 and the + "no cylinder thermostat" hours (h=11 winter, h=3 summer). + + Worksheet line (59)m for U985-0001-000565 (Block 1) reads + 128.3772, 115.9536, 128.3772, 124.2360, 128.3772, 41.9160, + 43.3132, 43.3132, 41.9160, 128.3772, 124.2360, 128.3772 kWh + summing to 1174.79 kWh/yr. The cascade must route the primary-loss gate + to `_water_heating_main` (the WHC-914-resolved DHW main) — gating on + `_first_main_heating` mis-keys the gate to Main 1's HP record (or + absent record) and zeroes (59)m even though the gas combi → external + cylinder pipework physically incurs the loss. + """ + # Arrange — synthesise cert 000565's heating shape: ASHP Main 1 + + # gas-combi Main 2 servicing DHW via WHC 914 + cylinder lodged with + # no cylinder thermostat. The Elmhurst mapper produces + # `main_heating_category=None` on Main 1 when the cert lodges a SAP + # code without a PCDB Table 362 reference (see + # `_elmhurst_main_heating_category` TODO docstring) — mirror that + # shape here so the current `_first_main_heating` routing fails the + # gate even though Main 2 (the DHW main) plainly carries the loss. + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + 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, # DHW from second main system + cylinder_size=3, # Medium = 160 L + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=50, + cylinder_thermostat="N", # no cylinder thermostat → h_winter=11 + ), + ) + + # 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 — full 12-tuple matches cert 000565 worksheet (59)m at 1e-4. + assert wh_result is not None + expected_59m = ( + 128.3772, 115.9536, 128.3772, 124.2360, 128.3772, 41.9160, + 43.3132, 43.3132, 41.9160, 128.3772, 124.2360, 128.3772, + ) + got_59m = wh_result.primary_loss_monthly_kwh + for month_idx, (got, want) in enumerate(zip(got_59m, expected_59m)): + assert abs(got - want) < 1e-4, ( + f"(59)m month {month_idx + 1}: got {got!r}, want {want!r} per " + f"SAP 10.2 §4 line 7700 + Table 3 (uninsulated p=0, no " + f"cylinder thermostat h=11/3); cert 000565 worksheet line (59)" + ) + + def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None: """SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for each month from Table 3a, 3b or 3c (enter "0" if not a combi