Model/domain/sap10_calculator/calculator.py
Khalim Conn-Kowlessar 3f5cd550cb Thread the off-peak meter flag and high-rate fractions onto SapResult 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:45:29 +00:00

951 lines
47 KiB
Python
Raw Permalink 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, replace
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
# Main heating system 2's fuel cost per kWh (legacy/off-peak scalar path).
# main system 2 may burn a DIFFERENT fuel from main 1 (e.g. wood logs vs
# electric room heaters) — pricing its kWh at main 1's rate grossly mis-
# costs it. Defaults to 0.0; cert_to_inputs sets it to main 2's own rate
# when a second main is lodged (the term is multiplied by main 2's kWh,
# which is 0 when no second main exists, so the default is inert).
main_2_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
# Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
# code column) for ADR-0014 BillDerivation fuel attribution. Output-
# only — these do NOT feed ECF / cost / CO2 / primary energy /
# sap_score (the rating cascade already prices each end-use via the
# per-end-use cost/CO2/PE factor fields above). They tell the bill
# adapter WHICH fuel carrier each end-use burns. None when the
# corresponding system is absent (no main / no 2nd main / no
# secondary) or the water-heating fuel is not resolvable.
main_heating_fuel_code: Optional[int] = None
main_2_heating_fuel_code: Optional[int] = None
secondary_heating_fuel_code: Optional[int] = None
hot_water_fuel_code: Optional[int] = None
# Off-Peak Meter day/night billing metadata for ADR-0014 Bill Derivation —
# output-only (NOT fed into cost / CO2 / PE / sap_score, already priced via
# the per-end-use cost factor fields above). `is_off_peak_meter` routes every
# electric end use to the off-peak carrier; the per-end-use High-Rate
# Fractions (SAP 10.2 Table 12a) drive the day/night split. cert_to_inputs
# supplies them from the same Table-12a helpers the cost cascade uses;
# defaults (`False` + 1.0) keep synthetic / standard-tariff constructions a
# no-op. They thread byte-identical onto `SapResult`.
is_off_peak_meter: bool = False
main_heating_high_rate_fraction: float = 1.0
main_2_heating_high_rate_fraction: float = 1.0
secondary_heating_high_rate_fraction: float = 1.0
hot_water_high_rate_fraction: float = 1.0
pumps_fans_high_rate_fraction: float = 1.0
other_electricity_high_rate_fraction: float = 1.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
# Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
# code column) + annual PV export for ADR-0014 BillDerivation. Output-
# only metadata — these do NOT contribute to ecf / total_fuel_cost_gbp
# / co2_kg_per_yr / primary_energy_kwh_per_yr / sap_score. They tell
# the bill adapter WHICH fuel carrier each end-use burns; the fuel
# codes are None when the corresponding system is absent or the water-
# heating fuel is not resolvable. `pv_exported_kwh_per_yr` is the
# annual kWh exported to the grid (SAP 10.2 Appendix M1 §3-4 split),
# 0.0 when there is no PV.
main_heating_fuel_code: Optional[int]
main_2_heating_fuel_code: Optional[int]
secondary_heating_fuel_code: Optional[int]
hot_water_fuel_code: Optional[int]
pv_exported_kwh_per_yr: float
primary_energy_kwh_per_yr: float
primary_energy_kwh_per_m2: float
monthly: tuple[MonthlyEntry, ...]
intermediate: dict[str, float]
# Off-Peak Meter day/night billing metadata for ADR-0014 Bill Derivation
# (output-only — NOT fed into ecf / cost / CO2 / PE / sap_score, which the
# rating cascade already prices via the per-end-use cost factors). When the
# dwelling is on an off-peak meter, EVERY electric end use bills on it, each
# split day/night by its own **High-Rate Fraction** (SAP 10.2 Table 12a — the
# share billed at the day/high rate). The fractions are the calculator's own
# Table-12a values, surfaced so the bill reuses them rather than re-deriving
# a second day/night model. Defaults (`False` + 1.0) make a standard-tariff
# dwelling a no-op: the carrier stays `ELECTRICITY` and nothing splits.
is_off_peak_meter: bool = False
main_heating_high_rate_fraction: float = 1.0
main_2_heating_high_rate_fraction: float = 1.0
secondary_heating_high_rate_fraction: float = 1.0
hot_water_high_rate_fraction: float = 1.0
pumps_fans_high_rate_fraction: float = 1.0
# Lighting / appliances / cooking / cooling — the Grid 2 ALL_OTHER_USES split
# (appliances + cooking inherit it; SAP does not rate those unregulated loads).
other_electricity_high_rate_fraction: float = 1.0
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/(213)m/(215)m precomputed upstream by
# `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline.
# `main_heating_fuel_kwh` aggregates both main systems (213)m is zero
# for single-main certs, so this is the per-system sum for dual-main
# dwellings (cert 0240 / simulated case 6) and main-1 alone otherwise.
fuel_main = (
inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1]
+ inputs.energy_requirements.main_2_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_fuel_kwh` aggregates main systems 1 and 2 (see (211)+(213)
# above). Bill main 2 at ITS OWN fuel rate, not main 1's — a dual-fuel
# pair (e.g. electric room heaters + wood logs) is mis-costed otherwise
# (the §10a standard path already splits these; this is the legacy/
# off-peak scalar path). main 2's kWh is 0 when no second main is
# lodged, so the default 0.0 rate is inert.
main_2_fuel_kwh = inputs.energy_requirements.main_2_fuel_kwh_per_yr
main_1_fuel_kwh = main_fuel_kwh - main_2_fuel_kwh
main_heating_cost = (
main_1_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
+ main_2_fuel_kwh * inputs.main_2_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 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1 ("if the
# result of the calculation is less than 1, the rating is 1"). Apply the
# same floor to the continuous value so it stays a valid rating — the
# un-rounded part is for sensitivity NEAR real ratings, not for emitting
# a physically impossible negative SAP on a degenerate dwelling (e.g. a
# cert lodged at the floor of 1). Mirrors `sap_rating_integer`'s max(1,…).
sap_cont = max(1.0, 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,
main_heating_fuel_code=inputs.main_heating_fuel_code,
main_2_heating_fuel_code=inputs.main_2_heating_fuel_code,
secondary_heating_fuel_code=inputs.secondary_heating_fuel_code,
hot_water_fuel_code=inputs.hot_water_fuel_code,
pv_exported_kwh_per_yr=inputs.pv_exported_kwh_per_yr or 0.0,
primary_energy_kwh_per_yr=primary_energy_kwh,
primary_energy_kwh_per_m2=primary_energy_per_m2,
monthly=monthly,
intermediate=intermediate,
is_off_peak_meter=inputs.is_off_peak_meter,
main_heating_high_rate_fraction=inputs.main_heating_high_rate_fraction,
main_2_heating_high_rate_fraction=inputs.main_2_heating_high_rate_fraction,
secondary_heating_high_rate_fraction=inputs.secondary_heating_high_rate_fraction,
hot_water_high_rate_fraction=inputs.hot_water_high_rate_fraction,
pumps_fans_high_rate_fraction=inputs.pumps_fans_high_rate_fraction,
other_electricity_high_rate_fraction=inputs.other_electricity_high_rate_fraction,
)
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:
# SAP 10.2 Appendix U paragraph 1 (p.124): the SAP and EI ratings are
# computed on UK-average climate (so ratings are nationally
# comparable), but "other calculations (such as for energy use and
# costs on EPCs) are done using local weather" — the EPC-displayed
# CO2 emissions and primary energy use postcode-district weather from
# the PCDB. So we run two climate cascades and graft the demand
# cascade's CO2/PE onto the rating cascade's SAP result. (Worked
# example: simulated case 45 — rating SAP 60.53/CO2 692.13 on
# UK-average; demand CO2 626.78/PE 6581.59 on the W6 postcode.)
from domain.sap10_calculator.rdsap.cert_to_inputs import (
cert_to_demand_inputs,
cert_to_inputs,
)
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc))
return replace(
rating,
co2_kg_per_yr=demand.co2_kg_per_yr,
primary_energy_kwh_per_yr=demand.primary_energy_kwh_per_yr,
primary_energy_kwh_per_m2=demand.primary_energy_kwh_per_m2,
)