mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
b536b46ab4
commit
a41ac6bd74
5 changed files with 57 additions and 50 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue