Model/scripts/decompose_co2_pe_error.py
Khalim Conn-Kowlessar dfcd7af57c 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>
2026-06-14 01:54:51 +00:00

124 lines
5.2 KiB
Python

"""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()