Model/domain/sap10_calculator/calculator.py
Khalim Conn-Kowlessar 2f039aeb39 Thread appliances + cooking annual kWh onto SapResult for ADR-0014 bills
ADR-0014 BillDerivation prices a per-end-use EnergyBreakdown
(HEATING / HOT_WATER / LIGHTING / PUMPS_FANS / APPLIANCES / COOKING).
SapResult already carried the first four but not appliances or cooking,
so a downstream SapResult→EnergyBreakdown adapter had to stub those two
at 0 kWh — understating the bill by the whole unregulated electricity
load. Surface them so the property_baseline side can wire the sections.

Adds two output-only fields to CalculatorInputs + SapResult, threaded
exactly like lighting_kwh_per_yr:
  appliances_kwh_per_yr  — SAP 10.2 Appendix L L13/L14/L16a annual E_A
                           (sum of the §5 (68) monthly appliances kWh)
  cooking_kwh_per_yr     — SAP 10.2 Appendix L L20 (p.91) ELECTRICITY
                           estimate E_cook = 138 + 28×N

Both values already existed in cert_to_inputs.py (appliances_monthly_kwh,
cooking_monthly_kwh) — reused, not recomputed.

Fuel attribution: cooking_kwh_per_yr is the L20 ELECTRICITY figure (the
field docstring says so), distinct from the L18 cooking heat GAIN
(35 + 7N W) the §5 internal-gains cascade uses. The bill adapter should
treat cooking as an electricity carrier; a gas-cooker split, if ever
needed, is a separate follow-up.

HARD CONSTRAINT honoured — output-only, zero rating drift. Appliances +
cooking are unregulated and are NOT fed into ECF / total_fuel_cost /
CO2 / primary energy / sap_score. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical (1165 rated pins green). The synthetic CalculatorInputs
fixtures set the new fields non-zero on purpose so the existing cost/PE
reconciliation assertions act as leak detectors.

New focused test asserts both fields are populated (non-zero) and
threaded unchanged onto SapResult, with cooking equal to the L20
electricity figure (138 + 28×occupancy) to 1e-9. pyright net-zero
111 → 111.

Note: 11 pre-existing failures in test_appendix_u.py / test_table_32.py
arrived with the recently absorbed PR and are unrelated to this change
(they fail identically on the clean branch); flagged separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:00:10 +00:00

