fix(climate): compute EPC CO2/PE on the postcode demand cascade (SAP 10.2 Appendix U p.124)

The SAP/EI rating is computed on UK-average weather (Appendix U Tables
U1-U3 region 0) so ratings are nationally comparable, but Appendix U
paragraph 1 (PDF p.124) requires that "other calculations (such as for
energy use and costs on EPCs) are done using local weather. Weather data
for each postcode district are taken from the PCDB". `Sap10Calculator.
calculate` ran ONE cascade (UK-average) and fed it to SAP, CO2 AND primary
energy, so every cert's EPC-displayed CO2/PE were computed on the wrong
climate. Because most of England is warmer than the UK-average, this
systematically OVER-counted heating demand on the emissions/PE outputs.

The two cascades (`cert_to_inputs` rating, `cert_to_demand_inputs`
postcode) already existed; this wires the demand cascade into the
production entry point and grafts its CO2/PE onto the rating result (SAP
unchanged). The corpus gauge's longstanding +5% CO2/PE over-estimate was
mostly this climate bug, NOT (as previously diagnosed) per-cert mapper
fidelity:
  CO2 MAE 0.26 -> 0.12 t/yr  (bias +0.18 -> +0.04)
  PE  MAE 13.6 -> 3.8 kWh/m2 (bias +9.0  -> +0.24)
  SAP within-0.5 = 69.7% (rating cascade, unchanged)

Worksheet-validated to 1e-4 on simulated case 45 (heat-pump ground-floor
flat, postcode W6): the P960 prints the current dwelling twice — Block 1
on UK-average weather (SAP 60.5318, CO2 692.13) and Block 2 on postcode
weather (CO2 626.78, PE 6581.59). Both reproduce exactly. Added a tracked
case-45 Summary fixture + two-cascade cascade pin as a permanent guard,
and ratcheted the corpus CO2/PE ceilings to 0.13 / 4.2. The e2e Elmhurst
suite (Block-1 line refs) now pins the rating cascade directly; the two
Vaillant overlay snapshots refreshed to demand-cascade CO2/PE.

pyright not installed in this codespace (strict gate not run locally);
change is type-trivial (dataclasses.replace over SapResult).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-18 14:15:34 +00:00
parent 26106505be
commit fc7c4d2d3b
7 changed files with 234 additions and 20 deletions

Binary file not shown.

View file

@ -42,7 +42,7 @@ Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from typing import Final, Optional, TYPE_CHECKING
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
@ -863,6 +863,25 @@ class Sap10Calculator(SapCalculator):
"""
def calculate(self, epc: "EpcPropertyData") -> SapResult:
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
# SAP 10.2 Appendix U paragraph 1 (p.124): the SAP and EI ratings are
# computed on UK-average climate (so ratings are nationally
# comparable), but "other calculations (such as for energy use and
# costs on EPCs) are done using local weather" — the EPC-displayed
# CO2 emissions and primary energy use postcode-district weather from
# the PCDB. So we run two climate cascades and graft the demand
# cascade's CO2/PE onto the rating cascade's SAP result. (Worked
# example: simulated case 45 — rating SAP 60.53/CO2 692.13 on
# UK-average; demand CO2 626.78/PE 6581.59 on the W6 postcode.)
from domain.sap10_calculator.rdsap.cert_to_inputs import (
cert_to_demand_inputs,
cert_to_inputs,
)
return calculate_sap_from_inputs(cert_to_inputs(epc))
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc))
return replace(
rating,
co2_kg_per_yr=demand.co2_kg_per_yr,
primary_energy_kwh_per_yr=demand.primary_energy_kwh_per_yr,
primary_energy_kwh_per_m2=demand.primary_energy_kwh_per_m2,
)

View file

@ -686,12 +686,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None:
# dwelling's baseline fabric and so the ASHP end-state SAP. Still a snapshot
# of the Vaillant overlay's own output, validated transitively by the
# system-boiler pin below (which reproduces a real Vaillant cert at delta 0).
# CO2/PE are the postcode DEMAND cascade now that `Sap10Calculator.
# calculate` computes EPC emissions/PE on local weather (SAP 10.2
# Appendix U p.124); SAP is unchanged (UK-average rating cascade).
_assert_overlay_scores(
before,
option.overlay,
sap=51.99820176096402,
co2=1268.4645083243888,
pe=13080.20756425629,
co2=1065.7593506066496,
pe=10995.781557709413,
)
@ -715,12 +718,14 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw
# boiler-1 pin above); the same merge also resolved this cert's main-fuel
# mapper gap (§14.2 mains-gas derivation), so its raw before now baselines —
# see `test_gas_boiler_instant_hw_before_baselines`.
# CO2/PE are the postcode DEMAND cascade now (see the boiler-1 pin above);
# SAP is unchanged (UK-average rating cascade).
_assert_overlay_scores(
before,
option.overlay,
sap=39.00740809309464,
co2=2248.6089062232704,
pe=23094.10189037302,
co2=1845.8588018295509,
pe=18944.42568846759,
)

