Model/domain/sap10_calculator/calculator.py
Khalim Conn-Kowlessar 8452cf9e2d S0380.180: heat-network distribution pumping electricity (§C3.2) — closes CH1
SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and
Primary Energy associated with the electricity used for pumping water
through the distribution system are allowed for by adding electrical
energy equal to 1% of the energy required for space and water heating."

Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472)
bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity
for pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a). (307)m/(310)m = (space_demand +
hw_output) / efficiency (the cascade models a heat network's generator
efficiency as 1/DLF).

This un-defers the (372)/(472) front the post-S0380.179 handover flagged
"don't guess until the factor source is identified": the source is
§C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent
0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet
shows the (372) energy column as 0.01×(307) (space only) while computing
emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by-
line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating),
(472)=208.2267 PE (demand).

New `_heat_network_distribution_electricity` helper (gated on
`_is_heat_network_main`) precomputes the energy + effective CO2/PE
factors; three new CalculatorInputs fields + calculator.py CO2/PE
summation terms (0.0/None → no-op for individually-heated certs).

Closures:
  CH1 (Boilers/Gas)  CO2 −23.60→−0.00, PE −208.23→+0.00  — FULLY EXACT
  CH3 (HP/Elec)      CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution
                     component closed; code-304 community-HP COP remains)
  CH2/CH4/CH6        gain their (372)/(472) component (CO2 +23.6, PE
                     +208.2); dominant CHP displaced-electricity credit
                     residual (Table 12f + block 12b/13b) is next slice.

No regression on the other 36 corpus variants (helper returns None off
heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0
fail; pyright net-zero 43→43.

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

831 lines
39 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
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity. For community-heating mains the network pump
# energy = 1% of (space + water) heat generated (worksheet (313));
# its CO2 / PE (worksheet (372)/(472)) bill on Table 12d/12e monthly
# electricity factors (fuel code 50) weighted by the monthly heat
# profile. The energy + effective factors are precomputed in
# cert_to_inputs. 0.0 / None for individually-heated certs (no
# distribution loop) leaves the cascade unchanged.
heat_network_distribution_kwh_per_yr: float = 0.0
heat_network_distribution_co2_factor_kg_per_kwh: Optional[float] = None
heat_network_distribution_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
)
# SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (372) — electricity
# for pumping water through a heat network's distribution system.
# Zero for individually-heated certs (factor None → 0.0).
heat_network_distribution_co2 = (
inputs.heat_network_distribution_kwh_per_yr
* (inputs.heat_network_distribution_co2_factor_kg_per_kwh or 0.0)
)
co2 = (
main_heating_co2
+ secondary_heating_co2
+ hot_water_co2
+ pumps_fans_co2
+ lighting_co2
+ electric_shower_co2
+ heat_network_distribution_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 C §C3.2 (PDF p.51) worksheet (472) — heat-network
# distribution pumping electricity primary energy (CO2 sister above).
heat_network_distribution_primary_kwh = (
inputs.heat_network_distribution_kwh_per_yr
* (inputs.heat_network_distribution_primary_factor or 0.0)
)
# 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
+ heat_network_distribution_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,
"heat_network_distribution_co2_kg_per_yr": heat_network_distribution_co2,
"heat_network_distribution_pe_kwh_per_yr": heat_network_distribution_primary_kwh,
"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))