slice S-A6: SAP10.3 rating + EI rating formulas (§13 + §14)

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.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 09:12:25 +00:00
parent c0afe3592f
commit 9106621aee
2 changed files with 210 additions and 0 deletions

View file

@ -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

View file

@ -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)