§8c slice 3: CalculatorInputs + MonthlyEntry + SapResult + cert_to_inputs wiring (atomic)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-21 07:58:34 +00:00
parent 3b9fa936f0
commit f37970666e
3 changed files with 61 additions and 4 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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