"""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.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) (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). """ from __future__ import annotations from math import log10 from typing import Final 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] = 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 _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.2 §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.2 §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.2 §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.2 §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