Cohort residual slice 4: SAP 10.2 rating constants — 000490 closes to delta=0

Replaces the SAP 10.3 §13 rating constants in `worksheet/rating.py`
with SAP 10.2 values per ADR-0010 (active spec target is SAP 10.2,
14-03-2025; spec changed to SAP 10.3 only as of 13-01-2026 which
hasn't been adopted):

  Energy Cost Deflator         0.36 → 0.42
  Linear branch slope          16.21 → 13.95   (SAP = 100 − slope × ECF)
  Log branch intercept         108.8 → 117.0   (SAP = intercept − slope × log10(ECF))
  Log branch slope             120.5 → 121.0

The two errors were near-cancelling on the Elmhurst cohort (low-cost
combi-gas dwellings on the linear branch): the wrong deflator made
our ECF ~14% low, and the wrong linear slope made our SAP drop per
unit ECF ~16% high. Their product was close to the spec but not
exactly — leaving 000490 stuck 1 SAP integer over PDF after the
other component closures (Appendix L, secondary heating, ventilation,
pumps_fans) had brought cost to within £0.04 of PDF.

Final cohort SAP integer status — **both fixtures hit delta=0**:

  000474:  integer 62 = PDF 62 (continuous 61.91 vs PDF 62.26, Δ -0.35)
  000490:  integer 57 = PDF 57 (continuous 57.40 vs PDF 57.40, Δ -0.002)

000490 e2e SAP integer ceiling tightened 1 → 0.

Updated 8 internal rating + calculator tests that pinned the SAP 10.3
constants (test_rating.py, test_calculator.py, test_bre_worked_
examples.py). All 685 tests green; 0 xfail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 11:25:38 +00:00
parent b536b46ab4
commit a41ac6bd74
5 changed files with 57 additions and 50 deletions

View file

@ -244,13 +244,12 @@ def test_baseline_dwelling_worksheet_trace() -> None:
assert inter["pv_export_credit_gbp"] == pytest.approx(0.0, abs=1e-9)
# Assert — §13 Energy cost rating -------------------------------------
# (256) energy cost deflator (Table 12 = 0.36; see PARITY_FINDINGS for
# RdSAP10 Table 32 update to 0.42 — separate spec drift, tracked there).
assert inter["deflator"] == pytest.approx(0.36, rel=1e-12)
# (256) energy cost deflator (Table 12 = 0.42 per SAP 10.2).
assert inter["deflator"] == pytest.approx(0.42, rel=1e-12)
# (257) ECF = [(255) × (256)] / [(4) + 45.0]
floor_area_offset_m2 = 45.0 # baked into (257) formula
expected_ecf = (
result.total_fuel_cost_gbp * 0.36 / (tfa + floor_area_offset_m2)
result.total_fuel_cost_gbp * 0.42 / (tfa + floor_area_offset_m2)
)
assert inter["ecf"] == pytest.approx(expected_ecf, rel=1e-9)
assert inter["ecf"] == pytest.approx(result.ecf, rel=1e-12)

View file

@ -355,10 +355,10 @@ def test_calculate_exposes_per_end_use_fuel_costs() -> None:
def test_calculate_exposes_ecf_and_deflator() -> None:
# Arrange — P5 trace mode: ECF (the rating denominator) and the §13
# Table 12 deflator (0.36) surface on `intermediate`. ECF mirrors the
# top-level field; deflator is the only fixed worksheet constant the
# SAP rating depends on, so naming it lets future rating-equation
# sweep slices reference it explicitly.
# Table 12 deflator (0.42 per SAP 10.2) surface on `intermediate`.
# ECF mirrors the top-level field; deflator is the only fixed
# worksheet constant the SAP rating depends on, so naming it lets
# future rating-equation sweep slices reference it explicitly.
inputs = _baseline_inputs()
# Act
@ -366,7 +366,7 @@ def test_calculate_exposes_ecf_and_deflator() -> None:
# Assert
assert result.intermediate["ecf"] == pytest.approx(result.ecf, rel=1e-9)
assert result.intermediate["deflator"] == pytest.approx(0.36, rel=1e-12)
assert result.intermediate["deflator"] == pytest.approx(0.42, rel=1e-12)
def test_calculate_exposes_co2_chain() -> None:
@ -625,9 +625,9 @@ def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None:
def test_ecf_uses_table_12_energy_cost_deflator() -> None:
# Arrange — §13 Equation (7): ECF = 0.36 × cost / (TFA + 45). The
# orchestrator must report an ECF that reconciles with this formula
# given the cost it reported.
# Arrange — §13 Equation (7): ECF = 0.42 × cost / (TFA + 45) per
# SAP 10.2 Table 12. The orchestrator must report an ECF that
# reconciles with this formula given the cost it reported.
inputs = _baseline_inputs()
# Act
@ -635,7 +635,7 @@ def test_ecf_uses_table_12_energy_cost_deflator() -> None:
# Assert
expected_ecf = (
0.36
0.42
* result.total_fuel_cost_gbp
/ (inputs.dimensions.total_floor_area_m2 + 45.0)
)

View file

@ -1,14 +1,20 @@
"""SAP 10.3 §13 Energy Cost Rating + §14 Environmental Impact Rating.
"""SAP 10.2 §13 Energy Cost Rating + §14 Environmental Impact Rating.
The Energy Cost Factor (ECF) blends total annual fuel cost with the
dwelling's floor area; the SAP rating maps ECF onto a 1-100+ scale that
rewards low cost per square metre. The EI rating is the same shape applied
to annual CO2 emissions.
Constants taken from SAP 10.3 Table 12 (page 191):
- Energy Cost Deflator = 0.36
Constants taken from SAP 10.2 Table 12 (page 191) per ADR-0010 (active
spec target is SAP 10.2, 14-03-2025):
- Energy Cost Deflator = 0.42
- Linear branch (ECF < 3.5): SAP = 100 13.95 × ECF
- Log branch (ECF 3.5): SAP = 117 121 × log10(ECF)
Reference: SAP 10.3 specification (13-01-2026) §13 + §14 (pages 38-39),
(SAP 10.3 widens these to 0.36 / 16.21 / 108.8 / 120.5 apply when the
spec target moves per a follow-up ADR amendment.)
Reference: SAP 10.2 specification (14-03-2025) §13 + §14 (pages 38-39),
Table 12 (page 191).
"""
@ -18,13 +24,13 @@ from math import log10
from typing import Final
ENERGY_COST_DEFLATOR: Final[float] = 0.36
ENERGY_COST_DEFLATOR: Final[float] = 0.42
FLOOR_AREA_OFFSET_M2: Final[float] = 45.0
ECF_LOG_THRESHOLD: Final[float] = 3.5
_SAP_LINEAR_INTERCEPT: Final[float] = 100.0
_SAP_LINEAR_SLOPE: Final[float] = 16.21
_SAP_LOG_INTERCEPT: Final[float] = 108.8
_SAP_LOG_SLOPE: Final[float] = 120.5
_SAP_LINEAR_SLOPE: Final[float] = 13.95
_SAP_LOG_INTERCEPT: Final[float] = 117.0
_SAP_LOG_SLOPE: Final[float] = 121.0
_CF_LOG_THRESHOLD: Final[float] = 28.3
_EI_LINEAR_INTERCEPT: Final[float] = 100.0
_EI_LINEAR_SLOPE: Final[float] = 1.34

View file

@ -113,12 +113,13 @@ def test_elmhurst_000490_end_to_end_sap_score_currently_within_3_points() -> Non
# Act
result = Sap10Calculator().calculate(epc)
# Assert — secondary heating cascade closed the £104 cost gap; SAP
# integer is now 58 vs PDF 57 (delta 1). The residual delta is from
# the +0.7% upstream useful space heating overshoot — next ticket.
# Assert — full cohort residual hunt closed: Appendix L lighting +
# secondary heating cascade + ventilation cert lodgements + Table 4f
# pumps_fans + SAP 10.2 rating constants. 000490 now hits SAP integer
# delta=0 (continuous ~0.002 under PDF).
delta = abs(result.sap_score - _ELMHURST_000490_EXPECTED.sap_rating)
assert delta <= 1, (
f"SAP rating delta {delta} exceeds current-state ceiling of 1. "
assert delta == 0, (
f"SAP rating delta {delta} — expected 0 (integer match with PDF). "
f"Actual={result.sap_score}, expected={_ELMHURST_000490_EXPECTED.sap_rating}."
)
continuous_delta = abs(

View file

@ -1,19 +1,20 @@
"""Tests for SAP 10.3 §13 Energy Cost Rating and §14 Environmental Impact
"""Tests for SAP 10.2 §13 Energy Cost Rating and §14 Environmental Impact
Rating formulas.
(7) ECF = deflator × total_cost / (TFA + 45)
(8) if ECF 3.5, SAP10 = 108.8 120.5 × log10(ECF)
(9) if ECF < 3.5, SAP10 = 100 16.21 × ECF
(8) if ECF 3.5, SAP10 = 117 121 × log10(ECF)
(9) if ECF < 3.5, SAP10 = 100 13.95 × ECF
(10) CF = CO2_emissions / (TFA + 45)
(11) if CF 28.3, EI = 200 95 × log10(CF)
(12) if CF < 28.3, EI = 100 1.34 × CF
Energy Cost Deflator = 0.36 per Table 12 (page 191). SAP and EI ratings
Energy Cost Deflator = 0.42 per Table 12 (page 191). SAP and EI ratings
are rounded to nearest integer; if the result is less than 1 it is quoted
as 1.
Reference: SAP 10.3 specification (13-01-2026) §13 + §14 (pages 38-39),
Table 12 Energy Cost Deflator (page 191).
Reference: SAP 10.2 specification (14-03-2025) §13 + §14 (pages 38-39),
Table 12 Energy Cost Deflator (page 191). Per ADR-0010, active spec
target is SAP 10.2.
"""
import pytest
@ -26,48 +27,48 @@ from domain.sap.worksheet.rating import (
)
def test_ecf_applies_deflator_0_36_to_total_cost_per_adjusted_area() -> None:
# Arrange — Equation (7): ECF = 0.36 × total_cost / (TFA + 45). For a
def test_ecf_applies_deflator_0_42_to_total_cost_per_adjusted_area() -> None:
# Arrange — Equation (7): ECF = 0.42 × total_cost / (TFA + 45). For a
# 100 m² dwelling costing £500/year:
# ECF = 0.36 × 500 / 145 ≈ 1.2414
# ECF = 0.42 × 500 / 145 ≈ 1.4483
# Act
result = energy_cost_factor(total_cost_gbp=500.0, total_floor_area_m2=100.0)
# Assert
assert result == pytest.approx(1.241, abs=0.005)
assert result == pytest.approx(1.4483, abs=0.005)
def test_sap_rating_uses_linear_branch_when_ecf_below_3_5() -> None:
# Arrange — Equation (9): SAP = 100 16.21 × ECF when ECF < 3.5.
# For ECF = 1.241 → SAP = 100 20.11 ≈ 79.89, rounds to 80.
# Arrange — Equation (9): SAP = 100 13.95 × ECF when ECF < 3.5.
# For ECF = 1.4483 → SAP = 100 20.204 ≈ 79.80, rounds to 80.
# Act
result = sap_rating(ecf=1.241)
result = sap_rating(ecf=1.4483)
# Assert — un-rounded SAP value for hand-verification.
assert result == pytest.approx(79.89, abs=0.05)
assert result == pytest.approx(79.80, abs=0.05)
def test_sap_rating_uses_log_branch_when_ecf_above_3_5() -> None:
# Arrange — Equation (8): SAP = 108.8 120.5 × log10(ECF) for ECF ≥ 3.5.
# Arrange — Equation (8): SAP = 117 121 × log10(ECF) for ECF ≥ 3.5.
# For a high-cost dwelling, ECF = 4.965:
# log10(4.965) ≈ 0.6960
# SAP = 108.8 120.5 × 0.6960 ≈ 24.93
# SAP = 117 121 × 0.6960 ≈ 32.78
# Act
result = sap_rating(ecf=4.965)
# Assert
assert result == pytest.approx(24.93, abs=0.05)
assert result == pytest.approx(32.78, abs=0.05)
def test_sap_rating_log_threshold_boundary_is_continuous() -> None:
# Arrange — At ECF = 3.5 (the branch boundary) both formulas should
# yield the same value within tolerance, since the rating curve has
# been calibrated to be continuous at the transition.
# linear: 100 16.21 × 3.5 = 43.265
# log: 108.8 120.5 × log10(3.5) = 108.8 65.55 ≈ 43.25
# linear: 100 13.95 × 3.5 = 51.175
# log: 117 121 × log10(3.5) = 117 65.83 ≈ 51.17
# Act
at_threshold = sap_rating(ecf=3.5)
@ -81,13 +82,13 @@ def test_net_energy_exporter_returns_sap_above_100() -> None:
# Arrange — §13 explicitly: "The SAP rating scale has been set so that
# SAP 100 is achieved at zero-ECF. It can rise above 100 if the dwelling
# is a net exporter of energy." For ECF = -0.3:
# SAP = 100 16.21 × (0.3) = 100 + 4.86 = 104.86
# SAP = 100 13.95 × (0.3) = 100 + 4.185 = 104.185
# Act
result = sap_rating(ecf=-0.3)
# Assert
assert result == pytest.approx(104.86, abs=0.05)
assert result == pytest.approx(104.185, abs=0.05)
assert result > 100.0
@ -95,11 +96,11 @@ def test_sap_rating_integer_rounds_to_nearest_and_clamps_to_minimum_one() -> Non
# Arrange — §13: "The SAP rating is rounded to the nearest integer. If
# the result of the calculation is less than 1 the rating should be
# quoted as 1." So a catastrophically high-cost dwelling (e.g. ECF ≈ 10)
# → SAP = 108.8 120.5 × 1 = 11.7, clamps to 1.
# A typical case ECF = 1.241 → 79.89 → 80.
# → SAP = 117 121 × 1 = 4, clamps to 1.
# A typical case ECF = 1.4483 → 79.80 → 80.
# Act
typical = sap_rating_integer(ecf=1.241)
typical = sap_rating_integer(ecf=1.4483)
catastrophic = sap_rating_integer(ecf=10.0)
# Assert