mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
803 lines
37 KiB
Python
803 lines
37 KiB
Python
"""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))
|