View file

@ -0,0 +1,107 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 45" worksheet a ~47 GROUND-FLOOR FLAT heated by an
air-source HEAT PUMP (PCDB 100053 ECODAN, radiators, MCS=No) with a
WHC-903 electric-immersion DHW and a 110 L cylinder, postcode W6 9BF
(SAP Region "Thames Valley").
Case 45 is the 1e-4 oracle for the SAP 10.2 Appendix U (PDF p.124) TWO-
CLIMATE-CASCADE split. The P960 prints the current dwelling TWICE:
* Block 1 "11a. SAP rating / 12a. CO2" computed on UK-AVERAGE
weather (Appendix U Tables U1-U3 region 0). Drives the SAP/EI rating.
Space-heat demand (98c) = 7333.79; SAP value (258) = 60.5318 (-> 61);
total CO2 (272) = 692.13.
* Block 2 "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY"
computed on POSTCODE-DISTRICT weather (PCDB Table 172, W6). Drives the
EPC-displayed figures. Space-heat demand (98c) = 5921.05; total CO2
(272) = 626.78; total primary energy (286) = 6581.59.
Per Appendix U paragraph 1: "Other calculations (such as for energy use
and costs on EPCs) are done using local weather." `Sap10Calculator.
calculate` therefore runs both cascades and grafts the demand cascade's
CO2/PE onto the rating cascade's SAP — this fixture pins BOTH.
Like the other `_elmhurst_worksheet_001431_case*` fixtures it does NOT
hand-build the EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises
the WHOLE extractor + mapper + calculator pipeline.
Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/
simulated case 45/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf` so the
test runs without depending on the unstaged workspace.
Per [[feedback-zero-error-strict]]: pins are abs <= 1e-4 against the PDF.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_case45.pdf"
)
# Block 1 — UK-average RATING cascade (`cert_to_inputs`).
RATING_SPACE_HEATING_KWH: Final[float] = 7333.7892 # (98c)
RATING_SAP_CONTINUOUS: Final[float] = 60.5318 # (258) un-rounded
RATING_SAP_INTEGER: Final[int] = 61 # (258)
RATING_CO2_KG_PER_YR: Final[float] = 692.1287 # (272)
# Block 2 — POSTCODE-district DEMAND cascade (`cert_to_demand_inputs`).
DEMAND_SPACE_HEATING_KWH: Final[float] = 5921.0486 # (98c)
DEMAND_CO2_KG_PER_YR: Final[float] = 626.7797 # (272)
DEMAND_PRIMARY_ENERGY_KWH: Final[float] = 6581.5936 # (286)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label/value token sequences).
Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-45 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target. This module is a pin PROVIDER (build_epc + constants);
the collected assertions live in `test_section_cascade_pins`."""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -24,7 +24,10 @@ from typing import Final
import pytest
from domain.sap10_calculator.calculator import Sap10Calculator
from domain.sap10_calculator.calculator import (
Sap10Calculator,
calculate_sap_from_inputs,
)
from domain.sap10_calculator.rdsap.cert_to_inputs import (
cert_to_inputs,
water_heating_section_from_cert,
@ -338,8 +341,13 @@ def test_sap_result_pin(fixture_name: str, field_name: str) -> None:
epc = _FIXTURE_MODULES[fixture_name].build_epc()
expected = getattr(pin, field_name)
# Act
result = Sap10Calculator().calculate(epc)
# Act — these pins are the worksheet's Block-1 (energy-rating) line refs,
# i.e. the UK-average RATING cascade. `Sap10Calculator.calculate` now
# grafts the postcode DEMAND cascade's CO2/PE onto the result (SAP 10.2
# Appendix U p.124), so the rating-cascade fields are pinned via
# `cert_to_inputs` directly; the demand cascade is pinned separately
# (corpus gauge + simulated case 45 Block-2 pins).
result = calculate_sap_from_inputs(cert_to_inputs(epc))
actual = getattr(result, field_name)
# Assert

View file

@ -46,6 +46,7 @@ from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_001431_case21 as _w001431_case21,
_elmhurst_worksheet_001431_case43 as _w001431_case43,
_elmhurst_worksheet_001431_case44 as _w001431_case44,
_elmhurst_worksheet_001431_case45 as _w001431_case45,
)
@ -491,6 +492,67 @@ def test_case44_blower_door_pressure_test_matches_pdf() -> None:
_pin(vent.effective_monthly_ach[0], 0.5812, "§2 (25) Jan case44")
def test_case45_heat_pump_two_climate_cascade_matches_pdf() -> None:
"""Simulated case 45 (heat-pump ground-floor flat, postcode W6) is the
1e-4 oracle for the SAP 10.2 Appendix U (p.124) two-climate-cascade
split. The P960 prints the current dwelling twice:
* Block 1 ("11a SAP rating / 12a CO2") on UK-AVERAGE weather (region
0): space heat (98c) 7333.79, SAP (258) 60.5318, CO2 (272) 692.13.
* Block 2 ("EPC COSTS, EMISSIONS AND PRIMARY ENERGY") on POSTCODE
weather (PCDB Table 172, W6): space heat (98c) 5921.05, CO2 (272)
626.78, primary energy (286) 6581.59.
The SAP/EI rating reads the rating cascade; the EPC-displayed CO2/PE
read the demand cascade. Pins both ends at abs=1e-4."""
# Arrange
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_demand_inputs
epc = _w001431_case45.build_epc()
# The split only exists because the postcode resolves to local weather.
assert local_climate_for_cert(epc) is not None
# Act — both climate cascades from the one cert.
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc))
# Assert — Block 1 (UK-average rating cascade).
_pin(
rating.space_heating_kwh_per_yr,
_w001431_case45.RATING_SPACE_HEATING_KWH,
"(98c) rating case45",
)
_pin(
rating.sap_score_continuous,
_w001431_case45.RATING_SAP_CONTINUOUS,
"(258) rating case45",
)
assert rating.sap_score == _w001431_case45.RATING_SAP_INTEGER
_pin(
rating.co2_kg_per_yr,
_w001431_case45.RATING_CO2_KG_PER_YR,
"(272) rating case45",
)
# Assert — Block 2 (postcode demand cascade).
_pin(
demand.space_heating_kwh_per_yr,
_w001431_case45.DEMAND_SPACE_HEATING_KWH,
"(98c) demand case45",
)
_pin(
demand.co2_kg_per_yr,
_w001431_case45.DEMAND_CO2_KG_PER_YR,
"(272) demand case45",
)
_pin(
demand.primary_energy_kwh_per_yr,
_w001431_case45.DEMAND_PRIMARY_ENERGY_KWH,
"(286) demand case45",
)
def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two

View file

@ -30,11 +30,7 @@ from typing import Any
import pytest
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,
)
from domain.sap10_calculator.calculator import Sap10Calculator
_CORPUS = Path(
"backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl"
@ -127,10 +123,25 @@ _CORPUS = Path(
# "ground floor" floor_type as exposed (worksheet-validated to 1e-4 on simulated
# case 45: floor (28a) 0 -> 25.38 W/K, fabric (33) 75.6 -> 101.01) -> 69.5% ->
# 69.7% (MAE 0.859 -> 0.854). Pinned in test_heat_transmission.
# POSTCODE DEMAND CASCADE (SAP 10.2 Appendix U paragraph 1, p.124): the
# CO2/PE over-estimate diagnosed above as "per-cert mapper/demand fidelity"
# was largely a CLIMATE-cascade bug. The SAP/EI rating is computed on
# UK-average weather (Tables U1-U3 region 0), but EPC-displayed energy use,
# CO2 emissions and primary energy use POSTCODE-DISTRICT weather from PCDB
# Table 172 — "other calculations (such as for energy use and costs on EPCs)
# are done using local weather". We were feeding the UK-average demand to all
# three outputs, so warm-region certs (most of England, warmer than the
# UK-average) over-counted heating demand → CO2/PE high. `Sap10Calculator.
# calculate` now grafts the demand cascade's CO2/PE onto the rating cascade's
# SAP. Across the corpus this moved CO2 MAE 0.26 -> 0.12 t/yr (bias +0.18 ->
# +0.04) and PE MAE 13.6 -> 3.8 kWh/m2/yr (bias +9.0 -> +0.24); SAP unchanged
# (rating cascade). Worksheet-validated to 1e-4 on simulated case 45 (rating
# CO2 692.13; demand CO2 626.78, PE 6581.59). The residual PE/CO2 spread is
# now the genuine per-cert mapper-fidelity tail.
_MIN_WITHIN_HALF_SAP = 0.695
_MAX_SAP_MAE = 0.86
_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current
_MAX_CO2_MAE_TONNES = 0.13 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 4.2 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]:
@ -155,8 +166,12 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus(
co2_signed_errs_t: list[float] = [] # our lodged, tonnes/yr
pe_signed_errs: list[float] = [] # our lodged, kWh/m²/yr
skipped = 0
_calculator = Sap10Calculator()
# Act — run the API → EpcPropertyData → calculator pipeline per cert.
# `Sap10Calculator.calculate` runs both climate cascades (SAP 10.2
# Appendix U p.124): the SAP rating on UK-average weather, CO2/PE on
# postcode-district weather — exactly the two figures the EPC lodges.
for doc in corpus:
lodged_sap = doc.get("energy_rating_current")
if lodged_sap is None:
@ -164,9 +179,7 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus(
continue
try:
epc = EpcPropertyDataMapper.from_api_response(doc)
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
result = _calculator.calculate(epc)
except Exception:
# A mapper / calculator raise is a coverage gap tracked elsewhere
# (eval_api_sap_accuracy.py); here we gauge the certs that compute.