mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(heat-network): apply Table 4c(3) flat-rate charging factor to demand
SAP 10.2 Table 4c(3) (PDF p.169) "Factor for controls and charging method" multiplies a heat network's heat requirement by 1.05-1.10 for FLAT-RATE charging (note d: household pays a fixed amount regardless of heat used, so no incentive to economise), and by 1.0 for charging linked to use. The worksheet folds it into the heat-network requirement alongside the Table 12c distribution loss factor: (307) space = (98c) x (302) x (305) x (306) (310) DHW = (64) x (305a) x (306) Our cascade applied (306) DLF but never (305)/(305a), so every flat-rate community-heating cert under-counted demand -> over-rated SAP. Folded the factor into the 1/DLF efficiency override at the space-heating (206) and DHW (water-inherits-from-main) sites. Space column adds +0.05 for no thermostatic control (2301/2302); DHW column is 1.05 flat-rate / 1.0 linked-to-use. Corpus (RdSAP-21.0.1, 1000 certs): community cluster median +0.32 -> -0.19, within-0.5 38% -> 62% (control 2307 +0.83 -> -0.19; 2306 unchanged at factor 1.0 as spec requires). Overall gauge 65.0% -> 65.9%, MAE 1.174 -> 1.160. Ratcheted the corpus-test floor 0.62 -> 0.63 / MAE ceiling 1.25 -> 1.22. Also records (corpus-test comment + scripts/decompose_co2_pe_error.py) the disproof of the prior "CO2/PE +5% is a factor/scope bug" lead: factors are spec-exact, scope identical, and the bias is per-cert demand fidelity (corr(SAP-err, PE-diff) = -0.54), not a one-slice factor fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
fbe1cb54ad
commit
dfcd7af57c
4 changed files with 274 additions and 13 deletions
|
|
@ -1070,6 +1070,56 @@ def _heat_network_dlf(age_band: Optional[str]) -> float:
|
|||
raise UnmappedSapCode("heat_network_age_band", age_band)
|
||||
|
||||
|
||||
# SAP 10.2 Table 4c(3) (PDF p.169) — "Factor for controls and charging
|
||||
# method" for HEAT NETWORKS, by Table 4e control code. The factor multiplies
|
||||
# the heat-network heat requirement on top of the Table 12c distribution loss
|
||||
# factor: worksheet (307) space = (98c) × (302) × (305) × (306), and
|
||||
# (310) DHW = (64) × (305a) × (306). "Flat rate charging" (note d: the
|
||||
# household pays a fixed amount regardless of heat used) carries a demand
|
||||
# penalty — there is no incentive to economise; "charging linked to use of
|
||||
# heat" does not. The SPACE column adds a further +0.05 when there is also
|
||||
# no thermostatic room-temperature control (2301/2302).
|
||||
_HEAT_NETWORK_SPACE_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = {
|
||||
# Flat rate charging, no thermostatic room control → 1.10
|
||||
2301: 1.10, 2302: 1.10,
|
||||
# Flat rate charging, with some thermostatic control → 1.05
|
||||
2303: 1.05, 2304: 1.05, 2305: 1.05, 2307: 1.05, 2311: 1.05, 2313: 1.05,
|
||||
# Charging linked to use of heat, thermostat but no TRVs → 1.05
|
||||
2308: 1.05, 2309: 1.05,
|
||||
# Charging linked to use of heat, with TRVs → 1.00
|
||||
2306: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00,
|
||||
}
|
||||
_HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = {
|
||||
# Flat rate charging → 1.05 (all controls)
|
||||
2301: 1.05, 2302: 1.05, 2303: 1.05, 2304: 1.05,
|
||||
2305: 1.05, 2307: 1.05, 2311: 1.05, 2313: 1.05,
|
||||
# Charging linked to use of heat → 1.00
|
||||
2306: 1.00, 2308: 1.00, 2309: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00,
|
||||
}
|
||||
|
||||
|
||||
def _heat_network_space_charging_factor(main: Optional[MainHeatingDetail]) -> float:
|
||||
"""SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305) — heat-network
|
||||
space-heating factor for controls and charging method. Returns 1.0 when
|
||||
the control is absent (no penalty); every Table 4e Group-3 code
|
||||
(2301-2314) is covered, so an unmapped key only arises off the
|
||||
heat-network path and is harmless at 1.0."""
|
||||
code = main.main_heating_control if main is not None else None
|
||||
if not isinstance(code, int):
|
||||
return 1.0
|
||||
return _HEAT_NETWORK_SPACE_CHARGING_FACTOR_BY_CODE.get(code, 1.0)
|
||||
|
||||
|
||||
def _heat_network_dhw_charging_factor(main: Optional[MainHeatingDetail]) -> float:
|
||||
"""SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305a) — heat-network
|
||||
water-heating factor for charging method (flat rate → 1.05, linked to
|
||||
use → 1.0). Returns 1.0 when the control is absent."""
|
||||
code = main.main_heating_control if main is not None else None
|
||||
if not isinstance(code, int):
|
||||
return 1.0
|
||||
return _HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE.get(code, 1.0)
|
||||
|
||||
|
||||
def _dwelling_age_band(epc: EpcPropertyData) -> Optional[str]:
|
||||
"""The dwelling's construction age band, read from the first building
|
||||
part that lodges one.
|
||||
|
|
@ -1822,7 +1872,13 @@ def _main_heating_detail_efficiency(
|
|||
eff = seasonal_efficiency(main_code, main_category, main_fuel)
|
||||
if _is_heat_network_main(main):
|
||||
primary_age = _dwelling_age_band(epc)
|
||||
eff = 1.0 / _heat_network_dlf(primary_age)
|
||||
# Worksheet (307): heat required = demand × (305) × (306 DLF), so the
|
||||
# delivered-per-fuel efficiency carries 1 / ((305) charging factor ×
|
||||
# DLF). The Table 4c(3) flat-rate charging penalty raises demand.
|
||||
eff = 1.0 / (
|
||||
_heat_network_dlf(primary_age)
|
||||
* _heat_network_space_charging_factor(main)
|
||||
)
|
||||
return eff
|
||||
|
||||
|
||||
|
|
@ -7074,8 +7130,13 @@ def cert_to_inputs(
|
|||
# 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)
|
||||
# = q_generated, matching the per-kWh-generated unit price. Worksheet
|
||||
# (310): heat required = (64) × (305a) × (306 DLF), so the DHW
|
||||
# efficiency also carries 1 / Table 4c(3) (305a) charging factor.
|
||||
water_eff = 1.0 / (
|
||||
_heat_network_dlf(primary_age)
|
||||
* _heat_network_dhw_charging_factor(main)
|
||||
)
|
||||
elif epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES:
|
||||
# HW-only heat network (whc 950/951/952): the Table 4a plant
|
||||
# efficiency is already in `water_eff`; apply the Table 12c
|
||||
|
|
|
|||
124
scripts/decompose_co2_pe_error.py
Normal file
124
scripts/decompose_co2_pe_error.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Decompose the API-path CO2/PE over-estimate against lodged EPC figures.
|
||||
|
||||
The corpus integration test surfaced a systematic +5-10% over-estimate on
|
||||
CO2 and PE while cost/SAP is well-calibrated. This script profiles the
|
||||
structure of that bias: multiplicative vs additive, per-component shares,
|
||||
and segmentation by fuel / PV / heating type — to localise the cause.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics as stats
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
SAP_10_2_SPEC_PRICES,
|
||||
cert_to_inputs,
|
||||
)
|
||||
|
||||
CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
docs = [
|
||||
json.loads(line)
|
||||
for line in CORPUS.read_text().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for doc in docs:
|
||||
lodged_sap = doc.get("energy_rating_current")
|
||||
lodged_co2 = doc.get("co2_emissions_current") # t/yr
|
||||
lodged_pe = doc.get("energy_consumption_current") # kWh/m2/yr
|
||||
if lodged_pe is None or lodged_co2 is None:
|
||||
continue
|
||||
try:
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
res = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
tfa = res.intermediate["tfa_m2"]
|
||||
im = res.intermediate
|
||||
rows.append({
|
||||
"cert": doc.get("rrn") or "",
|
||||
"sap_err": res.sap_score_continuous - (lodged_sap or 0),
|
||||
"our_pe": res.primary_energy_kwh_per_m2,
|
||||
"lodged_pe": lodged_pe,
|
||||
"pe_diff": res.primary_energy_kwh_per_m2 - lodged_pe,
|
||||
"pe_ratio": res.primary_energy_kwh_per_m2 / lodged_pe if lodged_pe else 0,
|
||||
"our_co2": res.co2_kg_per_yr / 1000.0,
|
||||
"lodged_co2": lodged_co2,
|
||||
"co2_diff": res.co2_kg_per_yr / 1000.0 - lodged_co2,
|
||||
"co2_ratio": (res.co2_kg_per_yr / 1000.0) / lodged_co2 if lodged_co2 else 0,
|
||||
# PE components per m2
|
||||
"space_pe": im["space_heating_pe_kwh_per_m2"],
|
||||
"hw_pe": im["hot_water_pe_kwh_per_m2"],
|
||||
"other_pe": im["other_pe_kwh_per_m2"],
|
||||
"pv_pe": im["pv_pe_offset_kwh_per_m2"],
|
||||
"mains_gas": (doc.get("sap_energy_source") or {}).get("mains_gas"),
|
||||
"has_pv": bool((doc.get("sap_energy_source") or {}).get("photovoltaic_supply")),
|
||||
"tfa": tfa,
|
||||
})
|
||||
|
||||
n = len(rows)
|
||||
print(f"decomposed {n} certs\n" + "=" * 70)
|
||||
|
||||
def summ(key: str) -> str:
|
||||
vals = [r[key] for r in rows]
|
||||
return (f"median={stats.median(vals):+.3f} mean={stats.mean(vals):+.3f} "
|
||||
f"p10={sorted(vals)[n//10]:+.3f} p90={sorted(vals)[n*9//10]:+.3f}")
|
||||
|
||||
print(f"PE diff (kWh/m2): {summ('pe_diff')}")
|
||||
print(f"PE ratio (our/lod): {summ('pe_ratio')}")
|
||||
print(f"CO2 diff (t/yr) : {summ('co2_diff')}")
|
||||
print(f"CO2 ratio (our/lod): {summ('co2_ratio')}")
|
||||
print("=" * 70)
|
||||
|
||||
# multiplicative vs additive: correlate pe_diff with lodged_pe magnitude
|
||||
lod = [r["lodged_pe"] for r in rows]
|
||||
dif = [r["pe_diff"] for r in rows]
|
||||
mean_lod, mean_dif = stats.mean(lod), stats.mean(dif)
|
||||
cov = sum((l - mean_lod) * (d - mean_dif) for l, d in zip(lod, dif)) / n
|
||||
var = sum((l - mean_lod) ** 2 for l in lod) / n
|
||||
slope = cov / var
|
||||
print(f"PE: regress pe_diff ~ lodged_pe -> slope={slope:+.4f} intercept={mean_dif - slope*mean_lod:+.3f}")
|
||||
print(" (slope~0 => additive constant; slope>0 => multiplicative)")
|
||||
print("=" * 70)
|
||||
|
||||
# segment
|
||||
def seg(name: str, pred: Any) -> None:
|
||||
s = [r for r in rows if pred(r)]
|
||||
if not s:
|
||||
return
|
||||
print(f" {name:28s} n={len(s):4d} PEdiff_med={stats.median(r['pe_diff'] for r in s):+6.2f} "
|
||||
f"PEratio_med={stats.median(r['pe_ratio'] for r in s):.3f} "
|
||||
f"CO2diff_med={stats.median(r['co2_diff'] for r in s):+.3f} "
|
||||
f"SAPerr_med={stats.median(r['sap_err'] for r in s):+.2f}")
|
||||
|
||||
print("SEGMENTS:")
|
||||
seg("mains_gas=Y", lambda r: r["mains_gas"] in (True, 1, "Y"))
|
||||
seg("mains_gas=N", lambda r: r["mains_gas"] not in (True, 1, "Y"))
|
||||
seg("ALL", lambda r: True)
|
||||
print("=" * 70)
|
||||
print("PE COMPONENT MEANS (kWh/m2):")
|
||||
for k in ("space_pe", "hw_pe", "other_pe", "pv_pe", "our_pe", "lodged_pe"):
|
||||
print(f" {k:12s} mean={stats.mean(r[k] for r in rows):7.2f} "
|
||||
f"median={stats.median(r[k] for r in rows):7.2f}")
|
||||
print("=" * 70)
|
||||
# Well-calibrated-SAP certs: energy is right, so PE diff isolates factor/scope.
|
||||
cal = [r for r in rows if abs(r["sap_err"]) < 0.3]
|
||||
print(f"WELL-CALIBRATED-SAP (|sap_err|<0.3) n={len(cal)}:")
|
||||
print(f" PE diff median={stats.median(r['pe_diff'] for r in cal):+.2f} "
|
||||
f"ratio={stats.median(r['pe_ratio'] for r in cal):.3f}")
|
||||
print(f" CO2 diff median={stats.median(r['co2_diff'] for r in cal):+.3f} "
|
||||
f"ratio={stats.median(r['co2_ratio'] for r in cal):.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -277,6 +277,70 @@ 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_flat_rate_charging_applies_table_4c3_space_factor() -> None:
|
||||
# Arrange — community heat-network main (Table 4a code 301, cat 6), age
|
||||
# band E (Table 12c DLF = 1.41). Control 2307 = "Flat rate charging,
|
||||
# TRVs". SAP 10.2 Table 4c(3) (PDF p.169) multiplies the heat-network
|
||||
# heat requirement by 1.05 for flat-rate charging (worksheet (305) →
|
||||
# (307) = (98c) × (302) × (305) × (306)). Flat-rate billing gives no
|
||||
# incentive to economise, so demand rises. The factor folds into the
|
||||
# delivered-per-fuel efficiency alongside the DLF: 1 / (DLF × 1.05).
|
||||
main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=20, # mains gas (community)
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2307, # Flat rate charging, TRVs
|
||||
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 — efficiency = 1 / (DLF 1.41 × Table 4c(3) factor 1.05).
|
||||
assert abs(inputs.main_heating_efficiency - 1.0 / (1.41 * 1.05)) <= 1e-9
|
||||
|
||||
|
||||
def test_heat_network_charging_linked_to_use_has_no_table_4c3_penalty() -> None:
|
||||
# Arrange — same community heat-network main, but control 2306 =
|
||||
# "Charging system linked to use of heating, programmer and TRVs". SAP
|
||||
# 10.2 Table 4c(3) (PDF p.169) gives factor 1.0 (charges track usage, so
|
||||
# no demand penalty), leaving the efficiency at the bare 1/DLF — i.e. the
|
||||
# 2307 flat-rate penalty above must NOT fire here.
|
||||
main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=20,
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2306, # Charging linked to use, programmer + TRVs
|
||||
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 — efficiency = 1 / DLF 1.41, no Table 4c(3) multiplier.
|
||||
assert abs(inputs.main_heating_efficiency - 1.0 / 1.41) <= 1e-9
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -41,18 +41,30 @@ _CORPUS = Path(
|
|||
)
|
||||
|
||||
# Measured floors/ceilings over the fixed corpus at HEAD (1000 certs, 0 skips).
|
||||
# Current: SAP within-0.5 = 65.0%, SAP MAE = 1.174.
|
||||
# CO2 MAE = 0.27 t/yr (signed +0.17 — a systematic over-estimate, see below).
|
||||
# PE MAE = 14.6 kWh/m2/yr (signed +8.9).
|
||||
# Current: SAP within-0.5 = 65.9%, SAP MAE = 1.160 (heat-network Table 4c(3)
|
||||
# flat-rate charging factor, this slice: community cluster 38% -> 62% within-0.5).
|
||||
# CO2 MAE = 0.28 t/yr (signed +0.17 — a systematic over-estimate, see below).
|
||||
# PE MAE = 14.7 kWh/m2/yr (signed +9.2).
|
||||
#
|
||||
# The SAP (cost) gauge is the optimised target — its floor/ceiling are TIGHT.
|
||||
# CO2 and PE are reported + LOOSELY guarded: cost is well-calibrated but CO2
|
||||
# and PE both run ~+5-10% high (a real systematic gap, not yet investigated —
|
||||
# uniform across fuels, so a CO2/PE-factor or scope issue, NOT the energy or
|
||||
# cost). Their ceilings catch "got worse", not "isn't perfect".
|
||||
# RATCHET any of these up when a slice tightens the corresponding metric.
|
||||
_MIN_WITHIN_HALF_SAP = 0.62
|
||||
_MAX_SAP_MAE = 1.25
|
||||
# CO2 and PE are reported + LOOSELY guarded: both run ~+5% high vs the lodged
|
||||
# figures. INVESTIGATED (see scripts/decompose_co2_pe_error.py): this is NOT a
|
||||
# CO2/PE-factor or scope bug. Table 12 factors are spec-exact (SAP 10.2 p.188:
|
||||
# mains gas PEF 1.130 / CO2 0.210, electricity 1.501 / 0.136) and worksheet
|
||||
# (286)/(272) scope is identical to ours. Two real causes:
|
||||
# 1. Per-cert mapper/demand fidelity. corr(SAP-err, PE-diff) = -0.54: when we
|
||||
# over-estimate demand the cert under-rates on SAP AND over-estimates PE.
|
||||
# Golden cert 0300-2747 (matched data) hits register PE to -0.10 kWh/m2,
|
||||
# proving the engine is correct — the corpus bias is the aggregate of the
|
||||
# same unclosed mapper gaps the SAP gauge chases, seen through a more
|
||||
# sensitive (linear, no standing-charge/deflator damping) lens.
|
||||
# 2. SAP cost is genuinely calibrated, NOT energy hiding behind it: a +5% gas
|
||||
# space-heating bump moves SAP -0.6 (would show as a -0.6 corpus bias if
|
||||
# energy were 5% high; actual SAP bias is +0.145).
|
||||
# So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is
|
||||
# no one-slice factor fix. RATCHET any ceiling up when a slice tightens it.
|
||||
_MIN_WITHIN_HALF_SAP = 0.63
|
||||
_MAX_SAP_MAE = 1.22
|
||||
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
|
||||
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue