§9a slice 2: CalculatorInputs.energy_requirements + cert_to_inputs wiring + SapResult fields + _solve_month refactor (atomic)

Path (i) — cert_to_inputs precompute. cert_to_inputs calls space_heating_fuel_monthly_kwh from local SpaceHeatingResult + Table 11 secondary fraction + per-system efficiencies; stashes the EnergyRequirementsResult on new `CalculatorInputs.energy_requirements` composite slot (default = _ZERO_ENERGY_REQUIREMENTS_RESULT).

_solve_month stops doing q/η inline — reads precomputed (211)m / (215)m fuel tuples directly via `inputs.energy_requirements.{main_1,secondary}_fuel_monthly_kwh[m-1]`. Existing `CalculatorInputs.main_heating_efficiency` / `.secondary_heating_efficiency` / `.secondary_heating_fraction` stay on the dataclass as inputs to the orchestrator (now redundant for the calculator's read path; kept for audit + backwards compat).

SapResult gains flat `main_2_heating_fuel_kwh_per_yr` and `space_cooling_fuel_kwh_per_yr` scalars — both zero in scope A, populated by future two-main + Table 10c SEER slices.

Round-trip test pins `inputs.energy_requirements.main_1_fuel_kwh_per_yr == result.main_heating_fuel_kwh_per_yr` to float equality (no rounding from the cert→inputs hop) and asserts scope-A scalars stay zero. PDF-derived ALL_FIXTURES pinning (Q5(α) grilling decision) blocked on PCDB integration — flagged in PCDB gap-list entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-21 08:39:26 +00:00
parent 2b5fc6a575
commit 380b6781e8
3 changed files with 92 additions and 16 deletions

View file

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

View file

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

View file

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