diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 8ef3e429..50e3be02 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -44,7 +44,9 @@ from domain.sap.worksheet.heat_transmission import HeatTransmission from domain.sap.worksheet.internal_gains import internal_gains_w from domain.sap.worksheet.mean_internal_temperature import mean_internal_temperature_c from domain.sap.worksheet.rating import ( + ECF_LOG_THRESHOLD, ENERGY_COST_DEFLATOR, + FLOOR_AREA_OFFSET_M2, energy_cost_factor, sap_rating, sap_rating_integer, @@ -381,6 +383,8 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: "hot_water_primary_kwh_per_yr": hot_water_primary_kwh, "other_primary_kwh_per_yr": other_primary_kwh, "pv_primary_offset_kwh_per_yr": pv_primary_offset_kwh, + "floor_area_offset_m2": FLOOR_AREA_OFFSET_M2, + "ecf_log_threshold": ECF_LOG_THRESHOLD, } return SapResult( diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index 86abc224..967fa361 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -348,6 +348,22 @@ def test_calculate_exposes_primary_energy_breakdown() -> None: assert result.primary_energy_kwh_per_yr == pytest.approx(expected_total, rel=1e-9) +def test_calculate_exposes_rating_equation_spec_constants() -> None: + # Arrange — P5 trace mode: the §13 ECF denominator carries a 45 m² + # floor-area offset (Table 12) and the SAP rating splits between a + # linear and a log regime at ECF = 3.5. Surfacing both on + # `intermediate` documents the equation alongside the already-exposed + # ecf + deflator (P5.7), so the SAP rating curve is fully auditable. + inputs = _baseline_inputs() + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert + assert result.intermediate["floor_area_offset_m2"] == pytest.approx(45.0, rel=1e-12) + assert result.intermediate["ecf_log_threshold"] == pytest.approx(3.5, rel=1e-12) + + def test_higher_main_heating_efficiency_reduces_fuel_use() -> None: # Arrange — Direction check: doubling the boiler efficiency must halve # the main-heating fuel kWh, holding everything else constant. diff --git a/packages/domain/src/domain/sap/worksheet/rating.py b/packages/domain/src/domain/sap/worksheet/rating.py index 0463c57b..552f0a4c 100644 --- a/packages/domain/src/domain/sap/worksheet/rating.py +++ b/packages/domain/src/domain/sap/worksheet/rating.py @@ -19,8 +19,8 @@ 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 +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 @@ -38,13 +38,13 @@ def energy_cost_factor( 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) + 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: + if ecf >= ECF_LOG_THRESHOLD: return _SAP_LOG_INTERCEPT - _SAP_LOG_SLOPE * log10(ecf) return _SAP_LINEAR_INTERCEPT - _SAP_LINEAR_SLOPE * ecf @@ -64,7 +64,7 @@ def environmental_impact_rating( ) -> 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) + 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