diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 8a97cae0..94d5dd44 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1021,6 +1021,27 @@ def _heat_network_dlf(age_band: Optional[str]) -> float: raise UnmappedSapCode("heat_network_age_band", age_band) +def _dwelling_age_band(epc: EpcPropertyData) -> Optional[str]: + """The dwelling's construction age band, read from the first building + part that lodges one. + + The GOV.UK API can lodge a junk empty leading building part (all + fields absent) ahead of the real Main Dwelling — reading + `sap_building_parts[0]` then yields None and silently drops the age + band (e.g. defaulting the heat-network DLF to the K-or-newer 1.50 + instead of the dwelling's true band). A no-op for normal certs, where + `[0]` is the Main part and already carries a valid band. + """ + return next( + ( + bp.construction_age_band + for bp in epc.sap_building_parts + if bp.construction_age_band + ), + None, + ) + + # SAP 10.2 Table 12 fuel code 50 — "electricity for pumping in # distribution network". Its CO2 / PE factors vary by month per Table # 12d / 12e (= standard-electricity profile); worksheet (372)/(472) @@ -1751,10 +1772,7 @@ def _main_heating_detail_efficiency( else: eff = seasonal_efficiency(main_code, main_category, main_fuel) if _is_heat_network_main(main): - primary_age = ( - epc.sap_building_parts[0].construction_age_band - if epc.sap_building_parts else None - ) + primary_age = _dwelling_age_band(epc) eff = 1.0 / _heat_network_dlf(primary_age) return eff @@ -4726,10 +4744,7 @@ def ventilation_from_cert( # lodged count is below the age-band minimum. The Elmhurst Summary # renders "0" as the form for unknown; the worksheet applies the # default via `max(lodged, table_5_default)`. - age_band = ( - epc.sap_building_parts[0].construction_age_band - if epc.sap_building_parts else "" - ) + age_band = _dwelling_age_band(epc) or "" is_park_home = (epc.property_type or "").strip().lower() == "park home" table_5_fan_default = _rdsap_extract_fans_default( age_band, epc.habitable_rooms_count, is_park_home=is_park_home, @@ -5116,10 +5131,7 @@ def _apply_rdsap_no_water_heating_system_default( """ if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM: return epc - age_band = ( - epc.sap_building_parts[0].construction_age_band - if epc.sap_building_parts else None - ) + age_band = _dwelling_age_band(epc) band = (age_band or "")[:1].upper() default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band) if default is None: @@ -6642,9 +6654,7 @@ def cert_to_inputs( # 10-hour) instead of `ALL_OTHER_USES` (0.80) — see # `_pumps_fans_fuel_cost_gbp_per_kwh`. Zero when no MEV is lodged. mev_kwh_for_cost_split = _mev_decentralised_kwh_per_yr_from_cert(epc) - primary_age = ( - epc.sap_building_parts[0].construction_age_band if epc.sap_building_parts else None - ) + primary_age = _dwelling_age_band(epc) # SAP 10.2 Appendix D2.1: if the cert lodges a PCDB index number that # resolves to a Table 105 (gas/oil boilers) record, the PCDB winter diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index a0a777d0..e528ec42 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -234,6 +234,40 @@ def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005) +def test_heat_network_dlf_uses_first_non_empty_building_part_age_band() -> None: + # Arrange — the GOV.UK API lodges a junk empty leading building part + # (all fields absent) before the real Main Dwelling. The dwelling age + # band (here A, 1.20 DLF per SAP 10.2 Table 12c) must be read from the + # first NON-empty part, not `sap_building_parts[0]` — otherwise the + # heat-network DLF defaults to the K-or-newer 1.50, inflating the + # distribution loss by 30%. Reproduces cert 8536-0929-6500-0815-7206. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + empty_leading_part = make_building_part(construction_age_band="") + main_dwelling_part = make_building_part(construction_age_band="A") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[empty_leading_part, main_dwelling_part], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — age band A → Table 12c DLF = 1.20 → efficiency = 1/1.20, + # NOT the empty-band default DLF 1.50 (1/1.50 = 0.667). + assert abs(inputs.main_heating_efficiency - 1.0 / 1.20) <= 1e-9 + + def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: # Arrange — heat-network main (Table 4a code 301 = community heating, # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution