From f37970666e803debebcdcbe6da99608422bf61fb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 21 May 2026 07:58:34 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A78c=20slice=203:=20CalculatorInputs=20+=20?= =?UTF-8?q?MonthlyEntry=20+=20SapResult=20+=20cert=5Fto=5Finputs=20wiring?= =?UTF-8?q?=20(atomic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full §8 mirror per Q9 grilling: CalculatorInputs.space_cooling_monthly_kwh (default (0,)*12), MonthlyEntry.space_cool_requirement_kwh, SapResult.space_cooling_kwh_per_yr. _solve_month indexes into the cooling tuple and calculate_sap_from_inputs sums the per-month entries. cert_to_inputs calls space_cooling_monthly_kwh with f_C=0 and cooling_gains=(0,)*12 — RdSAP convention since the cert never lodges cooled-area data and every `has_fixed_air_conditioning=False` cert collapses (107) to zero. The first cooling-enabled fixture needs a cooling_gains_from_cert helper + RdSAP cooled-area defaulting rule (deferred — SPEC_COVERAGE §8c row). Round-trip test pins inputs.space_cooling_monthly_kwh = (0,)*12, result.space_cooling_kwh_per_yr = 0.0, and every MonthlyEntry.space_cool_requirement_kwh = 0.0 for a typical SAP10 minimal cert. Co-Authored-By: Claude Opus 4.7 --- packages/domain/src/domain/sap/calculator.py | 15 ++++++++++ .../src/domain/sap/rdsap/cert_to_inputs.py | 29 ++++++++++++++++--- .../sap/rdsap/tests/test_cert_to_inputs.py | 21 ++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index aac1f13d..b59a24ed 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -129,6 +129,12 @@ class CalculatorInputs: secondary_heating_fraction: float = 0.0 secondary_heating_efficiency: float = 1.0 secondary_heating_fuel_cost_gbp_per_kwh: float = 0.0 + # SAP10.2 (107)m — space cooling requirement kWh per month from §8c + # orchestrator `space_cooling_monthly_kwh`. Includes spec Jun-Aug + # inclusion mask + 1-kWh clamp. Default (0,)*12 for backwards + # compatibility — every cert without `has_fixed_air_conditioning` + # collapses cooling to zero. + space_cooling_monthly_kwh: tuple[float, ...] = (0.0,) * 12 @dataclass(frozen=True) @@ -145,6 +151,7 @@ class MonthlyEntry: space_heat_requirement_kwh: float main_heating_fuel_kwh: float secondary_heating_fuel_kwh: float = 0.0 + space_cool_requirement_kwh: float = 0.0 @dataclass(frozen=True) @@ -159,6 +166,7 @@ class SapResult: total_fuel_cost_gbp: float co2_kg_per_yr: float space_heating_kwh_per_yr: float + space_cooling_kwh_per_yr: float main_heating_fuel_kwh_per_yr: float secondary_heating_fuel_kwh_per_yr: float hot_water_kwh_per_yr: float @@ -210,6 +218,10 @@ def _solve_month( else 0.0 ) + # SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh` + # (includes Jun-Aug inclusion mask + post-f_C × f_intermittent clamp). + q_cool = inputs.space_cooling_monthly_kwh[month - 1] + return MonthlyEntry( month=month, external_temp_c=t_ext, @@ -221,6 +233,7 @@ def _solve_month( space_heat_requirement_kwh=q_heat, main_heating_fuel_kwh=fuel_main, secondary_heating_fuel_kwh=fuel_secondary, + space_cool_requirement_kwh=q_cool, ) @@ -262,6 +275,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: ) space_heating_kwh = sum(e.space_heat_requirement_kwh for e in monthly) + space_cooling_kwh = sum(e.space_cool_requirement_kwh for e in monthly) main_fuel_kwh = sum(e.main_heating_fuel_kwh for e in monthly) secondary_fuel_kwh = sum(e.secondary_heating_fuel_kwh for e in monthly) delivered_fuel_kwh = ( @@ -377,6 +391,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: total_fuel_cost_gbp=total_cost, co2_kg_per_yr=co2, space_heating_kwh_per_yr=space_heating_kwh, + space_cooling_kwh_per_yr=space_cooling_kwh, main_heating_fuel_kwh_per_yr=main_fuel_kwh, secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh, hot_water_kwh_per_yr=inputs.hot_water_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 b06bde56..677737c3 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,7 @@ 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.space_cooling import space_cooling_monthly_kwh from domain.sap.worksheet.space_heating import space_heating_monthly_kwh from domain.sap.worksheet.ventilation import ( MechanicalVentilationKind, @@ -928,18 +929,35 @@ def cert_to_inputs( # SAP10.2 §8 — compose (98c)m via the orchestrator. Reuses the per-month # HTC + total-gains tuples already computed for §7 and adds T_int + η # from the MIT result. Includes the Table 9c step 10 summer clamp. + monthly_external_temp_c = tuple( + external_temperature_c(_region_index(epc.region_code), m) + for m in range(1, 13) + ) space_heating_result = space_heating_monthly_kwh( monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, monthly_internal_temperature_c=mit_result.adjusted_mean_internal_temp_monthly, - monthly_external_temperature_c=tuple( - external_temperature_c(_region_index(epc.region_code), m) - for m in range(1, 13) - ), + monthly_external_temperature_c=monthly_external_temp_c, monthly_utilisation_factor=mit_result.utilisation_factor_whole_monthly, monthly_total_gains_w=monthly_total_gains_w, total_floor_area_m2=dim.total_floor_area_m2, ) + # SAP10.2 §8c — compose (107)m via the orchestrator. RdSAP convention: + # `cooled_area_fraction = 0` always (the cert never lodges cooled-area + # data) and `cooling_gains = (0,)*12` until a real cooling-gains-from- + # cert helper lands. Both decisions deferred per SPEC_COVERAGE §8c row; + # for `has_fixed_air_conditioning=False` certs the f_C=0 zeros (107) + # regardless of gains so the stub is harmless. + space_cooling_result = space_cooling_monthly_kwh( + monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, + monthly_external_temperature_c=monthly_external_temp_c, + monthly_total_gains_w=(0.0,) * 12, + total_floor_area_m2=dim.total_floor_area_m2, + thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + cooled_area_fraction=0.0, + intermittency_factor=0.25, + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -962,6 +980,9 @@ def cert_to_inputs( # SAP10.2 (98c)m — total space heating kWh/month from §8 orchestrator # above (includes the spec Jun..Sep summer clamp). space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh, + # SAP10.2 (107)m — space cooling kWh/month from §8c orchestrator + # above (includes Jun-Aug inclusion mask + 1-kWh clamp). + space_cooling_monthly_kwh=space_cooling_result.space_cooling_monthly_kwh, region=_region_index(epc.region_code), control_type=control_type_value, responsiveness=responsiveness_value, 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 109b03d4..f9bbf3a7 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 @@ -292,6 +292,27 @@ def test_minimal_cert_round_trips_through_calculator_and_returns_sap_result() -> assert result.total_fuel_cost_gbp > 0 +def test_no_ac_cert_round_trips_to_zero_space_cooling_on_sap_result() -> None: + """RdSAP cert without a fixed air-conditioning system (the dominant + case) must wire through cert_to_inputs → calculator with the §8c + orchestrator producing all-zero cooling. SapResult.space_cooling_kwh_ + per_yr == 0.0 and every MonthlyEntry.space_cool_requirement_kwh == 0.0. + """ + # Arrange — has_fixed_air_conditioning is False by default on the + # SAP10 minimal heating fixture (mirrors every Elmhurst fixture). + epc = _typical_semi_detached_epc() + assert epc.sap_heating.has_fixed_air_conditioning is False + + # Act + inputs = cert_to_inputs(epc) + result = Sap10Calculator().calculate(epc) + + # Assert + assert inputs.space_cooling_monthly_kwh == (0.0,) * 12 + assert result.space_cooling_kwh_per_yr == 0.0 + assert all(entry.space_cool_requirement_kwh == 0.0 for entry in result.monthly) + + 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