fix(heat-network): derive dwelling age band from first non-empty building part

The GOV.UK API lodges a junk empty leading building part (all fields
None) ahead of the real Main Dwelling on some certs. Four sites in
cert_to_inputs.py read `sap_building_parts[0].construction_age_band` →
got None → silently dropped the dwelling age band. New `_dwelling_age_band`
helper takes the first part that lodges a band (a no-op for normal certs
where [0] is the Main part).

Closes two age-band-keyed defects on the 5 affected certs:

- SAP 10.2 Table 12c (p.193): the heat-network Distribution Loss Factor
  defaulted to the K-or-newer 1.50 instead of the dwelling's true band
  (cert 8536-0929-6500-0815-7206 is age A → 1.20), inflating distribution
  loss by 30%.
- RdSAP 10 §4.1 Table 5 (p.28): the empty band ("") fell through the
  age-band branches to the H–M habitable-rooms branch, defaulting in
  phantom extract fans. The true band A correctly yields 0 fans
  (bands A–E → 0).

Cert 8536: 31.76 → 41.12 vs lodged 39 (was −7.24, now +2.12). API eval
mean|err| 1.197 → 1.192, signed −0.229 → −0.218; headline within-0.5
holds at 56.8% (8536 lands at +2.1, a documented overshoot vs the
faithful case-31 worksheet — separate slice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 18:43:17 +00:00
parent e89b4041c7
commit ba56647401
2 changed files with 59 additions and 15 deletions

View file

@ -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

View file

@ -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