803 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SAP 10.2 calculator orchestrator.
Drives the 12-month heat-balance loop from a typed `CalculatorInputs`
aggregate and emits a typed `SapResult`. This module is the physics
assembly only — the RdSAP cert→inputs mapping lives in
`domain.sap10_calculator.rdsap.cert_to_inputs`. Splitting the two keeps orchestration
testable against synthetic inputs without dragging in cert-shape
assumptions.
Per-month worksheet flow (§§5-13):
1. External temp / wind / horizontal solar from `monthly_external_
temp_c_override` tuple if set (postcode demand cascade), else
Appendix U Tables U1-U3 by region.
2. Internal gains (§5 + Appendix L) given TFA and month.
3. Solar gains (§6 + Appendix U §U3.2) summed over the window list.
4. HLC = HLC_T (already supplied) + HLC_V = ach × volume × 0.33.
5. Thermal time constant τ = TMP × TFA / (3.6 × HLC) for utilisation η.
6. Mean internal temperature (§7 + Table 9b/9c) and utilisation factor
(Table 9a) — supplied as monthly tuples from cert_to_inputs.
7. Useful space-heating requirement (Table 9c step 10).
8. Delivered fuel kWh = Q_heat / main-heating efficiency.
Annual aggregation:
- ECF = Table 12 deflator × total cost / (TFA + 45); SAP rating from
§13 piecewise log/linear (slice 23 — constants pinned by ADR-0010).
- CO2 per end-use uses per-end-use factors on CalculatorInputs:
gas end-uses (main, hot water) use the annual Table 12 factor;
electricity end-uses (secondary, pumps/fans, lighting, electric
shower) use the Σ(kWh_m × Table 12d_m) / Σ kWh_m effective annual.
- Primary Energy: same shape with Table 12 / Table 12e factors.
- Environmental Impact Rating from §14 (log/linear on CO2/m²).
The factor-per-end-use machinery is the slice-32/33 closure of the U985
Block 2 (demand cascade) §12 / §13a line refs. See
`worksheet/tests/test_section_cascade_pins.py` for the conformance suite.
Reference: SAP 10.2 specification (14-03-2025) §§5-14 (pages 23-44),
Tables 9a/9b/9c (pages 183-185), Table 12/12a/12d/12e (pages 191-195),
Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Final, Optional, TYPE_CHECKING
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
if TYPE_CHECKING:
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.sap10_calculator.worksheet.dimensions import Dimensions
from domain.sap10_calculator.worksheet.energy_requirements import EnergyRequirementsResult
from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult
from domain.sap10_calculator.worksheet.heat_transmission import HeatTransmission
from domain.sap10_calculator.worksheet.rating import (
ECF_LOG_THRESHOLD,
ENERGY_COST_DEFLATOR,
FLOOR_AREA_OFFSET_M2,
energy_cost_factor,
sap_rating,
sap_rating_integer,
)
_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,
)
# §10a default — used as `CalculatorInputs.fuel_cost` default for synthetic
# constructions that bypass cert_to_inputs. All-zero cost; calculator
# delegation falls through to the existing inline cost math when this is
# the default (slice 2a doesn't yet route through `inputs.fuel_cost`).
_ZERO_FUEL_COST_RESULT: Final[FuelCostResult] = FuelCostResult(
main_1_high_rate_fraction=1.0,
main_1_low_rate_fraction=0.0,
main_1_high_rate_cost_gbp=0.0,
main_1_low_rate_cost_gbp=0.0,
main_1_other_fuel_cost_gbp=0.0,
main_1_total_cost_gbp=0.0,
main_2_high_rate_fraction=1.0,
main_2_low_rate_fraction=0.0,
main_2_high_rate_cost_gbp=0.0,
main_2_low_rate_cost_gbp=0.0,
main_2_other_fuel_cost_gbp=0.0,
main_2_total_cost_gbp=0.0,
secondary_high_rate_fraction=1.0,
secondary_low_rate_fraction=0.0,
secondary_high_rate_cost_gbp=0.0,
secondary_low_rate_cost_gbp=0.0,
secondary_other_fuel_cost_gbp=0.0,
secondary_total_cost_gbp=0.0,
water_high_rate_fraction=1.0,
water_low_rate_fraction=0.0,
water_high_rate_cost_gbp=0.0,
water_low_rate_cost_gbp=0.0,
water_other_fuel_cost_gbp=0.0,
instant_shower_cost_gbp=0.0,
space_cooling_cost_gbp=0.0,
pumps_fans_cost_gbp=0.0,
lighting_cost_gbp=0.0,
additional_standing_charges_gbp=0.0,
pv_credit_gbp=0.0,
appendix_q_saved_gbp=0.0,
appendix_q_used_gbp=0.0,
total_cost_gbp=0.0,
)
@dataclass(frozen=True)
class CalculatorInputs:
"""Synthetic SAP 10.2 calculator inputs. The cert→inputs mapper
(S-A7b) produces one of these from an `EpcPropertyData`.
Fuel-cost fields are per-end-use because SAP §12 / Table 32 charges
different tariffs for space heating vs hot water vs lighting/pumps
depending on the dwelling's tariff (e.g. Economy-7 charges space
heating at the off-peak rate but lighting at standard). For single-
tariff dwellings the three fields are equal.
"""
dimensions: Dimensions
heat_transmission: HeatTransmission
# SAP10.2 (25)m — effective monthly air-change rate (12-tuple Jan..Dec).
# Per-month because ventilation HLC varies with wind speed (Table U2)
# and MV mode (§2 lines 24a-d). Constant-monthly inputs work too:
# pass `(ach,) * 12` to model a single rate across all months.
monthly_infiltration_ach: tuple[float, ...]
# SAP10.2 (73)m — total internal gains W per month (Jan..Dec).
# Per-month because lighting/appliances cosine-modulate and pumps/fans
# zero out in summer per Table 5a. Produced by §5 orchestrator
# `internal_gains_from_cert` (called from cert_to_inputs).
internal_gains_monthly_w: tuple[float, ...]
# SAP10.2 (83)m — total solar gains W per month (Jan..Dec). Produced
# by §6 orchestrator `solar_gains_from_cert` upstream; the calculator
# only indexes into it per month, no recomputation here.
solar_gains_monthly_w: tuple[float, ...]
# SAP10.2 (93)m — adjusted mean internal temperature °C per month, and
# (94)m — utilisation factor (whole-dwelling Ti) per month. Both come
# from §7 orchestrator `mean_internal_temperature_monthly` upstream.
# The calculator stops iterating η in _solve_month — Table 9c is a
# sequential chain (steps 1-9), not a fixed-point loop.
mean_internal_temp_monthly_c: tuple[float, ...]
utilisation_factor_monthly: tuple[float, ...]
# SAP10.2 (98c)m — total space heating requirement kWh per month from
# §8 orchestrator `space_heating_monthly_kwh`. Includes the spec summer
# clamp (Jun..Sep = 0). Calculator stops calling the per-month leaf
# `monthly_heat_requirement_kwh` directly; just indexes here.
space_heating_monthly_kwh: tuple[float, ...]
region: int
control_type: int
responsiveness: float
living_area_fraction: float
control_temperature_adjustment_c: float
thermal_mass_parameter_kj_per_m2_k: float
main_heating_efficiency: float
hot_water_kwh_per_yr: float
pumps_fans_kwh_per_yr: float
lighting_kwh_per_yr: float
# Unregulated annual delivered electricity — output-only, NOT fed
# into ECF / cost / CO2 / primary energy / sap_score (regulated
# energy only). Surfaced for ADR-0014 BillDerivation's APPLIANCES +
# COOKING sections. `cooking_kwh_per_yr` is the SAP 10.2 Appendix L
# L20 (p.91) ELECTRICITY figure (138 + 28×N), not the L18 cooking
# heat gain. `appliances_kwh_per_yr` is the L13/L14/L16a annual E_A.
appliances_kwh_per_yr: float
cooking_kwh_per_yr: float
space_heating_fuel_cost_gbp_per_kwh: float
hot_water_fuel_cost_gbp_per_kwh: float
other_fuel_cost_gbp_per_kwh: float
co2_factor_kg_per_kwh: float
# SAP 10.2 Table 12a Grid 2 split — MEV/MVHR fans on off-peak
# tariffs (7-hour: 0.71 high-frac; 10-hour: 0.58 high-frac) bill
# at a DIFFERENT blended rate than "all other uses" (7-hour: 0.90;
# 10-hour: 0.80). Cert_to_inputs supplies the MEV-kWh-weighted
# blended rate here for pumps_fans on off-peak; None on standard-
# tariff certs (no split applies) and on certs without MEV/MVHR.
# When None the legacy `other_fuel_cost_gbp_per_kwh` applies to
# the whole pumps_fans stream.
pumps_fans_fuel_cost_gbp_per_kwh: Optional[float] = None
# Pre-computed monthly external temperature (°C). When provided, the
# calculator's per-month solve uses this directly instead of looking up
# `external_temperature_c(region, month)`. Set by cert_to_inputs from
# either UK-average (rating cascade) or PCDB postcode (demand cascade).
monthly_external_temp_c_override: Optional[tuple[float, ...]] = None
# Per-end-use effective CO2 factors. For electricity end-uses with
# known monthly kWh distribution, cert_to_inputs computes the days-
# weighted average Table 12d factor: Σ(kWh_m × CO2_m) / Σ(kWh_m). Gas
# end-uses keep the annual factor. Default None → calculator falls
# back to the global `co2_factor_kg_per_kwh` (legacy synthetic path).
main_heating_co2_factor_kg_per_kwh: Optional[float] = None
secondary_heating_co2_factor_kg_per_kwh: Optional[float] = None
hot_water_co2_factor_kg_per_kwh: Optional[float] = None
pumps_fans_co2_factor_kg_per_kwh: Optional[float] = None
lighting_co2_factor_kg_per_kwh: Optional[float] = None
electric_shower_kwh_per_yr: float = 0.0
electric_shower_co2_factor_kg_per_kwh: Optional[float] = None
# Primary energy factors per end-use (Table 12 "Primary energy factor"
# column). Used by §14 to derive the cert's `energy_consumption_current`
# (which is PRIMARY energy per m²). For a single-fuel dwelling all
# three collapse to the same value.
space_heating_primary_factor: float = 1.0
hot_water_primary_factor: float = 1.0
# Standard-electricity PE factor per RdSAP10 Table 32 (p.95) / SAP10.2
# Table 12 = 1.501. Table 12e (p.195) provides monthly overrides — see
# the per-end-use PE factor fields below for the monthly cascade.
other_primary_factor: float = 1.501
# Per-end-use effective PE factors. For electricity end-uses with known
# monthly kWh distribution, cert_to_inputs computes the days-weighted
# Table 12e factor Σ(kWh_m × PE_m) / Σ(kWh_m). Gas end-uses keep the
# annual Table 12 factor. None → calculator falls back to the global
# `space_heating_primary_factor` / `hot_water_primary_factor` /
# `other_primary_factor` (legacy synthetic path).
secondary_heating_primary_factor: Optional[float] = None
pumps_fans_primary_factor: Optional[float] = None
lighting_primary_factor: Optional[float] = None
electric_shower_primary_factor: Optional[float] = None
# Generation offsets — applied as a cost credit against the ECF
# numerator. SAP 10.2 Appendix M: PV self-consumption + export
# collapse to a single credit at the export rate (Table 12 code 60).
pv_generation_kwh_per_yr: float = 0.0
pv_export_credit_gbp_per_kwh: float = 0.0
# SAP 10.2 Appendix M1 §3-4 PV onsite/export split. When both are
# set, the PE cascade (and follow-up CO2/cost wiring) applies
# IMPORT factors to the onsite-consumed portion and EXPORT factors
# to the exported portion. None → legacy fall-through that credits
# all PV at the IMPORT factor (over-credits the exported portion;
# used by synthetic CalculatorInputs constructions in unit tests).
pv_dwelling_kwh_per_yr: Optional[float] = None
pv_exported_kwh_per_yr: Optional[float] = None
# SAP 10.2 Appendix M1 §8 — per-cert PE factors for the PV split.
# Mirrors the §7 CO2 cascade shape: the dwelling factor is the
# effective monthly Table 12e IMPORT factor (Σ(E_PV,dw,m × PE_30,m) /
# Σ(E_PV,dw,m)); the exported factor is the effective monthly
# Table 12e factor for code 60 ("electricity sold to grid, PV").
# Both are precomputed in cert_to_inputs from the PV split. None
# falls back to the legacy annual values: `other_primary_factor`
# (1.501, standard electricity) for the dwelling portion and
# `pv_export_primary_factor` (0.501) for the exported portion —
# preserves synthetic CalculatorInputs constructions.
pv_dwelling_primary_factor: Optional[float] = None
pv_exported_primary_factor: Optional[float] = None
# Legacy annual fall-back for the exported PE factor (synthetic
# constructions or zero-export months that yield no effective
# monthly value). SAP 10.2 Table 12 code 60 = 0.501.
pv_export_primary_factor: float = 0.501
# SAP 10.2 Appendix M1 §6 (p.94) — IMPORT price for onsite-consumed
# PV generation. cert_to_inputs supplies this from Table 12a (standard
# tariff or weighted off-peak per the dwelling's meter); synthetic
# constructions leave it None to fall back to the legacy single-rate
# credit at the EXPORT price. When set, the calculator's synthetic
# cost fallback (the `fuel_cost is _ZERO` branch) credits onsite kWh
# at this IMPORT price and exported kWh at `pv_export_credit_gbp_per_kwh`.
pv_dwelling_import_price_gbp_per_kwh: Optional[float] = None
# SAP 10.2 Appendix M1 §7 — per-cert CO2 factors for the PV split.
# The dwelling factor is the effective monthly Table 12d IMPORT
# factor (Σ(E_PV,dw,m × CO2_30,m) / Σ(E_PV,dw,m)); the exported
# factor is the effective monthly Table 12d code-60 ("electricity
# sold to grid, PV") factor. Both are computed in cert_to_inputs.
# Synthetic CalculatorInputs constructions leave these None → no
# PV CO2 credit applied (legacy behaviour).
pv_dwelling_co2_factor_kg_per_kwh: Optional[float] = None
pv_exported_co2_factor_kg_per_kwh: Optional[float] = None
# Secondary heating — SAP 10.2 Table 11 routes a fraction of space
# heating demand to a secondary system (0.10 for gas/oil/solid main
# systems; 0.15-0.20 for electric room/storage heaters). Fraction
# 0.0 disables secondary handling (default for ports that don't yet
# split heating).
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
# SAP10.2 (109) — Fabric Energy Efficiency precomputed by cert_to_inputs
# via `fabric_energy_efficiency_kwh_per_m2_yr` from the §8/§8c results.
# 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
)
# SAP10.2 §10a — fuel-cost line refs (240)..(255) precomputed by
# cert_to_inputs via `fuel_cost(...)`. Default zero result so non-
# cert constructions keep working through the inline cost math
# (calculator routes through `inputs.fuel_cost.total_cost_gbp` only
# when the precompute lodges a non-zero `total_cost_gbp`).
fuel_cost: FuelCostResult = field(
default_factory=lambda: _ZERO_FUEL_COST_RESULT
)
# Table 32 standing charges (electric off-peak high-rate code +
# mains gas) — added to `total_cost` when the calculator's off-
# peak fallback path fires. STANDARD-tariff certs route through
# `fuel_cost.additional_standing_charges_gbp` instead and ignore
# this field. cert_to_inputs sets this via `additional_standing_
# charges_gbp(main_fuel_code, water_heating_fuel_code, tariff)`.
standing_charges_gbp: float = 0.0
@dataclass(frozen=True)
class MonthlyEntry:
"""Per-month worksheet outputs for downstream audit. SAP 10.2 §§5-9."""
month: int
external_temp_c: float
internal_temp_c: float
internal_gains_w: float
solar_gains_w: float
heat_loss_rate_w: float
utilisation_factor: float
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)
class SapResult:
"""Calculator output. `sap_score` is the rounded RdSAP-style integer
(1-100+); `sap_score_continuous` keeps the un-rounded value for
sensitivity analysis."""
sap_score: int
sap_score_continuous: float
ecf: float
total_fuel_cost_gbp: float
co2_kg_per_yr: float
space_heating_kwh_per_yr: float
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
# Unregulated annual delivered electricity for ADR-0014
# BillDerivation (APPLIANCES + COOKING sections). Output-only — these
# do NOT contribute to ecf / total_fuel_cost_gbp / co2_kg_per_yr /
# primary_energy_kwh_per_yr / sap_score. `cooking_kwh_per_yr` is the
# SAP 10.2 Appendix L L20 (p.91) ELECTRICITY estimate (138 + 28×N);
# the bill adapter should treat it as an electricity carrier (a
# gas-cooker split, if ever needed, is a separate follow-up).
appliances_kwh_per_yr: float
cooking_kwh_per_yr: float
primary_energy_kwh_per_yr: float
primary_energy_kwh_per_m2: float
monthly: tuple[MonthlyEntry, ...]
intermediate: dict[str, float]
def _time_constant_h(*, tmp_kj_per_m2_k: float, tfa_m2: float, hlc_w_per_k: float) -> float:
if hlc_w_per_k <= 0:
return float("inf")
return tmp_kj_per_m2_k * tfa_m2 / (_TIME_CONSTANT_DIVISOR_KJ_TO_WH * hlc_w_per_k)
def _solve_month(
*,
inputs: CalculatorInputs,
month: int,
hlc_w_per_k: float,
time_constant_h: float,
heat_loss_parameter: float,
) -> MonthlyEntry:
t_ext = (
inputs.monthly_external_temp_c_override[month - 1]
if inputs.monthly_external_temp_c_override is not None
else external_temperature_c(inputs.region, month)
)
g_int = inputs.internal_gains_monthly_w[month - 1]
g_sol = inputs.solar_gains_monthly_w[month - 1]
# SAP 10.2 §7 Table 9c is a sequential chain (steps 1-9); the §7
# orchestrator computes (93)m and (94)m upstream and the calculator
# consumes them by index. No fixed-point iteration here.
_ = time_constant_h # τ now lives inside the §7 orchestrator
_ = heat_loss_parameter
t_int = inputs.mean_internal_temp_monthly_c[month - 1]
eta = inputs.utilisation_factor_monthly[month - 1]
loss_rate_w = max(0.0, hlc_w_per_k * (t_int - t_ext))
# 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]
# 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).
q_cool = inputs.space_cooling_monthly_kwh[month - 1]
return MonthlyEntry(
month=month,
external_temp_c=t_ext,
internal_temp_c=t_int,
internal_gains_w=g_int,
solar_gains_w=g_sol,
heat_loss_rate_w=loss_rate_w,
utilisation_factor=eta,
space_heat_requirement_kwh=q_heat,
main_heating_fuel_kwh=fuel_main,
secondary_heating_fuel_kwh=fuel_secondary,
space_cool_requirement_kwh=q_cool,
)
def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
"""Run SAP 10.2 §§5-13 monthly loop on synthetic inputs; return a
typed `SapResult`. Cert-shape mapping is the job of `cert_to_inputs`
(S-A7b); this entry point is pure physics."""
tfa = inputs.dimensions.total_floor_area_m2
volume = inputs.dimensions.volume_m3
transmission_hlc = inputs.heat_transmission.total_w_per_k
# SAP10.2 §3 line (38): ventilation HLC = 0.33 × (25)m × volume —
# monthly because (25)m varies with Table U2 wind. HLC, HLP, and the
# time constant τ all become 12-tuples.
monthly_hlc_v = tuple(
ach * volume * _AIR_HEAT_CAPACITY_WH_PER_M3_K
for ach in inputs.monthly_infiltration_ach
)
monthly_hlc = tuple(transmission_hlc + hv for hv in monthly_hlc_v)
monthly_hlp = tuple(h / tfa if tfa > 0 else 0.0 for h in monthly_hlc)
monthly_tau_h = tuple(
_time_constant_h(
tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k,
tfa_m2=tfa,
hlc_w_per_k=h,
)
for h in monthly_hlc
)
monthly = tuple(
_solve_month(
inputs=inputs,
month=m,
hlc_w_per_k=monthly_hlc[m - 1],
time_constant_h=monthly_tau_h[m - 1],
heat_loss_parameter=monthly_hlp[m - 1],
)
for m in range(1, 13)
)
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 = (
main_fuel_kwh
+ secondary_fuel_kwh
+ inputs.hot_water_kwh_per_yr
+ inputs.pumps_fans_kwh_per_yr
+ inputs.lighting_kwh_per_yr
)
# SAP10.2 §10a Fuel costs — line refs (240)..(255) precomputed by
# cert_to_inputs._fuel_cost via the worksheet/fuel_cost orchestrator
# (Table 32 prices, Table 12a fractions, Table 12 note (a) standing-
# charge gating). Calculator unpacks the precompute when populated;
# synthetic-test CalculatorInputs constructions that leave the slot
# at its zero default still use the legacy inline cost math (scalar
# cost fields × kWh). That legacy path is slated for removal once
# the synthetic test corpus migrates to `fuel_cost=` (future ticket).
if inputs.fuel_cost is not _ZERO_FUEL_COST_RESULT and (
inputs.fuel_cost.total_cost_gbp != 0.0
or inputs.fuel_cost.additional_standing_charges_gbp != 0.0
):
fuel_cost_result = inputs.fuel_cost
total_cost = fuel_cost_result.total_cost_gbp
main_heating_cost = (
fuel_cost_result.main_1_total_cost_gbp
+ fuel_cost_result.main_2_total_cost_gbp
)
secondary_heating_cost = fuel_cost_result.secondary_total_cost_gbp
hot_water_cost = (
fuel_cost_result.water_high_rate_cost_gbp
+ fuel_cost_result.water_low_rate_cost_gbp
+ fuel_cost_result.water_other_fuel_cost_gbp
)
pumps_fans_cost = fuel_cost_result.pumps_fans_cost_gbp
lighting_cost = fuel_cost_result.lighting_cost_gbp
pv_credit = -fuel_cost_result.pv_credit_gbp
else:
# SAP 10.2 Appendix M1 §6 — synthetic-path β-split credit. When
# cert_to_inputs supplies the split (E_PV,dw + E_PV,ex + dwelling
# IMPORT price) credit onsite kWh at IMPORT and exported kWh at
# EXPORT; otherwise fall through to the legacy single-rate credit
# at the EXPORT price (preserves unit-test fixtures that lodge
# only `pv_generation_kwh_per_yr` + `pv_export_credit_gbp_per_kwh`).
if (
inputs.pv_dwelling_kwh_per_yr is not None
and inputs.pv_exported_kwh_per_yr is not None
and inputs.pv_dwelling_import_price_gbp_per_kwh is not None
):
pv_credit = (
inputs.pv_dwelling_kwh_per_yr
* inputs.pv_dwelling_import_price_gbp_per_kwh
+ inputs.pv_exported_kwh_per_yr
* inputs.pv_export_credit_gbp_per_kwh
)
else:
pv_credit = (
inputs.pv_generation_kwh_per_yr
* inputs.pv_export_credit_gbp_per_kwh
)
main_heating_cost = main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
secondary_heating_cost = (
secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh
)
hot_water_cost = (
inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
)
pumps_fans_rate = (
inputs.pumps_fans_fuel_cost_gbp_per_kwh
if inputs.pumps_fans_fuel_cost_gbp_per_kwh is not None
else inputs.other_fuel_cost_gbp_per_kwh
)
pumps_fans_cost = inputs.pumps_fans_kwh_per_yr * pumps_fans_rate
lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
# SAP 10.2 §10a (PDF p.145) line (247a): instantaneous electric
# showers route their (64a) kWh through the "other fuel" tariff
# and add to (255) total cost. The `fuel_cost`-based path above
# already includes this via `instant_shower_cost_gbp`; the
# fallback scalar path was silently dropping it on TEN_HOUR /
# zero-fuel-cost certs (cert 000565 surfaced this as a £93
# under-count once the upstream Elmhurst extractor began
# reporting the shower roster correctly).
electric_shower_cost = (
inputs.electric_shower_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
)
total_cost = max(
0.0,
main_heating_cost
+ secondary_heating_cost
+ hot_water_cost
+ electric_shower_cost
+ pumps_fans_cost
+ lighting_cost
+ inputs.standing_charges_gbp
- pv_credit,
)
ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa)
sap_int = sap_rating_integer(ecf=ecf)
sap_cont = sap_rating(ecf=ecf)
co2_factor = inputs.co2_factor_kg_per_kwh
# Per-end-use effective CO2 factors (Table 12d monthly cascade for
# electricity, annual for gas). cert_to_inputs supplies these from
# monthly kWh × monthly Table 12d factors; synthetic constructions
# without per-end-use values fall back to the legacy single factor.
main_co2_factor = inputs.main_heating_co2_factor_kg_per_kwh or co2_factor
secondary_co2_factor = inputs.secondary_heating_co2_factor_kg_per_kwh or co2_factor
hot_water_co2_factor = inputs.hot_water_co2_factor_kg_per_kwh or co2_factor
pumps_fans_co2_factor = inputs.pumps_fans_co2_factor_kg_per_kwh or co2_factor
lighting_co2_factor = inputs.lighting_co2_factor_kg_per_kwh or co2_factor
electric_shower_co2_factor = (
inputs.electric_shower_co2_factor_kg_per_kwh or co2_factor
)
main_heating_co2 = main_fuel_kwh * main_co2_factor
secondary_heating_co2 = secondary_fuel_kwh * secondary_co2_factor
hot_water_co2 = inputs.hot_water_kwh_per_yr * hot_water_co2_factor
pumps_fans_co2 = inputs.pumps_fans_kwh_per_yr * pumps_fans_co2_factor
lighting_co2 = inputs.lighting_kwh_per_yr * lighting_co2_factor
electric_shower_co2 = (
inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor
)
co2 = (
main_heating_co2
+ secondary_heating_co2
+ hot_water_co2
+ pumps_fans_co2
+ lighting_co2
+ electric_shower_co2
)
# SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption
# offsets grid imports at the IMPORT CO2 factor (Table 12d weighted
# by E_PV,dw,m); exports credit at the EXPORT CO2 factor (Table 12d
# code 60 weighted by E_PV,ex,m). Both factors are precomputed in
# cert_to_inputs; None preserves the legacy zero-credit behaviour
# for synthetic CalculatorInputs constructions.
if (
inputs.pv_dwelling_kwh_per_yr is not None
and inputs.pv_dwelling_co2_factor_kg_per_kwh is not None
):
co2 -= (
inputs.pv_dwelling_kwh_per_yr
* inputs.pv_dwelling_co2_factor_kg_per_kwh
)
if (
inputs.pv_exported_kwh_per_yr is not None
and inputs.pv_exported_co2_factor_kg_per_kwh is not None
):
co2 -= (
inputs.pv_exported_kwh_per_yr
* inputs.pv_exported_co2_factor_kg_per_kwh
)
# Per-end-use effective PE factors. Same shape as the CO2 cascade:
# electricity end-uses use Table 12e (p.195) monthly factors weighted
# by per-month kWh; gas end-uses use the annual Table 12 / Table 32
# PE factor. Defaults fall back to the legacy single-factor path so
# synthetic CalculatorInputs constructions keep working.
secondary_primary_factor = (
inputs.secondary_heating_primary_factor
if inputs.secondary_heating_primary_factor is not None
else inputs.space_heating_primary_factor
)
pumps_fans_primary_factor = (
inputs.pumps_fans_primary_factor
if inputs.pumps_fans_primary_factor is not None
else inputs.other_primary_factor
)
lighting_primary_factor = (
inputs.lighting_primary_factor
if inputs.lighting_primary_factor is not None
else inputs.other_primary_factor
)
electric_shower_primary_factor = (
inputs.electric_shower_primary_factor
if inputs.electric_shower_primary_factor is not None
else inputs.other_primary_factor
)
space_heating_primary_kwh = (
main_fuel_kwh * inputs.space_heating_primary_factor
+ secondary_fuel_kwh * secondary_primary_factor
)
hot_water_primary_kwh = inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor
other_primary_kwh = (
inputs.pumps_fans_kwh_per_yr * pumps_fans_primary_factor
+ inputs.lighting_kwh_per_yr * lighting_primary_factor
+ inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor
)
# SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT
# PEF (offsets grid imports); PV exports credit at the EXPORT PEF
# ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When
# the cert→inputs cascade has computed the β-split (§3-4 in
# `domain.sap10_calculator.worksheet.photovoltaic`), use it; fall
# back to all-IMPORT for synthetic CalculatorInputs constructions
# in unit tests (which don't supply the split).
if (
inputs.pv_dwelling_kwh_per_yr is not None
and inputs.pv_exported_kwh_per_yr is not None
):
pv_dwelling_pe_factor = (
inputs.pv_dwelling_primary_factor
if inputs.pv_dwelling_primary_factor is not None
else inputs.other_primary_factor
)
pv_exported_pe_factor = (
inputs.pv_exported_primary_factor
if inputs.pv_exported_primary_factor is not None
else inputs.pv_export_primary_factor
)
pv_primary_offset_kwh = (
inputs.pv_dwelling_kwh_per_yr * pv_dwelling_pe_factor
+ inputs.pv_exported_kwh_per_yr * pv_exported_pe_factor
)
else:
pv_primary_offset_kwh = (
inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor
)
primary_energy_kwh = max(
0.0,
space_heating_primary_kwh
+ hot_water_primary_kwh
+ other_primary_kwh
- pv_primary_offset_kwh,
)
primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0
ht = inputs.heat_transmission
intermediate: dict[str, float] = {
"tfa_m2": inputs.dimensions.total_floor_area_m2,
"volume_m3": inputs.dimensions.volume_m3,
"storey_count": float(inputs.dimensions.storey_count),
"walls_w_per_k": ht.walls_w_per_k,
"roof_w_per_k": ht.roof_w_per_k,
"floor_w_per_k": ht.floor_w_per_k,
"party_walls_w_per_k": ht.party_walls_w_per_k,
"windows_w_per_k": ht.windows_w_per_k,
"roof_windows_w_per_k": ht.roof_windows_w_per_k,
"doors_w_per_k": ht.doors_w_per_k,
"thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k,
# Annual means for the back-compat single-float audit dict; full
# monthly arrays are available via the upstream VentilationResult.
"infiltration_ach": sum(inputs.monthly_infiltration_ach) / 12.0,
"infiltration_w_per_k": sum(monthly_hlc_v) / 12.0,
"heat_transfer_coefficient_w_per_k": sum(monthly_hlc) / 12.0,
"heat_loss_parameter_w_per_m2k": sum(monthly_hlp) / 12.0,
"time_constant_h": sum(monthly_tau_h) / 12.0,
"internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0,
"mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0,
"useful_space_heating_kwh_per_yr": space_heating_kwh,
"main_heating_cost_gbp": main_heating_cost,
"secondary_heating_cost_gbp": secondary_heating_cost,
"hot_water_cost_gbp": hot_water_cost,
"pumps_fans_cost_gbp": pumps_fans_cost,
"lighting_cost_gbp": lighting_cost,
"pv_export_credit_gbp": pv_credit,
"ecf": ecf,
"deflator": ENERGY_COST_DEFLATOR,
"delivered_fuel_kwh_per_yr": delivered_fuel_kwh,
"co2_factor_kg_per_kwh": co2_factor,
"main_heating_co2_kg_per_yr": main_heating_co2,
"secondary_heating_co2_kg_per_yr": secondary_heating_co2,
"hot_water_co2_kg_per_yr": hot_water_co2,
"pumps_fans_co2_kg_per_yr": pumps_fans_co2,
"lighting_co2_kg_per_yr": lighting_co2,
"space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0,
"hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0,
"other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0,
"pv_pe_offset_kwh_per_m2": pv_primary_offset_kwh / tfa if tfa > 0 else 0.0,
"floor_area_offset_m2": FLOOR_AREA_OFFSET_M2,
"ecf_log_threshold": ECF_LOG_THRESHOLD,
}
return SapResult(
sap_score=sap_int,
sap_score_continuous=sap_cont,
ecf=ecf,
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,
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,
appliances_kwh_per_yr=inputs.appliances_kwh_per_yr,
cooking_kwh_per_yr=inputs.cooking_kwh_per_yr,
primary_energy_kwh_per_yr=primary_energy_kwh,
primary_energy_kwh_per_m2=primary_energy_per_m2,
monthly=monthly,
intermediate=intermediate,
)
class SapCalculator(ABC):
"""The contract a SAP calculator satisfies: an `EpcPropertyData` in, a
typed `SapResult` out. `Sap10Calculator` is the SAP 10.2 implementation;
a future methodology (e.g. SAP 10.3 / a successor) is another subclass.
Consumers (e.g. `CalculatorRebaseliner`) depend on this abstraction, not
on a concrete calculator — so the engine can be swapped without touching
them.
"""
@abstractmethod
def calculate(self, epc: "EpcPropertyData") -> SapResult: ...
class Sap10Calculator(SapCalculator):
"""Deterministic SAP 10.2 calculator entry point. Maps an
`EpcPropertyData` to typed `CalculatorInputs` via the RdSAP-driven
`cert_to_inputs` mapper and runs the 12-month worksheet loop.
Separating mapping (cert-shape rules, RdSAP defaults) from the
physics orchestration (`calculate_sap_from_inputs`) lets either side
be tested without dragging in the other — and lets product code that
already has a populated `CalculatorInputs` (e.g. a future
MeasureApplicator that emits modified inputs) skip the mapper.
"""
def calculate(self, epc: "EpcPropertyData") -> SapResult:
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
return calculate_sap_from_inputs(cert_to_inputs(epc))