slice S-B31: Table 12c DLF on heat-network main and HW-from-main

Heat-network certs (cat=6) were under-predicted in cost — SAP bias
+6.31 across 13 sample certs, PE bias -15.6 (we under-predicted PE).
Root cause: missing distribution-loss-factor application.

SAP 10.2 spec references:
  - Table 12 note (k): "Cost is per unit of heat generated (i.e.
    before distribution losses); emission and primary factors are per
    unit of fuel used by the heat generator."
  - §C3.1: "Where a heat network is listed in the PCDB, the DLF is
    already factored into the cost, CO2 and PE factors recorded
    therein, so a DLF of 1 should be entered in worksheet (306) to
    avoid double counting." (Implication: non-PCDB networks MUST
    apply DLF.)
  - Table 12c (p. 193): DLF by age band, 1.20 (A pre-1900) →
    1.50 (K+ 2007+).
  - RdSAP 10 §10.11 Table 29 cross-references Table 12c.

Mechanism: setting main_heating_efficiency = 1/DLF (and water_eff
when HW inherits from main via codes 901/902/914) makes the
calculator's main_fuel_kwh = q_useful × DLF = q_generated, which
multiplied by the per-kWh-generated unit price gives the cost the
spec mandates.

Affects:
  - Heat-network main heating (sap_main_heating_code in 301-304 OR
    main_heating_category == 6)
  - HW from main on such certs (water_heating_code in 901/902/914)

Trade-off: CO2/PE for heat-network certs will under-predict ~20%
versus the spec's "fuel-burned × per-fuel-factor" formula, because
our architecture uses one main_fuel_kwh value for cost AND CO2/PE.
For SAP-rating purposes (the priority) this is acceptable; the PE
bias actually moves in the right direction here (cat=6 PE bias
-15.6 → -5.6) because the under-counting partially cancels a
pre-existing larger under-count.

Parity probe at 300 certs, seed=7:
  SAP MAE 4.69 → 4.61 (-0.08)
  SAP bias 0.98 → 0.87 (-0.11)
  PE  MAE 43.32 → 43.11 (-0.21)
  cat=6 PE bias -15.6 → -5.6  (+10.0, correct direction)
  cat=6 PE MAE  40.3 → 35.8   (-4.5)
  cat=6 our_pe  158.5 → 225.0 (cert 230.6 — converged)

Cumulative across S-B23 → S-B31:
  SAP MAE  5.34 → 4.61 (-0.73)
  PE  MAE 57.28 → 43.11 (-14.17)
  PE bias 51.56 → 38.64 (-12.92)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 22:59:36 +00:00
parent f14f76daf8
commit afdf297f3b
2 changed files with 189 additions and 0 deletions

View file

@ -185,6 +185,51 @@ _FORCE_SECONDARY_FOR_MAIN_CODES: Final[frozenset[int]] = frozenset(
_PV_EXPORT_TARIFF_CODE: Final[int] = 60
# SAP 10.2 Table 12c (page 193) — Distribution Loss Factor for heat
# networks by dwelling age band, used when no PCDB record is available
# (the modal RdSAP case). Per §C3.1: "Where a heat network is listed
# in the PCDB, the DLF is already factored into the cost, CO2 and PE
# factors recorded therein, so a DLF of 1 should be entered in
# worksheet (306) to avoid double counting." For non-PCDB networks
# (our case), DLF must be applied. K-or-newer (post-2007) = 1.50.
_HEAT_NETWORK_DLF_BY_AGE: Final[dict[str, float]] = {
"A": 1.20, "B": 1.26, "C": 1.33, "D": 1.37, "E": 1.41, "F": 1.43,
"G": 1.45, "H": 1.46, "I": 1.48, "J": 1.49, "K": 1.50, "L": 1.50,
"M": 1.50,
}
_HEAT_NETWORK_DLF_DEFAULT: Final[float] = 1.50
# SAP 10.2 Table 4a codes for heat-network main heating systems:
# 301 = boiler-driven community heating
# 302 = boiler-driven community heating with CHP
# 303 = community CHP only
# 304 = electric heat-pump community heating
_HEAT_NETWORK_MAIN_CODES: Final[frozenset[int]] = frozenset({301, 302, 303, 304})
_HEAT_NETWORK_CATEGORY: Final[int] = 6
def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool:
"""True when the cert's main heating is a heat network — either by
SAP code (Table 4a 301-304) or by `main_heating_category` (6)."""
if main is None:
return False
code = main.sap_main_heating_code
if isinstance(code, int) and code in _HEAT_NETWORK_MAIN_CODES:
return True
return main.main_heating_category == _HEAT_NETWORK_CATEGORY
def _heat_network_dlf(age_band: Optional[str]) -> float:
"""RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by
age band. Defaults to the K-or-newer value (1.50) when band missing."""
if age_band is None:
return _HEAT_NETWORK_DLF_DEFAULT
return _HEAT_NETWORK_DLF_BY_AGE.get(
age_band.upper(), _HEAT_NETWORK_DLF_DEFAULT
)
@dataclass(frozen=True)
class PriceTable:
"""Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and
@ -749,12 +794,29 @@ def cert_to_inputs(
)
eff = seasonal_efficiency(main_code, main_category, main_fuel)
if _is_heat_network_main(main):
# SAP 10.2 Table 12 note (k): heat-network unit prices are per
# kWh of heat GENERATED (before distribution losses), not per
# kWh of fuel consumed. Setting efficiency = 1/DLF makes the
# calculator's `main_fuel_kwh = q_useful / (1/DLF) = q_useful
# × DLF = q_generated`, so cost = q_generated × unit_price as
# the spec requires.
eff = 1.0 / _heat_network_dlf(primary_age)
water_eff = _water_efficiency_with_category_inherit(
water_heating_code=epc.sap_heating.water_heating_code,
main_code=main_code,
main_category=main_category,
main_fuel=main_fuel,
)
if (
_is_heat_network_main(main)
and epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
):
# HW from main on a heat-network cert: the DHW also incurs the
# network's distribution losses. Same 1/DLF override as for
# space heating so the delivered HW kWh reflects q_useful × DLF
# = q_generated, matching the per-kWh-generated unit price.
water_eff = 1.0 / _heat_network_dlf(primary_age)
is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES
hw_kwh = predicted_hot_water_kwh(
total_floor_area_m2=epc.total_floor_area_m2,

View file

@ -80,6 +80,133 @@ def _typical_semi_detached_epc():
)
def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> None:
# Arrange — heat-network main heating (Table 4a code 301 = community
# heating with CHP/boilers; main_heating_category=6). Cert age band
# E (1967-1975) lodges Table 12c DLF = 1.41.
# Per SAP 10.2 §C3.1 + Table 12 note (k): unit price is per kWh of
# heat GENERATED (i.e. before distribution losses), so the fuel-kwh
# multiplied by the unit price must be q_generated = q_useful × DLF.
# Setting main_heating_efficiency = 1/DLF makes our calculator's
# main_fuel_kwh = q_useful × DLF, which gives the correct cost.
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,
)
part = make_building_part(construction_age_band="E")
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[part],
sap_heating=make_sap_heating(main_heating_details=[main]),
)
# Act
inputs = cert_to_inputs(epc)
# Assert — DLF = 1.41 for age E → effective efficiency = 1/1.41 = 0.709.
assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005)
def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None:
# Arrange — when main heating is a heat network AND water heating
# inherits from main (water_heating_code=901), the HW also incurs
# the network's distribution losses. The water efficiency must be
# overridden to 1/DLF so that the delivered HW kWh (and therefore
# cost/CO2/PE applied to it) reflects q_useful × DLF.
# Compare against a gas-boiler baseline at the same age band: the
# heat-network HW kWh should be greater by the ratio 0.80/(1/DLF) =
# DLF × 0.80 = 0.80 × 1.41 = 1.128 (i.e. ~13% higher) since the
# non-heat-network baseline inherits water efficiency 0.80 from
# the heat-network main's pre-DLF efficiency.
part = make_building_part(construction_age_band="E")
hn_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=6,
sap_main_heating_code=301,
)
hn_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[part],
sap_heating=make_sap_heating(
main_heating_details=[hn_main],
water_heating_code=901, # from main
),
)
# Comparable gas-boiler baseline that ALSO inherits a 0.80 water
# efficiency through `water_heating_code=901` for direct comparison.
# Use sap_main_heating_code = None so cascade returns 0.80 default.
gas_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2,
)
gas_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[part],
sap_heating=make_sap_heating(
main_heating_details=[gas_main],
water_heating_code=901,
),
)
# Act
hn_hw = cert_to_inputs(hn_epc).hot_water_kwh_per_yr
gas_hw = cert_to_inputs(gas_epc).hot_water_kwh_per_yr
# Assert — DLF (1.41) for age E × 0.80 baseline / (1/1.41) HN = 1.128.
assert hn_hw / gas_hw == pytest.approx(1.41 * 0.80 / 1.0, abs=0.02)
def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None:
# Arrange — regression check: the DLF override only fires for heat-
# network main heating. A standard gas boiler (cat=2, code=102) must
# still get its Table 4b winter efficiency (0.84).
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2,
sap_main_heating_code=102, # gas combi pre-2005, 0.84 eff
)
part = make_building_part(construction_age_band="E")
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[part],
sap_heating=make_sap_heating(main_heating_details=[main]),
)
# Act
inputs = cert_to_inputs(epc)
# Assert — Table 4b code 102 winter efficiency = 0.84, no DLF override.
assert inputs.main_heating_efficiency == pytest.approx(0.84, abs=0.001)
def test_main_heating_fraction_does_not_override_table11_secondary_default() -> None:
# Arrange — the S-B30 attempt assumed `main_heating_fraction=1` meant
# "no secondary heating" and dropped the Table 11 default in that