From 9106621aeef539d6d0f3718bd2aca6654eb413d9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 09:12:25 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-A6:=20SAP10.3=20rating=20+=20EI=20rat?= =?UTF-8?q?ing=20formulas=20(=C2=A713=20+=20=C2=A714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenth slice of the SAP10 Calculator Session A (ADR-0009). Ships four pure functions under domain.sap.worksheet.rating implementing the SAP 10.3 rating formulas: energy_cost_factor(total_cost_gbp, total_floor_area_m2) -> equation (7): ECF = 0.36 × cost / (TFA + 45) Deflator 0.36 sourced from Table 12 (page 191). sap_rating(ecf) -> equations (8)/(9), continuous (un-rounded) SAP value: ECF ≥ 3.5: 108.8 − 120.5 × log10(ECF) ECF < 3.5: 100 − 16.21 × ECF Naturally rises above 100 for net energy exporters (negative ECF). sap_rating_integer(ecf) -> integer SAP value as published on the EPC: round to nearest, clamp to minimum 1 per §13. environmental_impact_rating(co2_emissions_kg_per_yr, total_floor_area_m2) -> equations (10)-(12), continuous EI rating: CF = CO2 / (TFA + 45) CF ≥ 28.3: 200 − 95 × log10(CF) CF < 28.3: 100 − 1.34 × CF 8 AAA cycles cover: ECF formula hand-computed, SAP linear branch (typical home), SAP log branch (high cost), boundary continuity at ECF=3.5, net-exporter SAP > 100, integer rounding + min-1 clamp, EI linear branch, EI log branch. Orchestrator (S-A7) wires these into Sap10Calculator alongside the monthly heat balance loop from S-A5e. --- .../domain/src/domain/sap/worksheet/rating.py | 70 +++++++++ .../domain/sap/worksheet/tests/test_rating.py | 140 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/rating.py create mode 100644 packages/domain/src/domain/sap/worksheet/tests/test_rating.py diff --git a/packages/domain/src/domain/sap/worksheet/rating.py b/packages/domain/src/domain/sap/worksheet/rating.py new file mode 100644 index 00000000..85ae0aa5 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/rating.py @@ -0,0 +1,70 @@ +"""SAP 10.3 §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 + +Reference: SAP 10.3 specification (13-01-2026) §13 + §14 (pages 38-39), +Table 12 (page 191). +""" + +from __future__ import annotations + +from math import log10 +from typing import Final + + +_ENERGY_COST_DEFLATOR: Final[float] = 0.36 +_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 +_CF_LOG_THRESHOLD: Final[float] = 28.3 +_EI_LINEAR_INTERCEPT: Final[float] = 100.0 +_EI_LINEAR_SLOPE: Final[float] = 1.34 +_EI_LOG_INTERCEPT: Final[float] = 200.0 +_EI_LOG_SLOPE: Final[float] = 95.0 + + +def energy_cost_factor( + *, + total_cost_gbp: float, + total_floor_area_m2: float, +) -> float: + """SAP 10.3 §13 equation (7): ECF = 0.36 × cost / (TFA + 45).""" + return _ENERGY_COST_DEFLATOR * total_cost_gbp / (total_floor_area_m2 + _FLOOR_AREA_OFFSET_M2) + + +def sap_rating(*, ecf: float) -> float: + """SAP 10.3 §13 equations (8)/(9). Un-rounded result so callers can + inspect the continuous value; `sap_rating_integer` rounds and clamps.""" + if ecf >= _ECF_LOG_THRESHOLD: + return _SAP_LOG_INTERCEPT - _SAP_LOG_SLOPE * log10(ecf) + return _SAP_LINEAR_INTERCEPT - _SAP_LINEAR_SLOPE * ecf + + +def sap_rating_integer(*, ecf: float) -> int: + """SAP 10.3 §13: round the continuous SAP rating to the nearest integer + and clamp to a minimum of 1 ("if the result of the calculation is less + than 1 the rating should be quoted as 1"). The integer value is the + one published on the EPC.""" + return max(1, round(sap_rating(ecf=ecf))) + + +def environmental_impact_rating( + *, + co2_emissions_kg_per_yr: float, + total_floor_area_m2: float, +) -> float: + """SAP 10.3 §14 equations (10)-(12). Un-rounded EI rating; mirrors the + SAP rating curve but uses CO2 emissions per (TFA + 45) as the input.""" + cf = co2_emissions_kg_per_yr / (total_floor_area_m2 + _FLOOR_AREA_OFFSET_M2) + if cf >= _CF_LOG_THRESHOLD: + return _EI_LOG_INTERCEPT - _EI_LOG_SLOPE * log10(cf) + return _EI_LINEAR_INTERCEPT - _EI_LINEAR_SLOPE * cf diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_rating.py b/packages/domain/src/domain/sap/worksheet/tests/test_rating.py new file mode 100644 index 00000000..a4a8b92a --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_rating.py @@ -0,0 +1,140 @@ +"""Tests for SAP 10.3 §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 + (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 +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). +""" + +import pytest + +from domain.sap.worksheet.rating import ( + energy_cost_factor, + environmental_impact_rating, + sap_rating, + sap_rating_integer, +) + + +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 + # 100 m² dwelling costing £500/year: + # ECF = 0.36 × 500 / 145 ≈ 1.2414 + + # 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) + + +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. + + # Act + result = sap_rating(ecf=1.241) + + # Assert — un-rounded SAP value for hand-verification. + assert result == pytest.approx(79.89, 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. + # For a high-cost dwelling, ECF = 4.965: + # log10(4.965) ≈ 0.6960 + # SAP = 108.8 − 120.5 × 0.6960 ≈ 24.93 + + # Act + result = sap_rating(ecf=4.965) + + # Assert + assert result == pytest.approx(24.93, 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 + + # Act + at_threshold = sap_rating(ecf=3.5) + just_below = sap_rating(ecf=3.499) + + # Assert + assert at_threshold == pytest.approx(just_below, abs=0.05) + + +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 + + # Act + result = sap_rating(ecf=-0.3) + + # Assert + assert result == pytest.approx(104.86, abs=0.05) + assert result > 100.0 + + +def test_sap_rating_integer_rounds_to_nearest_and_clamps_to_minimum_one() -> None: + # 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. + + # Act + typical = sap_rating_integer(ecf=1.241) + catastrophic = sap_rating_integer(ecf=10.0) + + # Assert + assert typical == 80 + assert catastrophic == 1 + + +def test_environmental_impact_rating_linear_branch_below_threshold() -> None: + # Arrange — Equations (10)/(12): CF = CO2/(TFA+45); when CF < 28.3, + # EI = 100 − 1.34 × CF. For a 100 m² home emitting 1500 kg CO2/yr: + # CF = 1500 / 145 ≈ 10.345 + # EI = 100 − 1.34 × 10.345 ≈ 86.14 + + # Act + result = environmental_impact_rating( + co2_emissions_kg_per_yr=1500.0, + total_floor_area_m2=100.0, + ) + + # Assert + assert result == pytest.approx(86.14, abs=0.05) + + +def test_environmental_impact_rating_log_branch_above_threshold() -> None: + # Arrange — When CF ≥ 28.3, EI = 200 − 95 × log10(CF). For a 100 m² + # home emitting 5000 kg CO2/yr: + # CF = 5000 / 145 ≈ 34.48 + # log10(34.48) ≈ 1.5377 + # EI = 200 − 95 × 1.5377 ≈ 53.92 + + # Act + result = environmental_impact_rating( + co2_emissions_kg_per_yr=5000.0, + total_floor_area_m2=100.0, + ) + + # Assert + assert result == pytest.approx(53.92, abs=0.05)