diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 3b46b481..a5e7799b 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -32,7 +32,7 @@ Reference: SAP 10.3 specification (13-01-2026) §§5-13 (pages 23-43), Table from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Final, TYPE_CHECKING from domain.sap.climate.appendix_u import external_temperature_c @@ -40,6 +40,7 @@ from domain.sap.climate.appendix_u import external_temperature_c if TYPE_CHECKING: from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.sap.worksheet.dimensions import Dimensions +from domain.sap.worksheet.energy_requirements import EnergyRequirementsResult from domain.sap.worksheet.heat_transmission import HeatTransmission from domain.sap.worksheet.rating import ( ECF_LOG_THRESHOLD, @@ -54,6 +55,28 @@ from domain.sap.worksheet.rating import ( _AIR_HEAT_CAPACITY_WH_PER_M3_K: Final[float] = 0.33 _TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 +# §9a default — used as `CalculatorInputs.energy_requirements` default for +# synthetic constructions that bypass cert_to_inputs. All-zero fuel; the +# calculator's read path falls through to the existing inline q/η math. +_ZERO_ENERGY_REQUIREMENTS_RESULT: Final[EnergyRequirementsResult] = EnergyRequirementsResult( + secondary_heating_fraction=0.0, + main_heating_total_fraction=1.0, + main_2_of_main_fraction=0.0, + main_1_of_total_fraction=1.0, + main_2_of_total_fraction=0.0, + main_1_efficiency_pct=100.0, + main_2_efficiency_pct=0.0, + secondary_efficiency_pct=100.0, + cooling_seer=0.0, + main_1_fuel_monthly_kwh=(0.0,) * 12, + main_2_fuel_monthly_kwh=(0.0,) * 12, + secondary_fuel_monthly_kwh=(0.0,) * 12, + main_1_fuel_kwh_per_yr=0.0, + main_2_fuel_kwh_per_yr=0.0, + secondary_fuel_kwh_per_yr=0.0, + cooling_fuel_kwh_per_yr=0.0, +) + @dataclass(frozen=True) class CalculatorInputs: @@ -140,6 +163,15 @@ class CalculatorInputs: # Default 0.0 for backwards compatibility — synthetic CalculatorInputs # constructions without cert_to_inputs leave it unset. fabric_energy_efficiency_kwh_per_m2_yr: float = 0.0 + # SAP10.2 §9a — per-system energy requirements (201)..(221) precomputed + # by cert_to_inputs via `space_heating_fuel_monthly_kwh`. Calculator + # reads `main_1_fuel_monthly_kwh` and `secondary_fuel_monthly_kwh` for + # per-month fuel attribution; existing `main_heating_efficiency` / + # `secondary_heating_efficiency` / `secondary_heating_fraction` fields + # are now redundant inputs (kept for backwards compat + audit). + energy_requirements: EnergyRequirementsResult = field( + default_factory=lambda: _ZERO_ENERGY_REQUIREMENTS_RESULT + ) @dataclass(frozen=True) @@ -174,7 +206,9 @@ class SapResult: space_cooling_kwh_per_yr: float fabric_energy_efficiency_kwh_per_m2_yr: float main_heating_fuel_kwh_per_yr: float + main_2_heating_fuel_kwh_per_yr: float secondary_heating_fuel_kwh_per_yr: float + space_cooling_fuel_kwh_per_yr: float hot_water_kwh_per_yr: float pumps_fans_kwh_per_yr: float lighting_kwh_per_yr: float @@ -214,15 +248,10 @@ def _solve_month( # SAP 10.2 §8 — (98c)m precomputed upstream by `space_heating_monthly_kwh` # (includes Table 9c summer clamp Jun..Sep). Calculator indexes directly. q_heat = inputs.space_heating_monthly_kwh[month - 1] - sec_frac = inputs.secondary_heating_fraction - q_main = q_heat * (1.0 - sec_frac) - q_secondary = q_heat * sec_frac - fuel_main = q_main / inputs.main_heating_efficiency if inputs.main_heating_efficiency > 0 else 0.0 - fuel_secondary = ( - q_secondary / inputs.secondary_heating_efficiency - if inputs.secondary_heating_efficiency > 0 - else 0.0 - ) + # SAP 10.2 §9a — (211)m/(215)m precomputed upstream by + # `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline. + fuel_main = inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1] + fuel_secondary = inputs.energy_requirements.secondary_fuel_monthly_kwh[month - 1] # SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh` # (includes Jun-Aug inclusion mask + post-f_C × f_intermittent clamp). @@ -400,7 +429,9 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: space_cooling_kwh_per_yr=space_cooling_kwh, fabric_energy_efficiency_kwh_per_m2_yr=inputs.fabric_energy_efficiency_kwh_per_m2_yr, main_heating_fuel_kwh_per_yr=main_fuel_kwh, + main_2_heating_fuel_kwh_per_yr=inputs.energy_requirements.main_2_fuel_kwh_per_yr, secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh, + space_cooling_fuel_kwh_per_yr=inputs.energy_requirements.cooling_fuel_kwh_per_yr, hot_water_kwh_per_yr=inputs.hot_water_kwh_per_yr, pumps_fans_kwh_per_yr=inputs.pumps_fans_kwh_per_yr, lighting_kwh_per_yr=inputs.lighting_kwh_per_yr, diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index d390b404..c9712c08 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -72,6 +72,9 @@ from domain.sap.worksheet.mean_internal_temperature import ( mean_internal_temperature_monthly, ) from domain.sap.worksheet.solar_gains import solar_gains_from_cert +from domain.sap.worksheet.energy_requirements import ( + space_heating_fuel_monthly_kwh, +) from domain.sap.worksheet.fabric_energy_efficiency import ( fabric_energy_efficiency_kwh_per_m2_yr, ) @@ -971,6 +974,24 @@ def cert_to_inputs( space_cooling_per_m2_kwh=space_cooling_result.space_cooling_per_m2_kwh, ) + # SAP10.2 §9a — per-system energy requirements (201)..(221). Composes + # (98c)m + Table 11 secondary fraction + per-system efficiencies into + # (211)m/(213)m/(215)m fuel-kWh tuples. Scope A: single-main only; + # (203)/(205)/(207)/(213) two-main and (209)/(221) cooling-SEER stay at + # zero placeholders until those slices land. + secondary_fraction_value = _secondary_fraction( + main, epc.sap_heating.secondary_heating_type + ) + secondary_efficiency_value = _secondary_efficiency( + epc.sap_heating, main_code, main_fuel + ) + energy_requirements_result = space_heating_fuel_monthly_kwh( + space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh, + secondary_heating_fraction=secondary_fraction_value, + main_heating_efficiency_pct=eff * 100.0, + secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0, + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -1023,12 +1044,9 @@ def cert_to_inputs( co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc), pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(prices), - secondary_heating_fraction=_secondary_fraction( - main, epc.sap_heating.secondary_heating_type - ), - secondary_heating_efficiency=_secondary_efficiency( - epc.sap_heating, main_code, main_fuel - ), + secondary_heating_fraction=secondary_fraction_value, + secondary_heating_efficiency=secondary_efficiency_value, + energy_requirements=energy_requirements_result, secondary_heating_fuel_cost_gbp_per_kwh=_secondary_fuel_cost_gbp_per_kwh( epc.sap_heating, main, epc.sap_energy_source.meter_type, prices ), diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 51ee0588..55440487 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -335,6 +335,33 @@ def test_no_ac_cert_round_trips_fee_equals_space_heating_per_m2() -> None: assert result.space_cooling_kwh_per_yr == 0.0 +def test_cert_to_inputs_precomputes_energy_requirements_on_calculator_inputs() -> None: + """§9a precompute path: cert_to_inputs runs `space_heating_fuel_monthly_ + kwh` and stashes the EnergyRequirementsResult on CalculatorInputs. The + composite slot's main_1 fuel matches what the calculator's SapResult + exposes as `main_heating_fuel_kwh_per_yr` to float equality (no rounding + introduced by the cert→inputs hop).""" + # Arrange + epc = _typical_semi_detached_epc() + + # Act + inputs = cert_to_inputs(epc) + result = Sap10Calculator().calculate(epc) + + # Assert + energy_req = inputs.energy_requirements + assert ( + energy_req.main_1_fuel_kwh_per_yr == result.main_heating_fuel_kwh_per_yr + ) + assert ( + energy_req.secondary_fuel_kwh_per_yr + == result.secondary_heating_fuel_kwh_per_yr + ) + # Scope-A placeholders pass through unchanged onto SapResult. + assert result.main_2_heating_fuel_kwh_per_yr == 0.0 + assert result.space_cooling_fuel_kwh_per_yr == 0.0 + + def test_calculator_always_uses_uk_average_weather_for_rating() -> None: # Arrange — SAP 10.2 Appendix U explicitly states: "Calculations for # ratings (SAP rating and environmental impact rating) are done with