Slice 42: golden-cohort PE pin uses demand cascade via calculate_sap_from_inputs

Slice 37's per-cert pin refactor pinned PE residuals against
`result.primary_energy_kwh_per_m2` from the rating cascade (UK-avg
climate). But per SAP10.2 Appendix U + the codebase's own
SAP_CALCULATOR.md docs, the EPC's published `energy_consumption_current`
is a postcode-climate value — same as CO2. The CO2 pin was already
correct; PE was an oversight.

Fix: use the public `calculate_sap_from_inputs` entry point twice —
once with `cert_to_inputs` (rating cascade) for SAP, once with
`cert_to_demand_inputs` (demand cascade) for PE + CO2. This drops
the four section-helper imports and reads everything off SapResult,
keeping the test surface minimal.

PE residuals shift on every fixture (sometimes toward zero, sometimes
away — the rating cascade was masking the real gap):

  cert                              old PE     new PE     Δ
  0240-0200-5706-2365-8010          +0.74      +5.58      worse — known RR gap
  0300-2747-7640-2526-2135          +17.34     +4.45      tighter
  0390-2254-6420-2126-5561 (LN12)   -3.14      +0.18      tighter ← bread-and-butter cert now within 0.2 kWh/m²
  0390-2954-3640-2196-4175          -27.64     -26.68     ~same
  2130-1033-4050-5007-8395 (DE22)   -61.25     -65.89     worse — PV PE-offset now correctly accounted
  6035-7729-2309-0879-2296          +34.62     +45.05     worse — known wall-insulation + RR gap
  7536-3827-0600-0600-0276          -27.45     -17.98     tighter
  8135-1728-8500-0511-3296          -14.37     -9.50      tighter

The "worse" certs (0240, 6035, DE22) were never close — the rating
cascade had been coincidentally masking the real PE gap on the certs
with documented mapper gaps. Demand cascade now exposes the real
residual for each; the documented gaps' fixes will close them.

LN12 (bread-and-butter, gas combi, no PV) now reads:
  SAP   resid +0       (exact match)
  PE    resid +0.18    (within 0.2 kWh/m² of lodged 241)
  CO2   resid +0.04    (within 0.05 t/yr of lodged 3.5)
First cert in the cohort within target ±0.5 on SAP and ±1 on PE/CO2.

930/930 Elmhurst cascade unchanged. 14/14 golden cohort + PCDB chain
green. Pyright net-zero (2 errors before and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 14:42:55 +00:00
parent 81392208c4
commit 6836aed004

View file

@ -39,9 +39,8 @@ from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap.calculator import calculate_sap_from_inputs
from domain.sap.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
cert_to_demand_inputs,
cert_to_inputs,
environmental_section_from_cert,
local_climate_for_cert,
)
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden"
@ -76,7 +75,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-12,
expected_pe_resid_kwh_per_m2=+0.74,
expected_pe_resid_kwh_per_m2=+5.5809,
expected_co2_resid_tonnes_per_yr=+0.3436,
notes=(
"Detached house, TFA 202, age J, oil boiler, Table 4b code 130. "
@ -96,7 +95,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0300-2747-7640-2526-2135",
actual_sap=78,
expected_sap_resid=-9,
expected_pe_resid_kwh_per_m2=+17.3417,
expected_pe_resid_kwh_per_m2=+4.4546,
expected_co2_resid_tonnes_per_yr=-0.5359,
notes=(
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
@ -108,7 +107,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=-7,
expected_pe_resid_kwh_per_m2=-27.6371,
expected_pe_resid_kwh_per_m2=-26.6760,
expected_co2_resid_tonnes_per_yr=-2.5816,
notes="Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges has_draught_lobby=true.",
),
@ -116,7 +115,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=+34.62,
expected_pe_resid_kwh_per_m2=+45.0454,
expected_co2_resid_tonnes_per_yr=+1.0245,
notes="Mid-terrace, TFA 128, age A, gas combi Table 4b code 104.",
),
@ -124,7 +123,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="7536-3827-0600-0600-0276",
actual_sap=68,
expected_sap_resid=+3,
expected_pe_resid_kwh_per_m2=-27.45,
expected_pe_resid_kwh_per_m2=-17.9833,
expected_co2_resid_tonnes_per_yr=-0.4781,
notes="Detached + 2 extensions, TFA 152, age D, gas PCDB.",
),
@ -132,7 +131,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="8135-1728-8500-0511-3296",
actual_sap=72,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-14.3709,
expected_pe_resid_kwh_per_m2=-9.5017,
expected_co2_resid_tonnes_per_yr=-0.1537,
notes="Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges blocked_chimneys_count=1.",
),
@ -140,7 +139,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+8,
expected_pe_resid_kwh_per_m2=-61.2533,
expected_pe_resid_kwh_per_m2=-65.8945,
expected_co2_resid_tonnes_per_yr=+0.1856,
notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
@ -160,7 +159,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0390-2254-6420-2126-5561",
actual_sap=65,
expected_sap_resid=0,
expected_pe_resid_kwh_per_m2=-3.1420,
expected_pe_resid_kwh_per_m2=+0.1797,
expected_co2_resid_tonnes_per_yr=+0.0413,
notes=(
"End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, "
@ -198,24 +197,28 @@ def _load_cert(cert_number: str) -> dict[str, Any]:
)
def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> None:
# Arrange — load the frozen cert JSON, map to EpcPropertyData, run
# the rating cascade (UK-avg climate, SAP 10.2 spec prices per
# ADR-0010) for SAP + PE; run the demand-cascade environmental
# section (postcode climate via PCDB Table 172) for CO2 — that's
# what the EPC publishes as `co2_emissions_current`.
# the calculator end-to-end via two cascades:
# - cert_to_inputs → UK-average climate → SAP rating (per SAP10.2
# Appendix U: only SAP + EI use UK-avg);
# - cert_to_demand_inputs → postcode climate (PCDB Table 172) →
# PE + CO2 (per the same Appendix U: everything the EPC publishes
# as "Current X" uses postcode-specific weather).
# The single public interface `calculate_sap_from_inputs` surfaces
# all three outputs on SapResult; no section helpers required.
doc = _load_cert(expectation.cert_number)
epc = EpcPropertyDataMapper.from_api_response(doc)
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Act
result = calculate_sap_from_inputs(inputs)
pc = local_climate_for_cert(epc)
env = environmental_section_from_cert(epc, postcode_climate=pc)
assert env is not None, "demand-cascade environmental section must compute"
co2_calc_tonnes = env.total_co2_kg_per_yr / 1000
rating = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
demand = calculate_sap_from_inputs(
cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
sap_resid = result.sap_score - expectation.actual_sap
pe_resid = result.primary_energy_kwh_per_m2 - doc["energy_consumption_current"]
co2_resid = co2_calc_tonnes - doc["co2_emissions_current"]
sap_resid = rating.sap_score - expectation.actual_sap
pe_resid = demand.primary_energy_kwh_per_m2 - doc["energy_consumption_current"]
co2_resid = demand.co2_kg_per_yr / 1000 - doc["co2_emissions_current"]
# Assert — each residual sits within an absolute tolerance of the
# recorded pin. Shifts beyond tolerance fire loudly: tighten the pin