mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
c0afe3592f
commit
9106621aee
2 changed files with 210 additions and 0 deletions
70
packages/domain/src/domain/sap/worksheet/rating.py
Normal file
70
packages/domain/src/domain/sap/worksheet/rating.py
Normal 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
|
||||
140
packages/domain/src/domain/sap/worksheet/tests/test_rating.py
Normal file
140
packages/domain/src/domain/sap/worksheet/tests/test_rating.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue