From a41ac6bd7430facabe67875f9cf48ce66e775adc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 22 May 2026 11:25:38 +0000 Subject: [PATCH] =?UTF-8?q?Cohort=20residual=20slice=204:=20SAP=2010.2=20r?= =?UTF-8?q?ating=20constants=20=E2=80=94=20000490=20closes=20to=20delta=3D?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap/tests/test_bre_worked_examples.py | 7 ++- .../src/domain/sap/tests/test_calculator.py | 18 +++---- .../domain/src/domain/sap/worksheet/rating.py | 22 ++++++--- .../tests/test_e2e_elmhurst_sap_score.py | 11 +++-- .../domain/sap/worksheet/tests/test_rating.py | 49 ++++++++++--------- 5 files changed, 57 insertions(+), 50 deletions(-) diff --git a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py index 0dc3d2da..89d29396 100644 --- a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py +++ b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py @@ -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) diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index b8f7232c..635d4de1 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -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) ) diff --git a/packages/domain/src/domain/sap/worksheet/rating.py b/packages/domain/src/domain/sap/worksheet/rating.py index 552f0a4c..5e15e2c0 100644 --- a/packages/domain/src/domain/sap/worksheet/rating.py +++ b/packages/domain/src/domain/sap/worksheet/rating.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py b/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py index 3a7358f6..c184fa92 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py @@ -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( diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_rating.py b/packages/domain/src/domain/sap/worksheet/tests/test_rating.py index a4a8b92a..0188b688 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_rating.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_rating.py @@ -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