mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
f14f76daf8
commit
afdf297f3b
2 changed files with 189 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue