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

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

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

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

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

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

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

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

6366 lines
286 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.

"""RdSAP 10 cert → SAP 10.2 CalculatorInputs mapping.
Reads `EpcPropertyData` (the gov EPC API / site-notes domain model) and
produces the typed `CalculatorInputs` the deterministic calculator
consumes. The boundary between this module and `calculator.py` is the
cleanest one: cert-shape assumptions and RdSAP defaulting rules stay
here; physics stays in `calculator.py` + `worksheet/*`.
Two cascades, two climate sources (per SAP10.2 Appendix U p.124):
* `cert_to_inputs(epc)` — RATING cascade, UK-average climate. Produces
the SAP rating and EI rating that the EPC publishes.
* `cert_to_demand_inputs(epc)` — DEMAND cascade, postcode-district
climate via PCDB Table 172. Produces the EPC's published "Current
Carbon", "Current Primary Energy", and (eventually) fuel bill.
Each cascade also exposes per-section helpers — `*_section_from_cert(epc,
postcode_climate=None)` — for §1..§13a worksheet line-ref pinning. The
section helpers map 1:1 to U985 worksheet sections; see
`worksheet/tests/test_section_cascade_pins.py` for the conformance suite.
Defaulting rules per RdSAP 10 (10-06-2025):
- Dimensions: §3 → `worksheet/dimensions.py`
- Heat transmission: §5 → `worksheet/heat_transmission.py`
- Infiltration: §4 Table 5 → `worksheet/ventilation.py`
- Living-area fraction: Table 27 by `habitable_rooms_count` (with §15
2-d.p. area rounding, see slice-26 docstring on `_living_area_fraction`)
- Heating efficiency: SAP 10.2 Tables 4a/4b + PCDB Table 105 override
- Hot-water demand: Appendix J full cascade (`worksheet/water_heating.py`)
- Lighting demand: Appendix L L1-L11 (`worksheet/internal_gains.py`)
- Fuel unit cost: RdSAP10 Table 32 (pence/kWh → £/kWh here)
- CO2 factors: Table 12 annual (gas) + Table 12d monthly (electricity)
- PE factors: Table 12 annual (gas) + Table 12e monthly (electricity)
Edge cases deliberately deferred (no fixture exercises):
- conservatory modes (`has_conservatory`)
- multi-fuel weighted unit cost (main-fuel only — Table 11 secondary split
IS implemented for kWh / CO2 / PE / fuel-cost paths)
- thermal mass parameter from construction type (defaults to medium 250)
- control_temperature_adjustment from main_heating_control code 2101/2103/2106
(defaults to 0; all 6 Elmhurst fixtures lodge 0)
- Table 12a off-peak tariff high-rate-fraction split (STANDARD-tariff only)
- BEDF (postcode-specific) fuel prices (Table 32 amendment prices only)
Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification
(14-03-2025) Tables 4a/4b/4e/12/12d/12e; PCDB10 data file Table 172
(postcode weather) + Table 105 (gas/oil boilers).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, replace
from decimal import ROUND_HALF_UP, Decimal
from typing import Callable, Final, Literal, Optional
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
PhotovoltaicArray,
SapBuildingPart,
SapVentilation,
SapWindow,
)
from domain.sap10_ml.demand import predicted_hot_water_kwh
from domain.sap10_ml.rdsap_uvalues import Country, u_floor
from domain.sap10_ml.sap_efficiencies import (
seasonal_efficiency,
water_heating_efficiency as _legacy_water_heating_efficiency,
)
from domain.sap10_calculator.calculator import CalculatorInputs
from domain.sap10_calculator.tables.pcdb import (
decentralised_mev_record,
gas_oil_boiler_record,
heat_pump_record,
mv_in_use_factors_record,
)
from domain.sap10_calculator.tables.pcdb.parser import (
GasOilBoilerRecord,
HeatPumpRecord,
)
from domain.sap10_calculator.tables.pcdb.postcode_weather import (
PostcodeClimate,
postcode_climate,
)
from domain.sap10_calculator.tables.table_12 import (
API_FUEL_TO_TABLE_12,
CO2_KG_PER_KWH,
PRIMARY_ENERGY_FACTOR,
_DEFAULT_CO2_KG_PER_KWH, # pyright: ignore[reportPrivateUsage]
_DEFAULT_PEF, # pyright: ignore[reportPrivateUsage]
co2_monthly_factors_kg_per_kwh,
co2_factor_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
primary_energy_factor,
)
from domain.sap10_calculator.tables.table_12a import (
OtherUse,
Table12aSystem,
Tariff,
other_use_high_rate_fraction,
rdsap_tariff_for_cert,
space_heating_high_rate_fraction,
tariff_from_meter_type,
)
from domain.sap10_calculator.tables.table_32 import (
additional_standing_charges_gbp,
is_electric_fuel_code,
is_liquid_fuel_code,
standing_charge_gbp,
unit_price_p_per_kwh as table_32_unit_price_p_per_kwh,
)
from domain.sap10_calculator.tables.table_4b import (
table_4b_seasonal_efficiencies_pct,
)
from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult, fuel_cost
from domain.sap10_calculator.worksheet.rating import (
ENERGY_COST_DEFLATOR,
energy_cost_factor,
environmental_impact_rating,
sap_rating,
sap_rating_integer,
)
from domain.sap10_calculator.worksheet.dimensions import dimensions_from_cert
from domain.sap10_calculator.worksheet.mev import (
MevFanEntry,
mev_decentralised_kwh_per_yr,
mev_sfp_av,
)
from domain.sap10_calculator.worksheet.internal_gains import (
InternalGainsResult,
OvershadingCategory,
internal_gains_from_cert,
)
from domain.sap10_calculator.worksheet.solar_gains import (
ORIENTATION_BY_SAP10_CODE,
Orientation,
RoofWindowInput,
SolarGainsResult,
solar_gains_from_cert,
surface_solar_flux_w_per_m2,
)
from domain.sap10_calculator.worksheet.appendix_h_solar import (
solar_water_heating_input_monthly_kwh,
)
from domain.sap10_calculator.worksheet.heat_transmission import (
DwellingExposure,
HeatTransmission,
_round_half_up,
heat_transmission_from_cert,
)
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap10_calculator.worksheet.mean_internal_temperature import (
MeanInternalTemperatureResult,
allocate_extended_heating_days_to_months,
extended_heating_days_from_psr_variable,
mean_internal_temperature_monthly,
)
from domain.sap10_calculator.worksheet.energy_requirements import (
EnergyRequirementsResult,
space_heating_fuel_monthly_kwh,
)
from domain.sap10_calculator.worksheet.fabric_energy_efficiency import (
fabric_energy_efficiency_kwh_per_m2_yr,
)
from domain.sap10_calculator.worksheet.photovoltaic import pv_split_monthly
from domain.sap10_calculator.worksheet.space_cooling import (
SpaceCoolingResult,
space_cooling_monthly_kwh,
)
from domain.sap10_calculator.worksheet.space_heating import (
SpaceHeatingResult,
space_heating_monthly_kwh,
)
from domain.sap10_calculator.worksheet.ventilation import (
MechanicalVentilationKind,
VentilationResult,
ventilation_from_inputs,
)
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
from domain.sap10_calculator.worksheet.water_heating import (
PIPEWORK_INSULATED_FULLY,
PIPEWORK_INSULATED_UNINSULATED,
TABLE_J1_TCOLD_FROM_MAINS_C,
WaterHeatingResult,
combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot,
combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
cylinder_storage_loss_factor_table_2,
cylinder_storage_loss_monthly_kwh,
cylinder_volume_factor_table_2a,
primary_loss_monthly_kwh,
water_efficiency_monthly_via_equation_d1,
water_heating_from_cert,
)
# RdSAP 10 Table 27 — fraction of total floor area treated as the
# "living area" for the §7 mean-internal-temperature blend.
_LIVING_AREA_FRACTION_BY_ROOMS: Final[dict[int, float]] = {
1: 0.75, 2: 0.50, 3: 0.30, 4: 0.25, 5: 0.21, 6: 0.18,
7: 0.16, 8: 0.14,
}
_LIVING_AREA_FRACTION_DEFAULT: Final[float] = 0.21
_LIVING_AREA_FRACTION_MIN: Final[float] = 0.13
_PENCE_TO_GBP: Final[float] = 0.01
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
# SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump
# rows. Keyed on RdSAP API `central_heating_pump_age` enum:
# 0 = Unknown → 115 kWh/yr (Table 4f "Circulation pump, unknown date")
# 1 = Pre 2013 → 165 kWh/yr (Table 4f "Circulation pump, 2012 or earlier")
# 2 = 2013 or later→ 41 kWh/yr (Table 4f "Circulation pump, 2013 or later")
# Elmhurst-path certs route here via `_elmhurst_pump_age_int` (mapper)
# which recognises both "Pre 2013" and "2012 or earlier" variants.
_TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE: Final[dict[int, float]] = {
0: 115.0,
1: 165.0,
2: 41.0,
}
# Default circulation pump kWh when pump_age is None (no lodging at
# all) — Table 4f doesn't have a "missing" row; the SAP convention is
# to use the unknown-date value.
_TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT: Final[float] = 115.0
# SAP 10.2 Table 4f (PDF p.175) footnote a) on the "Circulation pump"
# rows reads verbatim: "Multiply by a factor of 1.3 if room thermostat
# is absent." A gas/liquid-fuel boiler under control code 2101 / 2102
# (`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES`) has no room thermostat,
# so its circulation pump electricity is scaled by 1.3 — e.g. oil 6
# (pump_age "2013 or later" → 41 kWh) lands ws (230c) = 41 × 1.3 = 53.3.
_TABLE_4F_NO_ROOM_THERMOSTAT_PUMP_MULTIPLIER: Final[float] = 1.3
# Heat pumps from PCDB include circulation pump electricity in COP per
# Table 4f note: "Not applicable for electric heat pumps from
# database." Cat 4 (heat pump) → 0 kWh circulation pump.
_HP_MAIN_HEATING_CATEGORY: Final[int] = 4
# Wet-boiler SAP main_heating_code ranges (Table 4a + Table 4b). The
# Table 4f "Circulation pump" rows apply to systems with a primary
# water loop — i.e. boilers driving radiators / wet underfloor /
# convectors. Dry electric storage heaters (401-499), room heaters
# (601-699), and electric direct-acting / warm-air (501-515, 691+)
# have NO circulation pump per worksheet evidence:
#
# - electric 1 (code 191 electric boiler): ws (230c) = 41 kWh ✓
# - electric 5 (code 402 electric storage): ws (231) = 0 kWh ✗
# - solid fuel 2 (code 158 boiler): ws (230c) = 41 kWh ✓
# - solid fuel 9 (code 636 room heater): ws (231) = 0 kWh ✗
#
# Code ranges:
# 101-141 Gas/oil boilers (Table 4b)
# 151-161 Solid fuel boilers (Table 4a)
# 191-196 Electric boilers (Table 4a)
_WET_BOILER_CODE_RANGES: Final[tuple[range, ...]] = (
range(101, 142), # Gas/oil boilers
range(151, 162), # Solid fuel boilers
range(191, 197), # Electric boilers
)
def _is_wet_boiler_main(main: Optional[MainHeatingDetail]) -> bool:
"""Whether `main` is a wet boiler system (has a water-loop
circulation pump per Table 4f). Identifies by Table 4a/4b code
when lodged; falls back to PCDB Table 322 (gas/oil boiler) record
when the cert lodges an index number; finally falls back to
`main_heating_category` ∈ {1, 2} ("central heating" — conventionally
wet). Heat pumps (cat 4) return False here (Table 4f note "Not
applicable for electric heat pumps from database").
"""
if main is None:
return False
if main.main_heating_category == _HP_MAIN_HEATING_CATEGORY:
return False
code = main.sap_main_heating_code
if code is not None:
return any(code in r for r in _WET_BOILER_CODE_RANGES)
# No SAP code lodged. Try PCDB Table 322 (gas/oil boiler) record —
# the Elmhurst-path cohort certs (e.g. oil pcdb 1/2/3, pcdb 1)
# lodge `main_heating_index_number` but no Table 4b code, and a
# Table 322 record is sufficient evidence the main is a wet boiler.
if main.main_heating_index_number is not None:
if gas_oil_boiler_record(main.main_heating_index_number) is not None:
return True
# Final fallback — RdSAP categories 1/2 = central heating (without/
# with separate HW); both imply a wet primary loop. The gas-API
# cohort lodges cat=2 with no code and routed via this branch
# pre-S0380.149's refactor.
return main.main_heating_category in {1, 2}
# SAP 10.2 Table 4f (page 174) — flue fan kWh for a gas-fired boiler
# with fan-assisted flue (row "Gas boiler flue fan"). Liquid-fuel
# (oil) boilers use 100; gas-fired heat pumps and warm-air also 45.
_TABLE_4F_GAS_FLUE_FAN_KWH: Final[float] = 45.0
# SAP 10.2 Table 4f (PDF p.174) row "Liquid fuel boiler flue fan and
# fuel pump": 100 kWh/yr. Note c): "Applies to all liquid fuel boilers
# that provide main heating, but not if boiler provides hot water only.
# Where there are two main heating systems include two figures from
# this table." First exercised by oil 1 + oil pcdb 3 corpus variants.
_TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH: Final[float] = 100.0
# SAP 10.2 Table 4f row "Solar thermal system pump, electrically
# powered" — formula `[25 + 5×H1] × 2`. H1 is the solar collector
# aperture area in m². For cert 000565 the lodged 3 m² flat-panel
# array gives 2 × (25 + 15) = 80 kWh; without aperture lodging the
# cohort fall-through uses a 3 m² default.
_TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2: Final[float] = 3.0
# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. Two
# spec categories distribute heat as ducted air:
# - Category 5: heat pumps with warm-air distribution (codes 521,
# 523, 524 electric SH; 525, 526, 527 gas-fired).
# - Category 9: warm-air systems NOT heat pump (501-511, 520 gas-
# fired; 512-514 liquid-fired; 515 Electricaire electric).
# These systems share the Table 4f "Warm air heating system fans" row
# (the fan electricity is air-side, distinct from the wet-system
# circulation pump and the gas-boiler flue fan).
_TABLE_4A_WARM_AIR_SAP_CODES: Final[frozenset[int]] = frozenset({
501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 520,
512, 513, 514, 515,
521, 523, 524, 525, 526, 527,
})
# SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans" =
# SFP × 0.4 × V (kWh/yr). Footnote e):
# "SFP is the specific fan power from the database record for the
# warm air unit if applicable; otherwise 1.5 W/(l/s). These values
# of SFP include the in-use factor."
_TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S: Final[float] = 1.5
_TABLE_4F_WARM_AIR_FAN_VOLUME_FACTOR: Final[float] = 0.4
# Footnote e) — the warm-air fan electricity is omitted when the
# dwelling also has balanced whole-house mechanical ventilation,
# because the MV system's fans displace the warm-air circulation
# fans. Balanced kinds = MVHR + MV. Extract-only / PIV-from-outside
# / natural ventilation kinds do NOT trigger the omission.
_BALANCED_MV_KIND_NAMES: Final[frozenset[str]] = frozenset({"MVHR", "MV"})
def _table_4f_circulation_pump_kwh(main: Optional[MainHeatingDetail]) -> float:
"""SAP 10.2 Table 4f (PDF p.174) — Main 1 circulation pump kWh
based on `central_heating_pump_age` lodging.
Heat-pump mains (category 4) return 0 per Table 4f note "Not
applicable for electric heat pumps from database" — the HP's COP
already accounts for pump electricity internally. Dry electric
storage / direct-acting / room heaters also return 0 (no primary
water loop, no pump) — see `_is_wet_boiler_main`.
For wet boiler mains the dispatch reads the pump_age int enum:
0 / None → 115 kWh (Unknown date)
1 → 165 kWh (Pre 2013 / 2012 or earlier)
2 → 41 kWh (2013 or later)
Table 4f footnote a) then multiplies the row by 1.3 when the room
thermostat is absent (control code 2101 / 2102).
"""
if not _is_wet_boiler_main(main):
return 0.0
assert main is not None # _is_wet_boiler_main guards None
age = main.central_heating_pump_age
if age is None:
kwh = _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT
else:
kwh = _TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE.get(
age, _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT
)
if main.main_heating_control in _BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES:
kwh *= _TABLE_4F_NO_ROOM_THERMOSTAT_PUMP_MULTIPLIER
return kwh
def _table_4f_main_1_gas_boiler_flue_fan_kwh(
main: Optional[MainHeatingDetail],
) -> float:
"""SAP 10.2 Table 4f (PDF p.174) row "Gas boiler flue fan (if
fan assisted flue)": 45 kWh/yr.
Fires only when Main 1 is a wet gas-fuelled boiler with a
fan-assisted flue. Heat pumps (cat 4) and electric mains return
0 — different Table 4f rows govern (HPs subsumed in COP; electric
mains have no flue). Liquid fuel mains have their own 100 kWh
row, applied via `_table_4f_additive_components`.
"""
if not _is_wet_boiler_main(main):
return 0.0
assert main is not None # _is_wet_boiler_main guards None
fuel = main.main_fuel_type
# Gas fuel codes per Table 32 + their RdSAP API equivalents (same
# set the Main 2 branch in _table_4f_additive_components uses).
fuel_is_gas = isinstance(fuel, int) and fuel in {1, 2, 3, 5, 7, 9, 26, 27}
if fuel_is_gas and main.fan_flue_present:
return _TABLE_4F_GAS_FLUE_FAN_KWH
return 0.0
def _has_balanced_mechanical_ventilation(epc: EpcPropertyData) -> bool:
"""SAP 10.2 Table 4f footnote e) balanced-MV gate: True when the
cert lodges either MVHR (balanced with heat recovery) or MV
(balanced without heat recovery). False for MEV / PIV-from-outside
/ natural — footnote e) explicitly INCLUDES the warm-air fan kWh
for "a warm air system and MEV or PIV from outside".
"""
sv = epc.sap_ventilation
if sv is None:
return False
name = sv.mechanical_ventilation_kind
return name in _BALANCED_MV_KIND_NAMES
def _table_4f_warm_air_heating_fans_kwh(
main: Optional[MainHeatingDetail],
dwelling_volume_m3: float,
has_balanced_mv: bool,
) -> float:
"""SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system
fans" = SFP × 0.4 × V per footnote e). Default SFP = 1.5 W/(l/s)
when the cert has no PCDB warm-air-unit record. Suppressed when
the dwelling lodges balanced whole-house MV per the footnote-e
omission rule.
Fires for the Table 4a Cat 5 (heat pumps with warm-air
distribution) and Cat 9 (warm air NOT heat pump) sub-rows — see
`_TABLE_4A_WARM_AIR_SAP_CODES`. Cohort entry point is the
heating-systems corpus 001431 electric 2 variant (code 524
air-source warm-air HP, no MV, V = 227.25 m³ → 1.5 × 0.4 × 227.25
= 136.35 kWh, matching the P960 worksheet (249) line exactly).
"""
if main is None:
return 0.0
code = main.sap_main_heating_code
if code is None or code not in _TABLE_4A_WARM_AIR_SAP_CODES:
return 0.0
if has_balanced_mv:
return 0.0
return (
_TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S
* _TABLE_4F_WARM_AIR_FAN_VOLUME_FACTOR
* dwelling_volume_m3
)
def _table_4f_additive_components(epc: EpcPropertyData) -> float:
"""Sum the SAP 10.2 Table 4f line items that the base
`_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` lookup doesn't already cover —
i.e. components driven by per-cert lodgements rather than Main 1's
heating category alone.
Currently wired:
- (230a) MEV / MVHR — `SFPav × 1.22 × V` per SAP 10.2 §2.6.4 +
Table 4f. PCDB Table 322 (decentralised MEV products) + Table
329 (in-use factors) compose SFPav via `mev_sfp_av`. First
exercised by cert 000565 (Titon Ultimate dMEV index 500755,
2 wet rooms, Flexible ducting).
- (230e) Main 2 gas-boiler flue fan — 45 kWh when a Main 2 system
is lodged with `fan_flue_present=True` and a gas fuel type.
Cert 000565 (Main 1 HP + Main 2 gas combi via WHC 914) is the
first fixture exercising this.
- (230g) Solar HW pump — `[25 + 5×H1] × 2` per Table 4f. H1
defaults to 3 m² aperture (cert 000565 lodging) when the
schema doesn't carry the lodged value. TODO: parse the
Elmhurst §16 aperture area into the schema.
Warm-air heating fans (Table 4f row "Warm air heating system fans"
= SFP × 0.4 × V) live in a sibling helper
`_table_4f_warm_air_heating_fans_kwh` because they require the
dwelling volume from `dimensions_from_cert(epc)`, not just the
EPC payload — see the orchestrator pumps_fans summation.
Not yet wired:
- (230f) Combi keep-hot — 600 / 900 kWh per Table 4f when the
cert lodges keep-hot on the gas combi.
- (230c) Warm-air system pump (Cat 9 sub-row for systems with a
separate warm-air circulation pump — cohort doesn't exercise
it yet).
- (230h) WWHRS pump.
"""
total = 0.0
total += _mev_decentralised_kwh_per_yr_from_cert(epc)
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
if details:
main_1 = details[0]
# SAP 10.2 Table 4f row "Liquid fuel boiler flue fan and fuel
# pump" (100 kWh/yr). Note c): "Applies to all liquid fuel
# boilers that provide main heating, but not if boiler provides
# hot water only." Main 1 is by definition a main-heating
# boiler, so the gate reduces to "is the fuel liquid". Worksheet
# line (230d) on oil 1 + oil pcdb 3 confirms 100 kWh.
# `is_liquid_fuel_code` routes through Table-32 normalisation so
# Elmhurst-derived Table 32 codes (e.g. 23 = bulk wood pellets,
# solid) don't collide with API enum codes (where 23 = B30D
# community).
main_1_fuel = main_1.main_fuel_type
if isinstance(main_1_fuel, int) and is_liquid_fuel_code(main_1_fuel):
total += _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH
if len(details) >= 2:
main_2 = details[1]
# Gas fuel codes per Table 32 + their RdSAP API equivalents.
main_2_fuel_is_gas = main_2.main_fuel_type in {1, 2, 3, 5, 7, 9, 26, 27}
if main_2.fan_flue_present and main_2_fuel_is_gas:
total += _TABLE_4F_GAS_FLUE_FAN_KWH
# Note c): "Where there are two main heating systems include
# two figures from this table" — Main 2 liquid fuel boiler also
# gets its own 100 kWh per the spec.
main_2_fuel = main_2.main_fuel_type
if isinstance(main_2_fuel, int) and is_liquid_fuel_code(main_2_fuel):
total += _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH
if epc.solar_water_heating:
total += (
25.0 + 5.0 * _TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2
) * 2.0
return total
# SAP 10.2 §2.6.4 decentralised MEV fan flow rates (l/s) per PCDF Spec
# §A.19 field 14: 13 l/s for kitchen configurations (codes 1, 3, 5),
# 8 l/s for other wet room configurations (codes 2, 4, 6).
_MEV_KITCHEN_FAN_CONFIG_CODES: Final[frozenset[int]] = frozenset({1, 3, 5})
# PCDB Table 329 / 322 system_type=2 = decentralised MEV.
_MEV_DECENTRALISED_SYSTEM_TYPE: Final[int] = 2
# Elmhurst "Duct Type" cascade integer: 1=Flexible, 2=Rigid (per
# `_ELMHURST_DUCT_TYPE_TO_INT` in datatypes.epc.domain.mapper).
_MV_DUCT_TYPE_FLEXIBLE: Final[int] = 1
_MV_DUCT_TYPE_RIGID: Final[int] = 2
# Decentralised MEV PCDB fan-location codes (PCDF Spec §A.19 field 14):
# 1, 2 = in-room with ducting (use flexible/rigid IUF per duct type)
# 3, 4 = in-duct (use flexible/rigid IUF per duct type)
# 5, 6 = through-wall (use no-duct IUF independent of duct type)
_MEV_THROUGH_WALL_CONFIG_CODES: Final[frozenset[int]] = frozenset({5, 6})
def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
"""Compose the SAP 10.2 §5 Table 4f line (230a) MEV decentralised
annual electricity contribution from PCDB Tables 322 (per-fan SFP
+ flow) + 329 (per-ducting IUFs) + cert lodgement (wet-rooms
count, ducting type).
Returns 0.0 when:
- No MEV PCDF index is lodged (e.g. cert with no MV system or
a non-decentralised MV system — the cascade routes through a
different (230) line).
- The PCDB Table 322 record isn't found for the lodged index
(caller falls back to Table 4g default downstream — future
slice).
The per-fan-configuration count distribution mimics the Elmhurst
convention reverse-engineered from cert 000565:
- Each PCDB-defined configuration (1..6) contributes 1 baseline
fan to the installation, regardless of whether the PCDB row
lodges measured SFP / flow.
- Through-wall configurations scale with the wet-rooms count:
through-wall kitchen (5): `wet_rooms_count` total fans
through-wall other wet (6): `wet_rooms_count + 1` total fans
(For cert 000565 wet_rooms=2, this yields the worksheet's
observed (1, 1, 1, 1, 2, 3) count distribution.)
Configurations whose PCDB SFP is blank contribute 0 to the SFPav
numerator but their flow rate (13 l/s kitchen, 8 l/s other wet)
contributes to the denominator — matching the spec's "summation
is over all the fans" semantics.
TODO: validate the count convention against a second MEV
decentralised fixture; the rule above fits cert 000565 alone.
"""
pcdf_id = epc.mechanical_ventilation_index_number
if pcdf_id is None:
return 0.0
record = decentralised_mev_record(pcdf_id)
if record is None:
return 0.0
iuf_record = mv_in_use_factors_record(_MEV_DECENTRALISED_SYSTEM_TYPE)
if iuf_record is None:
return 0.0
wet_rooms = epc.wet_rooms_count if epc.wet_rooms_count > 0 else 1
duct_type = epc.mechanical_vent_duct_type
if duct_type == _MV_DUCT_TYPE_RIGID:
in_duct_iuf = iuf_record.sfp_iuf_rigid_no_scheme
else:
in_duct_iuf = iuf_record.sfp_iuf_flexible_no_scheme
through_wall_iuf = iuf_record.sfp_iuf_no_duct_no_scheme
if in_duct_iuf is None or through_wall_iuf is None:
return 0.0
fan_entries: list[MevFanEntry] = []
configs_by_code = {c.config_code: c for c in record.fan_configs}
for code in range(1, 7):
config = configs_by_code.get(code)
flow = (
13.0 if code in _MEV_KITCHEN_FAN_CONFIG_CODES else 8.0
)
sfp = config.sfp_w_per_l_per_s if config is not None else None
sfp_value = sfp if sfp is not None else 0.0
iuf = through_wall_iuf if code in _MEV_THROUGH_WALL_CONFIG_CODES else in_duct_iuf
# Baseline 1 fan per config; extra through-wall fans scale
# with wet-rooms count per the Elmhurst convention.
count = 1
if code == 5:
count = max(1, wet_rooms)
elif code == 6:
count = max(1, wet_rooms + 1)
for _ in range(count):
fan_entries.append(
MevFanEntry(
sfp_w_per_l_per_s=sfp_value,
flow_rate_l_per_s=flow,
iuf=iuf,
)
)
sfp_av = mev_sfp_av(tuple(fan_entries))
dimensions = dimensions_from_cert(epc)
return mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp_av,
dwelling_volume_m3=dimensions.volume_m3,
)
# SAP10.2 Table 6d note 1: "average or unknown" overshading is the
# default for existing dwellings. RdSAP doesn't lodge a per-dwelling
# overshading code so §5 always uses AVERAGE → Z_L = 0.83.
_INTERNAL_GAINS_DEFAULT_OVERSHADING: Final[OvershadingCategory] = (
OvershadingCategory.AVERAGE
)
# Water-heating codes for instantaneous (no-cylinder) systems — SAP §4
# Appendix J skips cylinder-storage + primary-pipework losses for these
# because there's no cylinder and no primary circuit.
_INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
# Elmhurst WHC code for "HW from a separate electric immersion heater":
# cylinder lodged but heated by an immersion element inside the tank, no
# primary pipework between any heat generator and the cylinder. SAP 10.2
# Table 3 (PDF p.160) puts "Electric immersion heater" first in its
# zero-loss list, so primary loss is zero whenever this code is lodged.
_WHC_ELECTRIC_IMMERSION: Final[int] = 903
# SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed
# per array. The module efficiency constant (0.8), orientation-dependent
# annual solar radiation S (kWh/m²/yr from Appendix U3.3), and overshading
# factor ZPV (Table M1) are decoupled here so per-array generation tracks
# the cert's tilt / orientation / shading data.
_PV_MODULE_EFFICIENCY_FACTOR: Final[float] = 0.8
# RdSAP10 §11.1 pitch enum → degrees from horizontal. RdSAP fixes the
# tilt to one of five values; certs lodge the integer code while
# Appendix U3.2 takes a continuous pitch.
_PV_PITCH_DEG_BY_CODE: Final[dict[int, float]] = {
1: 0.0, # horizontal
2: 30.0,
3: 45.0,
4: 60.0,
5: 90.0, # vertical
}
_PV_PITCH_DEG_DEFAULT: Final[float] = 30.0 # RdSAP10 §11.1 default
def _pv_pitch_deg(pitch_code: Optional[int]) -> float:
"""RdSAP 10 §11.1 PV pitch enum → degrees from horizontal. Strict-
dispatch per [[reference-unmapped-sap-code]]: absent (None / 0)
returns the spec default 30°; present-but-unmapped raises."""
if not pitch_code:
return _PV_PITCH_DEG_DEFAULT
if pitch_code in _PV_PITCH_DEG_BY_CODE:
return _PV_PITCH_DEG_BY_CODE[pitch_code]
raise UnmappedSapCode("pv_pitch_code", pitch_code)
# SAP 10.2 Appendix U3.3 equation (U4) constant: converts (W/m² × days)
# to (kWh/m²/yr) via 24 h/day ÷ 1000 W/kW = 0.024.
_HOURS_PER_DAY_OVER_1000: Final[float] = 0.024
_DAYS_PER_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
def _pv_annual_s_kwh_per_m2(
orientation_code: int,
pitch_code: int,
climate: "int | PostcodeClimate",
) -> float:
"""SAP 10.2 Appendix U3.3 equation (U4): annual solar radiation
(kWh/m²/yr) on a surface of given orientation and tilt. Sums the
monthly Appendix U3.2 surface flux over the year. `climate` selects
Table U3/U4 region (UK average = 0 for the rating cascade) or a
`PostcodeClimate` from PCDB Table 172 for the demand cascade.
Returns 0.0 for unrecognised orientation codes (cert octants outside
1..8) — these PV arrays contribute nothing."""
orientation = ORIENTATION_BY_SAP10_CODE.get(orientation_code)
if orientation is None:
return 0.0
pitch_deg = _pv_pitch_deg(pitch_code)
total = 0.0
for month_idx, days in enumerate(_DAYS_PER_MONTH):
s_m = surface_solar_flux_w_per_m2(
orientation=orientation,
pitch_deg=pitch_deg,
region=climate,
month=month_idx + 1,
)
total += days * s_m
return _HOURS_PER_DAY_OVER_1000 * total
# SAP 10.2 Table M1 — PV overshading factor ZPV. RdSAP10 omits SAP10.2's
# 5th "Severe" bucket; the four RdSAP codes map directly:
# 1 = very little / none → 1.0
# 2 = modest → 0.8
# 3 = significant → 0.5
# 4 = heavy → 0.35
_PV_OVERSHADING_FACTOR: Final[dict[int, float]] = {
1: 1.0,
2: 0.8,
3: 0.5,
4: 0.35,
}
_PV_OVERSHADING_FACTOR_DEFAULT: Final[float] = 1.0 # no shading
def _pv_overshading_factor(overshading_code: Optional[int]) -> float:
"""SAP 10.2 Table M1 PV overshading factor ZPV (RdSAP10 4-bucket
collapse). Strict-dispatch per [[reference-unmapped-sap-code]]:
absent (None / 0) returns the modal "no shading" default 1.0;
present-but-unmapped raises."""
if not overshading_code:
return _PV_OVERSHADING_FACTOR_DEFAULT
if overshading_code in _PV_OVERSHADING_FACTOR:
return _PV_OVERSHADING_FACTOR[overshading_code]
raise UnmappedSapCode("pv_overshading_code", overshading_code)
# SAP 10.2 Table 11 — fraction of space heating supplied by a secondary
# system, keyed on the main system's category.
# Cat 1, 2 (gas/oil/solid boiler): 0.10
# Cat 4 (heat pump): 0.00 (HP eff includes any secondary)
# Cat 5 (warm air): 0.10
# Cat 7 (electric storage): 0.15 (not-fan-assisted average)
# Cat 10 (room heaters): 0.20
# Heat networks (cat 3, 6) → 0.10 per Table 11.
_SECONDARY_HEATING_FRACTION_BY_CATEGORY: Final[dict[int, float]] = {
1: 0.10,
2: 0.10,
3: 0.10,
4: 0.00, # Heat pump: HP eff includes any secondary contribution
# per SAP 10.2 Table 11 explicit footnote; supersedes the
# 0.10 DEFAULT below which would erroneously bill 10% of
# space-heating cost as secondary on HP certs that lodge
# a secondary_heating_type code (cert 0380: 547 kWh @
# 13.19 p/kWh = £72 vs worksheet £0).
5: 0.10,
6: 0.10,
7: 0.15,
10: 0.20,
}
_SECONDARY_HEATING_FRACTION_DEFAULT: Final[float] = 0.10
# SAP §A.2.2 forcing rule: "A secondary system is always included for
# the SAP calculation when the main system (or main system 1 when there
# are two systems) is electric storage heaters or off-peak electric
# underfloor heating. This applies to main heating codes 401 to 407, 409
# and 421. Portable electric heaters (693) are used in the calculation
# if no secondary system has been identified."
# Code 408 (Integrated storage+direct-acting heater) is explicitly NOT
# in the spec's forced list — the integrated direct-acting element acts
# as the secondary already, so the calculation doesn't add another.
# For gas/oil/solid boiler main systems, the cert calculator only includes
# secondary when one has actually been lodged on the cert.
_DEFAULT_SECONDARY_HEATING_CODE: Final[int] = 693
_FORCE_SECONDARY_FOR_MAIN_CODES: Final[frozenset[int]] = frozenset(
list(range(401, 408)) + [409, 421]
)
# SAP 10.2 Table 11 (PDF p.188) — per-SAP-code secondary heating
# fraction for the "Electric storage heaters (not integrated)" row,
# which splits by Table 4a sub-type:
# not fan-assisted: 0.15
# fan-assisted: 0.10
# HHR: 0.10
# Cross-referenced against SAP 10.2 Table 4a (PDF p.166) code
# definitions (line refs 9120-9128 of the spec PDF):
# 401: Old (large volume) storage heaters — not fan-assisted
# 402: Slimline storage heaters — not fan-assisted
# 403: Convector storage heaters — not fan-assisted
# 404: Fan storage heaters — fan-assisted
# 405: Slimline + Celect — not fan-assisted
# 406: Convector + Celect — not fan-assisted
# 407: Fan + Celect — fan-assisted
# 408: Integrated storage + direct-acting — "Integrated"
# 409: High heat retention — HHR
# 421: Underfloor heating — "Other electric"
# Pre-S0380.144 the cascade defaulted to 0.10 for every forced electric
# storage code (mapper leaves `main_heating_category=None`); this dict
# distinguishes the not-fan-assisted 0.15 sub-row from the fan-
# assisted / HHR / integrated / other-electric 0.10 sub-rows.
_SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE: Final[dict[int, float]] = {
401: 0.15,
402: 0.15,
403: 0.15,
404: 0.10,
405: 0.15,
406: 0.15,
407: 0.10,
408: 0.10, # Not in `_FORCE_SECONDARY_FOR_MAIN_CODES` — only used
# when the cert lodges a secondary explicitly.
409: 0.10,
421: 0.10,
}
# SAP 10.2 Table 12 code 60 — PV export tariff. The calculator uses this
# rate as the per-kWh PV cost credit applied against total annual fuel
# cost in the ECF numerator.
_PV_EXPORT_TARIFF_CODE: Final[int] = 60
# SAP 10.2 Table 12c (page 193) — Distribution Loss Factor for heat
# networks by dwelling age band, used when no PCDB record is available
# (the modal RdSAP case). Per §C3.1: "Where a heat network is listed
# in the PCDB, the DLF is already factored into the cost, CO2 and PE
# factors recorded therein, so a DLF of 1 should be entered in
# worksheet (306) to avoid double counting." For non-PCDB networks
# (our case), DLF must be applied. K-or-newer (post-2007) = 1.50.
_HEAT_NETWORK_DLF_BY_AGE: Final[dict[str, float]] = {
"A": 1.20, "B": 1.26, "C": 1.33, "D": 1.37, "E": 1.41, "F": 1.43,
"G": 1.45, "H": 1.46, "I": 1.48, "J": 1.49, "K": 1.50, "L": 1.50,
"M": 1.50,
}
_HEAT_NETWORK_DLF_DEFAULT: Final[float] = 1.50
# SAP 10.2 Table 4a codes for heat-network main heating systems:
# 301 = boiler-driven community heating
# 302 = boiler-driven community heating with CHP
# 303 = community CHP only
# 304 = electric heat-pump community heating
_HEAT_NETWORK_MAIN_CODES: Final[frozenset[int]] = frozenset({301, 302, 303, 304})
_HEAT_NETWORK_CATEGORY: Final[int] = 6
# SAP 10.2 Table 4a (PDF p.164) heat-network heat-source efficiency by
# SAP code. Verbatim:
# 301 "Boilers (RdSAP)" → 80%
# 302 "CHP and boilers (RdSAP)" → 75% (overall — per RdSAP 10 §C)
# 304 "Heat pump (RdSAP)" → 300% (= COP 3.0)
# Used by the block 13a/12b PE/CO2 cascade to convert delivered network
# input (post-DLF) into FUEL input by dividing by the heat-source
# efficiency: spec (467) = (307+310) × 100 / (467a). The cascade meters
# heat-network input directly (eff = 1/DLF for cost via Table 12
# heat-network rate), so PE/CO2 factors are scaled by 1/heat_source_eff
# at lookup time to land at the spec's fuel-input × Table-12-factor.
#
# Code 302 (CHP+boilers) is omitted here because the 35%/65% heat-
# fraction split applies different efficiencies to the two heat sources
# (CHP 75% overall + boilers 80%) and a single composite efficiency
# can't model the displaced-electricity credit line per spec block
# 13b (464)/(466). The cascade for code 302 keeps the current
# 1/DLF override (giving large CO2/PE residuals on CH2/CH4/CH6 —
# follow-up slice scope).
_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = {
301: 0.80,
304: 3.00,
}
def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool:
"""True when the cert's main heating is a heat network — either by
SAP code (Table 4a 301-304) or by `main_heating_category` (6)."""
if main is None:
return False
code = main.sap_main_heating_code
if isinstance(code, int) and code in _HEAT_NETWORK_MAIN_CODES:
return True
return main.main_heating_category == _HEAT_NETWORK_CATEGORY
def _heat_network_heat_source_efficiency_scaling(
main: Optional[MainHeatingDetail],
) -> float:
"""Return the multiplicative scaling factor to apply to Table 12
CO2 / PE factors when the main is a heat-network boiler (SAP 301) or
heat pump (SAP 304). Cascade computes CO2/PE = network_input ×
Table_12_factor; spec block 13a/12b computes (network_input /
heat_source_eff) × Table_12_factor. Equivalent transform: scale the
factor by 1/heat_source_eff. Returns 1.0 for code 302 (CHP+boilers
— separate split-formula path) and non-heat-network mains.
"""
if not _is_heat_network_main(main):
return 1.0
code = main.sap_main_heating_code if main is not None else None
if not isinstance(code, int):
return 1.0
eff = _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY.get(code)
if eff is None:
return 1.0
return 1.0 / eff
def _heat_network_dlf(age_band: Optional[str]) -> float:
"""RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by
age band. Defaults to the K-or-newer value (1.50) when band missing.
Strict-dispatch per [[reference-unmapped-sap-code]]: absent
(None / "") returns the spec default; present-but-unmapped (e.g.
"X" or "Z") raises so the spec-coverage gap surfaces."""
if not age_band:
return _HEAT_NETWORK_DLF_DEFAULT
band = age_band.upper()
if band in _HEAT_NETWORK_DLF_BY_AGE:
return _HEAT_NETWORK_DLF_BY_AGE[band]
raise UnmappedSapCode("heat_network_age_band", age_band)
@dataclass(frozen=True)
class PriceTable:
"""Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and
the empirical cert-calibration prices used to parity-test against
the corpus's lodged ratings. The cert assessor software diverges
from spec on unit prices AND on which heating codes pick up the
off-peak rate (see slice S-B9 commit + S-B11 hand-trace).
`unit_price_p_per_kwh` accepts either an API fuel code or a Table 12
code; implementations translate before lookup.
`standard_electricity_p_per_kwh` is the rate applied to lighting +
pumps + fans regardless of main fuel. `e7_eligible_main_codes` lists
the SAP Table 4a main-heating codes that bill space heating at the
tariff's off-peak low-rate — narrower under the spec (storage
heaters only per Table 12a) than under cert calibration (the cert
assessor appears to apply off-peak to direct-electric too).
Tariff-specific off-peak low-rates are looked up via
`_off_peak_low_rate_gbp_per_kwh` per RdSAP 10 §19 Table 32.
"""
unit_price_p_per_kwh: Callable[[Optional[int]], float]
standard_electricity_p_per_kwh: float
e7_eligible_main_codes: frozenset[int]
# SAP 10.2/10.3 spec-correct: per Table 12a, only true storage heaters
# (401-409) and high-heat-retention storage (421-425) bill space heating
# at the low rate. Direct-acting electric (191-196), heat pumps, and
# underfloor heating bill 70-100% at the high rate, so they're not in
# the off-peak set here.
_SPEC_E7_ELIGIBLE_MAIN_CODES: Final[frozenset[int]] = frozenset(
list(range(401, 410)) + list(range(421, 426))
)
# RdSAP 10 Table 32 (PDF page 95) — the canonical SAP-rating price set per
# the RdSAP 10 §19.1 spec text:
#
# "The SAP rating for RdSAP 10 is to be calculated using Table 32 prices
# (not Table 12) for section 10a and 10b."
#
# Table 32 mains gas = 3.48 p/kWh (vs SAP 10.2 Table 12 = 3.64);
# 7-hour low = 5.50 p/kWh (vs Table 12 = 9.40);
# standard electricity = 13.19 p/kWh (vs Table 12 = 16.49).
#
# Wired into `cert_to_inputs` as the default PriceTable per ADR-0010
# §10a amendment (2026-05-21). Off-peak low-rates are looked up
# tariff-by-tariff via `_off_peak_low_rate_gbp_per_kwh`
# (S0380.138: routes 18-hour to 7.41, 10-hour to 7.50, 24-hour to 6.61).
RDSAP_10_TABLE_32_PRICES: Final[PriceTable] = PriceTable(
unit_price_p_per_kwh=table_32_unit_price_p_per_kwh,
standard_electricity_p_per_kwh=13.19, # Table 32 code 30
e7_eligible_main_codes=_SPEC_E7_ELIGIBLE_MAIN_CODES,
)
# Legacy alias retained so existing imports keep working. Per ADR-0010
# §10a amendment the SAP rating uses Table 32 prices, NOT SAP 10.2
# Table 12 — the name is preserved for back-compat; both constants point
# at the same Table 32 PriceTable instance.
SAP_10_2_SPEC_PRICES: Final[PriceTable] = RDSAP_10_TABLE_32_PRICES
# SAP 10.2 Table 4e (page 171) main_heating_control codes → control type
# (1/2/3 per Table 9 "Heating control type" column). Type drives the
# elsewhere-zone off-hours pattern in Table 9: types 1+2 use (7, 8),
# type 3 uses (9, 8) per footnote (b) "heating 0700-0900 and 1800-2300".
#
# Type 1: no time + temp control, or one but not both.
# Type 2: programmer + room thermostat (+/ TRVs); also bare TRV-class
# controls (2111 "TRVs and bypass", 2113 "Room thermostat and
# TRVs") — these were misclassified as type 3 pre-S0380.25 and
# pushed cert 0652 to +1.93 SAP / cert 6835 to +0.72.
# Type 3: time-and-temperature zone control (separate living-zone
# schedule via plumbing/electrical arrangement or PCDB device).
_CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
# SAP 10.2 Table 4e (PDF p.171-174) full coverage — strict-raise
# gated by `_control_type` per [[reference-unmapped-api-code]].
#
# Group 1 — BOILER SYSTEMS WITH RADIATORS OR UNDERFLOOR HEATING (p.171)
# "Not applicable (boiler DHW only)" 2100 — not in dispatch; cert that
# lodges 2100 has DHW-only on this main and space heating from another
# main / secondary, so the control type should come from that other
# source. Treat 2100 as type 2 default (modal RdSAP) since the cascade
# picks a control type from `_first_main_heating` regardless of role.
2100: 2,
2101: 1, 2102: 1, 2103: 1, 2104: 1,
2105: 2, 2106: 2, 2107: 2, 2108: 2, 2109: 2,
2110: 3,
2111: 2, # TRVs and bypass — Table 4e row "2 0"
2112: 3,
2113: 2, # Room thermostat and TRVs — Table 4e row "2 0"
# Group 2 — HEAT PUMPS WITH RADIATORS OR UNDERFLOOR HEATING (p.172-173)
# Pre-S0380.87 this group was missing; HP control 2207 silently
# defaulted to type 2 (cert 000565 over-counted SH by ~+4500 kWh).
2201: 1, 2202: 1, 2203: 1, 2204: 1,
2205: 2, 2206: 2,
2207: 3, # Time + temp zone control by plumbing/electrical (§9.4.14)
2208: 3, # Time + temp zone control by PCDB device (§9.4.14)
2209: 2, 2210: 2,
# Group 3 — HEAT NETWORKS (p.173). Pre-S0380.88 this group was
# missing; corpus has cert(s) lodging 2307 silently mis-classified.
2301: 1, 2302: 1, 2303: 1, 2304: 1,
2305: 2, 2307: 2, 2308: 2, 2309: 2, 2311: 2, 2313: 2,
2306: 3, 2310: 3, 2312: 3, 2314: 3,
# Group 4 — ELECTRIC STORAGE SYSTEMS (p.173). All type 3 per spec.
2401: 3, 2402: 3, 2403: 3, 2404: 3,
# Group 5 — WARM AIR SYSTEMS (incl. HP with warm air dist.) (p.173)
2501: 1, 2502: 1, 2503: 1, 2504: 1,
2505: 2,
2506: 3,
# Group 6 — ROOM HEATER SYSTEMS (p.173). Codes 2602-2605 type 3 per
# spec; 2601 is type 2.
2601: 2,
2602: 3, 2603: 3, 2604: 3, 2605: 3,
# Group 7 — OTHER SYSTEMS (p.173)
2701: 1, 2702: 1, 2703: 1, 2704: 1,
2705: 2,
2706: 3,
# Group 0 — NO HEATING SYSTEM PRESENT (p.171). Single code only.
2699: 2,
}
# SAP 10.2 Table 4e Group 1 (PDF p.171) — boiler control codes providing
# NO thermostatic control of room temperature, i.e. no room thermostat
# ("No time or thermostatic control of room temperature" 2101 /
# "Programmer, no room thermostat" 2102 — the two Group-1 rows carrying
# the "+0.6 °C / Table 4c(2)" annotation). Per RdSAP 10 §3 (PDF p.57)
# boiler interlock is "assumed present if there is a room thermostat and
# (for stored hot water systems heated by the boiler) a cylinder
# thermostat. Otherwise not interlocked." A gas/liquid-fuel boiler under
# one of these controls therefore has NO boiler interlock regardless of
# the cylinder thermostat, triggering the Table 4c(2) (PDF p.169) "No
# thermostatic control of room temperature regular boiler" -5pp Space
# + DHW seasonal-efficiency adjustment. The combi rows of Table 4c(2)
# take Space -5 / DHW 0; the DHW leg is gated separately on a cylinder
# being present (regular boiler) at the call site.
_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES: Final[frozenset[int]] = frozenset(
{2101, 2102}
)
# SAP 10.2 Table 4e (PDF p.171-173) — "Temperature adjustment, °C"
# column. Spec verbatim (p.170): "3. The 'Temperature adjustment'
# modifies the mean internal temperature and is added to worksheet
# (92)m." Table 9c step 8: "Apply adjustment to the mean internal
# temperature from Table 4e, where appropriate".
#
# Pre-S0380.145 the cascade hardcoded `control_temperature_adjustment
# _c=0.0` at all three call sites of `mean_internal_temperature_
# monthly`. The non-zero adjustments are concentrated on systems
# without thermostatic control (which run permanently at setpoint
# during their heating periods, raising MIT) and on Group 4 electric
# storage where the storage charging strategy raises the maintained
# mean (Manual charge +0.7, Automatic charge +0.4, Celect +0.4,
# HHR-specific controls 0).
_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE: Final[dict[int, float]] = {
# Group 0 — NO HEATING SYSTEM PRESENT
2699: +0.3,
# Group 1 — BOILER SYSTEMS WITH RADIATORS / UFH (and micro-CHP)
# 2100 = "Not applicable (boiler DHW only)" — no MIT contribution
# from this main; treat as 0.
2100: 0.0,
2101: +0.6, 2102: +0.6,
2103: 0.0, 2104: 0.0, 2105: 0.0, 2106: 0.0, 2107: 0.0, 2108: 0.0,
2109: 0.0, 2110: 0.0, 2111: 0.0, 2112: 0.0, 2113: 0.0,
# Group 2 — HEAT PUMPS WITH RADIATORS / UFH
2201: +0.3, 2202: +0.3,
2203: 0.0, 2204: 0.0, 2205: 0.0, 2206: 0.0,
2207: 0.0, 2208: 0.0, 2209: 0.0, 2210: 0.0,
# Group 3 — HEAT NETWORKS
2301: +0.3, 2302: +0.3,
2303: 0.0, 2304: 0.0, 2305: 0.0, 2306: 0.0, 2307: 0.0, 2308: 0.0,
2309: 0.0, 2310: 0.0, 2311: 0.0, 2312: 0.0, 2313: 0.0, 2314: 0.0,
# Group 4 — ELECTRIC STORAGE SYSTEMS
2401: +0.7, 2402: +0.4, 2403: +0.4, 2404: 0.0,
# Group 5 — WARM AIR SYSTEMS (incl. HP with warm air distribution)
2501: +0.3, 2502: +0.3,
2503: 0.0, 2504: 0.0, 2505: 0.0, 2506: 0.0,
# Group 6 — ROOM HEATER SYSTEMS
2601: +0.3,
2602: 0.0, 2603: 0.0, 2604: 0.0, 2605: 0.0,
# Group 7 — OTHER SYSTEMS
2701: +0.3, 2702: +0.3,
2703: 0.0, 2704: 0.0, 2705: 0.0, 2706: 0.0,
}
def _control_temperature_adjustment_c(
main: Optional[MainHeatingDetail],
) -> float:
"""SAP 10.2 Table 4e (PDF p.171-173) "Temperature adjustment, °C"
per Table 9c step 8 (PDF p.184). The adjustment is added to (92)m
to produce (93)m, which feeds the §8 heat loss rate calc and the
Table 9c step 9 re-calculated utilisation factor.
Returns 0.0 when no main is lodged or the cert's
`main_heating_control` is not an int. Raises `UnmappedSapCode`
for present-but-unmapped codes per [[reference-unmapped-sap-code]]
so spec-coverage gaps surface at test time.
"""
if main is None:
return 0.0
code = main.main_heating_control
if not isinstance(code, int):
return 0.0
if code in _CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE:
return _CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE[code]
raise UnmappedSapCode("main_heating_control_temperature_adjustment", code)
from domain.sap10_calculator.exceptions import (
MissingMainFuelType,
UnmappedSapCode,
)
def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
"""Map `EpcPropertyData.dwelling_type` to which envelope surfaces are
party (not heat-loss). Mid-floor flats/maisonettes lose both floor +
roof; top-floor lose floor only; ground-floor lose roof only. Houses
and bungalows expose both surfaces.
RdSAP 10 §3 lists flat-prefix dwelling types ("Top-floor flat",
"Mid-floor maisonette", etc.); matching is prefix-based and
case-insensitive so site-notes capitalisation drift doesn't break it.
"""
if not dwelling_type:
return DwellingExposure(has_exposed_floor=True, has_exposed_roof=True)
dt = dwelling_type.lower()
if dt.startswith("mid-floor"):
return DwellingExposure(has_exposed_floor=False, has_exposed_roof=False)
if dt.startswith("top-floor"):
return DwellingExposure(has_exposed_floor=False, has_exposed_roof=True)
if dt.startswith("ground-floor"):
return DwellingExposure(has_exposed_floor=True, has_exposed_roof=False)
return DwellingExposure(has_exposed_floor=True, has_exposed_roof=True)
def _region_index(region_code: Optional[str]) -> int:
"""SAP rating must be computed with UK-average weather per Appendix U
(p.124). Always returns region 0 (UK average); the demand cascade
(Current Carbon / Current Primary Energy / Fuel Bill) uses the
`postcode_climate` parameter on `cert_to_inputs` instead — see
`cert_to_demand_inputs`."""
_ = region_code
return 0
def _climate_source(
postcode_climate_override: Optional[PostcodeClimate],
) -> "int | PostcodeClimate":
"""Pick the climate source for downstream lookups. None → region 0
(UK-average, ratings cascade); a `PostcodeClimate` → postcode-district
PCDB Table 172 data (demand cascade)."""
return postcode_climate_override if postcode_climate_override is not None else 0
def _is_timber_or_steel_frame(parts: list[SapBuildingPart]) -> bool:
"""RdSAP 10 §5: wall_construction codes 5 (timber frame) and 6 (system
build steel frame) get the lower 0.25 structural ACH; everything else
is treated as 0.35 masonry."""
if not parts:
return False
wc = parts[0].wall_construction
return isinstance(wc, int) and wc in (5, 6)
def _living_area_fraction_default(habitable_rooms_count: Optional[int]) -> float:
"""RdSAP 10 Table 27 (p.52) lookup by `habitable_rooms_count`. Defaults
to the bottom of the table for ≥8 rooms; falls back to the SAP convention
0.21 when count missing or zero."""
if not habitable_rooms_count or habitable_rooms_count <= 0:
return _LIVING_AREA_FRACTION_DEFAULT
if habitable_rooms_count in _LIVING_AREA_FRACTION_BY_ROOMS:
return _LIVING_AREA_FRACTION_BY_ROOMS[habitable_rooms_count]
return _LIVING_AREA_FRACTION_MIN
def _living_area_fraction(
habitable_rooms_count: Optional[int], total_floor_area_m2: float
) -> float:
"""SAP 10.2 §7 LINE_91 = Living area / TFA.
RdSAP §9.2 (p.52): living area = Table 27 fraction × TFA. RdSAP §15
(p.66) requires "All internal floor areas and living area: 2 d.p." at
the RdSAP→SAP boundary. So the materialised living area is rounded to
2 d.p. half-up, then divided back by TFA to yield the LINE_91 that
feeds the §7 zone blend. This roundtrip is why fixtures lodge
e.g. 0.3001 (= 17.04/56.79) rather than the raw 0.30 Table 27 entry.
The multiplication runs in Decimal arithmetic so HALF_UP rounding
lands on the exact decimal boundary the spec defines. Float Table 27
fractions (e.g. 0.30 → 0.2999999...) otherwise drop products that
sit on the .005 boundary below the round-up threshold, e.g. cert
2536 (3 rooms, TFA 45.65): exact 0.30 × 45.65 = 13.6950 → 13.70;
float gives 13.69499... → 13.69, propagating a 0.0007 SAP residual
via the §7 MIT blend.
"""
fraction = _living_area_fraction_default(habitable_rooms_count)
if total_floor_area_m2 <= 0.0:
return fraction
living_area_m2 = float(
(Decimal(str(fraction)) * Decimal(str(total_floor_area_m2))).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
)
return living_area_m2 / total_floor_area_m2
def _window_total_area_and_avg_u(windows: list[SapWindow]) -> tuple[float, Optional[float]]:
"""Area-weighted total + U-value for the conduction worksheet."""
if not windows:
return 0.0, None
total_area = 0.0
weighted_u_area = 0.0
measured_area = 0.0
for w in windows:
a = float(w.window_width) * float(w.window_height)
total_area += a
if w.window_transmission_details is not None:
weighted_u_area += w.window_transmission_details.u_value * a
measured_area += a
avg_u = weighted_u_area / measured_area if measured_area > 0 else None
return total_area, avg_u
def _first_main_heating(epc: EpcPropertyData) -> Optional[MainHeatingDetail]:
"""First entry of `sap_heating.main_heating_details` if any. Multi-
heating split (Table 11) is Session B; the first heating system
drives Session-A inputs."""
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
return details[0] if details else None
# Elmhurst RdSAP water-heating codes that route DHW to a non-Main-1
# system. RdSAP code 914 = "from second main system" — DHW is
# serviced by Main 2 (typically a gas combi providing DHW only) while
# Main 1 handles space heat (e.g. cert 000565: HP Main 1 + gas combi
# Main 2 + WHC 914). The water-heating cascade reads Main 2's PCDB
# record / SAP code / fuel when this routing applies.
_WATER_FROM_SECOND_MAIN_CODES: Final[frozenset[int]] = frozenset({914})
def _water_heating_main(
epc: EpcPropertyData,
) -> Optional[MainHeatingDetail]:
"""The `MainHeatingDetail` that services DHW per the cert's
`water_heating_code` routing. WHC 914 ("from second main system")
returns Main 2 when present; otherwise returns Main 1.
The water-heating cascade (Table 4a / Appendix D2.1 summer
efficiency, water-heating fuel cost / CO2 / PE) keys off this
helper rather than `_first_main_heating` so the right system's
efficiency and fuel propagate to DHW.
"""
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
if not details:
return None
if (
epc.sap_heating.water_heating_code in _WATER_FROM_SECOND_MAIN_CODES
and len(details) >= 2
):
return details[1]
return details[0]
def _rdsap_tariff(epc: EpcPropertyData) -> Tariff:
"""Resolve the cert's Table 12a tariff column via RdSAP 10 §12
Rules 1-4 (page 62). Consults BOTH main heating systems — §12
says "the main system (or either main system if there are two)"
for the rules. The "or database" Rule 3 branch fires when a main
lodges a PCDB Table 362 heat-pump record (regardless of SAP
code).
Cert 000565 (Main 1 ASHP SAP 224 + Main 2 gas combi PCDB 15100,
Dual meter) → Rule 3 on Main 1 → TEN_HOUR, matching the
worksheet's "10 Hour Off Peak" lodging.
"""
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
main_1 = details[0] if details else None
main_2 = details[1] if len(details) >= 2 else None
def _hp_db(detail: Optional[MainHeatingDetail]) -> bool:
return (
detail is not None
and detail.main_heating_index_number is not None
and heat_pump_record(detail.main_heating_index_number) is not None
)
return rdsap_tariff_for_cert(
epc.sap_energy_source.meter_type,
main_1_sap_code=main_1.sap_main_heating_code if main_1 else None,
main_2_sap_code=main_2.sap_main_heating_code if main_2 else None,
main_1_is_heat_pump_database=_hp_db(main_1),
main_2_is_heat_pump_database=_hp_db(main_2),
)
def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]:
"""Fuel code for water heating per the cert's WHC routing. Prefers
an explicitly-lodged `water_heating_fuel`; otherwise falls back to
the fuel of whichever main system services DHW (Main 2 for WHC
914, Main 1 otherwise — see `_water_heating_main`).
Replaces the pattern `epc.sap_heating.water_heating_fuel or
main_fuel` that defaulted to Main 1 unconditionally; for cert
000565 the explicit fuel is None and Main 1 is a heat pump with
no fuel_type lodged, so the old fallback resolved to None and CO2/
PE/cost lookups returned defaults instead of the gas-combi values.
"""
if epc.sap_heating.water_heating_fuel:
return epc.sap_heating.water_heating_fuel
return _main_fuel_code(_water_heating_main(epc))
def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool:
"""True iff the cert's WHC routes HW from the main heating system
(codes 901 / 902 / 914) AND the main is a single-source heat
network with a registered heat-source efficiency
(`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — currently SAP code 301
boilers and 304 HP).
Elmhurst Summary §15.0 lodges `water_heating_fuel_type = "Mains gas"`
on community-heating certs regardless of the actual heat-network
source — without this guard the HW cost / CO2 / PE bills via the
Mains-gas Table 12 code (3.48 p/kWh / 0.21 / 1.13) instead of the
heat-network code (4.24 p/kWh / Table 12 code 41 / 51).
SAP code 302 (CHP+boilers) is excluded because the 35%/65% split
requires the displaced-electricity credit line per spec block 13b
(464)/(466) on the HW side — same constraint as `_main_heating_
co2_factor_kg_per_kwh` (S0380.172). Routing HW through main for
SAP 302 without the credit cascade would regress CO2 / PE; both
the SH and HW paths converge in a single follow-up slice that
wires the CHP credit + boiler-side factor split.
"""
if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES:
return False
main = _water_heating_main(epc)
if not _is_heat_network_main(main):
return False
code = main.sap_main_heating_code if main is not None else None
return isinstance(code, int) and code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY
def _main_heating_efficiency(epc: EpcPropertyData) -> float:
"""SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction.
Resolves PCDB Table 105 winter efficiency override → Table 4a/4b
seasonal efficiency → heat-network 1/DLF override. Used by §4 (water
heating cascade) and §9a (per-system fuel kWh) — both must see the
same value, so this single helper is the single source of truth."""
main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None
main_category = main.main_heating_category if main is not None else None
main_fuel = _main_fuel_code(main)
pcdb_main = (
gas_oil_boiler_record(main.main_heating_index_number)
if main is not None and main.main_heating_index_number is not None
else None
)
if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None:
eff = pcdb_main.winter_efficiency_pct / 100.0
else:
eff = seasonal_efficiency(main_code, main_category, main_fuel)
if _is_heat_network_main(main):
primary_age = (
epc.sap_building_parts[0].construction_age_band
if epc.sap_building_parts else None
)
eff = 1.0 / _heat_network_dlf(primary_age)
return eff
def _control_type(main: Optional[MainHeatingDetail]) -> int:
"""SAP 10.2 §7.1 / Table 9 control type 1/2/3 from the
`main_heating_control` code on `MainHeatingDetail`.
Strict-dispatch per [[reference-unmapped-api-code]]: distinguish
"lodging absent" (return modal default type 2) from "lodging
present but unmapped" (raise `UnmappedSapCode` so the spec-coverage
gap surfaces at test time instead of silently defaulting and
hiding bugs like S0380.87 — HP control 2207 silently routed to
type 2 for ~22 slices). The cascade is "total" per RdSAP §6.2.3
for *value* defaults but strict for *dispatch* coverage.
"""
if main is None:
return 2
code = main.main_heating_control
# `not code` catches the absent-lodging sentinels (None / 0 / "")
# that the datatype's Union[int, str] declaration nominally
# forbids but runtime data exhibits (e.g. cert 000565 Main 2 has
# `main_heating_control=""`). Cascade defaults to modal type 2.
if not code:
return 2
if isinstance(code, int) and code in _CONTROL_TYPE_BY_CODE:
return _CONTROL_TYPE_BY_CODE[code]
raise UnmappedSapCode("main_heating_control", code)
def _responsiveness(
main: Optional[MainHeatingDetail],
tariff: Optional[Tariff] = None,
) -> float:
"""SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271:
"R = responsiveness of main heating system (Table 4a or
Table 4d)"
Two sources, applied in order:
1. Table 4a (PDF p.163-170) — per-heating-system R for systems
whose responsiveness is intrinsic to the appliance (typically
lower than 1.0). Solid-fuel room heaters / range cookers /
independent boilers, electric storage / ceiling systems, range
cookers etc. all have spec-lodged R < 1.0 that overrides any
emitter-based lookup. Keyed on `sap_main_heating_code`.
2. Table 4d (PDF p.170) — heat-emitter R for systems whose
responsiveness is determined by the emitter type (e.g. gas /
oil / HP boilers feeding radiators or UFH). Keyed on
`heat_emitter_type`. Used as the fallback when the SAP code
isn't in the Table 4a dispatch dict.
For electric storage SAP codes (402, 403, 405, 406) Table 4a
Cat 7 splits R between the off-peak tariff (7-hour / 10-hour)
section and the 24-hour heating tariff section. Per SAP 10.2
§12.4.3 (PDF p.36) the 18-hour tariff has "electricity at the
low-rate price ... available for 18 hours per day" with at most
6h of interruption / 2h max each — operationally equivalent to
24-hour for storage-heater charging. The cascade therefore routes
EIGHTEEN_HOUR + TWENTY_FOUR_HOUR through the 24-hour Table 4a
sub-rows when an override is registered for the lodged SAP code.
Cert-side heat_emitter_type enum (per `_ELMHURST_HEAT_EMITTER_TO_SAP10`
at datatypes/epc/domain/mapper.py:3646):
1 = Radiators → R = 1.0
2 = Underfloor (in screed above insulation) → R = 0.75
3 = Underfloor (timber floor) → R = 1.0
4 = Warm air → R = 1.0
5 = Fan coils → R = 1.0
"Concrete slab" UFH (Table 4d R=0.25) has no cert-side enum entry
yet — that variant would need a new mapper code before the cascade
can dispatch it.
Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging
(None / 0 / "") returns modal default R=1.0 (radiators); lodging
present but unmapped raises `UnmappedSapCode` so the spec-coverage
gap surfaces at test time.
"""
if main is None:
return 1.0
# Table 4a — per-heating-system R (overrides emitter lookup).
sap_code = main.sap_main_heating_code
if sap_code is not None:
# 24-hour / 18-hour tariff override for electric storage heater
# rows that split between the off-peak and 24-hour sub-tables.
if (
tariff in _CONTINUOUS_CHARGING_TARIFFS
and sap_code in _RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE
):
return _RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE[sap_code]
if sap_code in _RESPONSIVENESS_BY_SAP_CODE:
return _RESPONSIVENESS_BY_SAP_CODE[sap_code]
# Table 4d — fallback per emitter type.
emitter = main.heat_emitter_type
if not emitter:
return 1.0
if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE:
return _RESPONSIVENESS_BY_EMITTER_CODE[emitter]
raise UnmappedSapCode("heat_emitter_type", emitter)
# SAP 10.2 §12.4.3 (PDF p.36) — tariffs with near-continuous low-rate
# availability for storage heaters. The 18-hour tariff allows at most
# 6h of interruption split into ≤2h windows, so the storage heaters
# charge essentially continuously — functionally the same as the
# explicit 24-hour heating tariff for the purposes of selecting the
# Table 4a R sub-row.
_CONTINUOUS_CHARGING_TARIFFS: Final[frozenset[Tariff]] = frozenset({
Tariff.EIGHTEEN_HOUR,
Tariff.TWENTY_FOUR_HOUR,
})
# SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters" —
# 24-hour heating tariff sub-table overrides for the codes whose R
# differs from the off-peak default (only the differing rows; 404,
# 407, 409 keep the same R in both sub-tables).
_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: Final[dict[int, float]] = {
402: 0.40, # Slimline storage (off-peak 0.20 → 24-hr 0.40)
403: 0.40, # Convector storage (off-peak 0.20 → 24-hr 0.40)
405: 0.60, # Slimline + Celect (off-peak 0.40 → 24-hr 0.60)
406: 0.60, # Convector + Celect (off-peak 0.40 → 24-hr 0.60)
}
# SAP 10.2 Table 4a (PDF p.163-170) — per-heating-system responsiveness R.
# These rows override the emitter-based Table 4d lookup because the spec
# explicitly lists R against the heating system (the system's intrinsic
# response time dominates over the emitter's distribution dynamics).
# Slice S0380.135 added the solid-fuel rows; S0380.137 added electric
# storage / direct-acting / underfloor / electric ceiling rows. More
# entries are added as fixtures surface them. SAP codes not in this
# dict fall through to Table 4d.
#
# A few electric storage codes (402, 403, 405, 407) carry a *different*
# R value in the 24-hour tariff section vs the off-peak section (e.g.
# Slimline 402 = R=0.2 off-peak / R=0.4 24-hour). This dict captures
# the off-peak value as the default because the 24-hour tariff is rare
# in the corpus (no variant lodges it). If a 24-hour-tariff cert
# surfaces with one of these codes the dispatch needs to be promoted
# to a (sap_code, tariff) lookup; until then the off-peak default
# applies (under-shoots R for the 24-hour case).
_RESPONSIVENESS_BY_SAP_CODE: Final[dict[int, float]] = {
# Solid-fuel independent boilers (Table 4a p.169):
151: 0.75, # Manual feed independent boiler
153: 0.75, # Auto (gravity) feed independent boiler
155: 0.75, # Wood chip/pellet independent boiler
# Solid-fuel room heaters with boiler to radiators (p.169):
156: 0.50, # Open fire with back boiler to radiators
158: 0.50, # Closed room heater with boiler to radiators
159: 0.75, # Stove (pellet-fired) with boiler to radiators
# Range cooker boilers (p.169):
160: 0.50, # Range cooker boiler (integral oven and boiler)
161: 0.50, # Range cooker boiler (independent oven and boiler)
# Solid-fuel room heaters without radiators (p.170 — alternative
# SAP code range for the same physical appliances):
631: 0.50, # Open fire in grate
632: 0.50, # Open fire with back boiler (no radiators)
633: 0.50, # Closed room heater
634: 0.50, # Closed room heater with boiler (no radiators)
635: 0.75, # Stove (pellet fired)
636: 0.75, # Stove (pellet fired) with boiler (no radiators)
# Electric storage heaters off-peak tariff (Table 4a p.170):
401: 0.00, # Old (large volume) storage heaters
402: 0.20, # Slimline storage heaters (24-hr tariff: 0.40)
403: 0.20, # Convector storage heaters (24-hr tariff: 0.40)
404: 0.40, # Fan storage heaters
405: 0.40, # Slimline storage heaters with Celect-type control
# (24-hr tariff: 0.60)
407: 0.60, # Fan storage heaters with Celect-type control
# (24-hr tariff: 0.60 — same)
408: 0.60, # Integrated storage+direct-acting heater
409: 0.80, # High heat retention storage heaters (§9.2.8)
# Electric underfloor heating off-peak / standard tariffs:
421: 0.00, # In concrete slab (off-peak only)
422: 0.25, # Integrated (storage+direct-acting)
423: 0.50, # Integrated (storage+direct-acting) low off-peak
424: 0.75, # In screed above insulation
425: 1.00, # In timber floor / immediately below floor covering
# Electric warm air:
515: 0.75, # Electricaire system
# Electric direct-acting room heaters (Table 4a p.170):
691: 1.00, # Panel, convector or radiant heaters
694: 1.00, # Water- or oil-filled radiators
# Electric ceiling heating (Table 4a Group 7 dispatch):
701: 0.75,
}
# SAP 10.2 Table 4d (PDF p.170) — heat-emitter responsiveness R.
# Keyed on the Elmhurst-mapper cert-side integer enum (mirrored by the
# API mapper which passes the integer through directly). Pre-S0380.89
# the cascade had `if emitter == 2: return 0.25` — silently mis-treating
# screed UFH (spec R=0.75) as concrete-slab UFH (spec R=0.25). The
# spec R-table is keyed on physical emitter category, not on a single
# "underfloor" lumping.
_RESPONSIVENESS_BY_EMITTER_CODE: Final[dict[int, float]] = {
1: 1.0, # Radiators
2: 0.75, # Underfloor (in screed above insulation)
3: 1.0, # Underfloor (timber floor)
4: 1.0, # Warm air
5: 1.0, # Fan coils
}
def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
"""Resolve `MainHeatingDetail.main_fuel_type` to a SAP fuel code.
- `main is None` (no main heating system) → None.
- `main_fuel_type` is an int → that code.
- `main_fuel_type` is anything else (empty string from a mapper
extraction gap, or an unmapped string label like 'Bulk LPG') →
raise `MissingMainFuelType`. Heating fuel has no defensible
"assume as-built" default (silently routing to mains gas
mis-categorises CO2 / PE / efficiency), so the cascade strict-
raises to force the mapper-side fix. Mirror of the
[[reference-unmapped-sap-code]] strict-raise pattern.
"""
if main is None:
return None
fuel = main.main_fuel_type
if isinstance(fuel, int):
return fuel
raise MissingMainFuelType(fuel, main.sap_main_heating_code)
def _fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail], prices: PriceTable
) -> float:
"""Convert main-fuel unit price → £/kWh using the supplied price
table. Unknown fuel falls back to mains gas per the table's default.
For CHP+boilers community heating (RdSAP 10 §C / SAP 10.2 Appendix
C — PDF p.58 default 35% CHP / 65% boilers when no PCDB record),
returns the heat-fraction-weighted blended price of the CHP fuel
code + the upstream boiler fuel code. The Elmhurst worksheet block
10b verifies this exactly: (340) = (307a) × CHP_price + (307b) ×
boiler_price = (307) × [chp_frac × CHP_price + (1 - chp_frac) ×
boiler_price]. Per [[feedback-spec-citation-in-commits]] the rule
is RdSAP 10 §C verbatim.
"""
if (
main is not None
and main.community_heating_chp_fraction is not None
and main.community_heating_boiler_fuel_type is not None
):
chp_frac = main.community_heating_chp_fraction
chp_price = prices.unit_price_p_per_kwh(_main_fuel_code(main))
boiler_price = prices.unit_price_p_per_kwh(
main.community_heating_boiler_fuel_type,
)
blended_p = chp_frac * chp_price + (1.0 - chp_frac) * boiler_price
return blended_p * _PENCE_TO_GBP
return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
# RdSAP energy_tariff enum (per datatypes/epc/domain/epc_codes.csv):
# 1 = dual (off-peak / Economy-7)
# 2 = Single (standard tariff)
# 3 = Unknown (Elmhurst-on-gas-property test says Single;
# corpus signal for electric dwellings says
# off-peak — see _is_off_peak_meter)
# 4 = dual (24 hour) (off-peak / 24h heating)
# 5 = off-peak 18 hour (off-peak)
#
# Different from the SAP-Schema enum which is 1=standard, 2=off-peak.
# Our corpus is RdSAP so we use RdSAP codes.
_RDSAP_UNKNOWN_METER: Final[frozenset[int]] = frozenset({3})
def _is_off_peak_meter(meter_type: object, *, fuel_is_electric: bool) -> bool:
"""Whether the dwelling bills the given end-use (fuel_is_electric) at
the off-peak rate. Routes through `tariff_from_meter_type` so every
lodging form recognised there (int 1/4/5, bare "18 Hour", long
"off-peak 18 hour", "Dual", "Dual (24 hour)", numeric strings) is
consistently classified as off-peak. Code 2 (Single) is always
standard. Code 3 (Unknown) routes to STANDARD per the spec-faithful
table_12a default, but `_is_off_peak_meter` applies the heuristic
"electric end-uses on Unknown meters typically come from E7-
eligible dwellings whose tariff the assessor couldn't pin down"
so Unknown + electric returns True, Unknown + non-electric stays
False. Pre-S0380.139 this helper had its own string-dispatch that
only recognised "off-peak 18 hour" (the RdSAP long form), so the
bare "18 Hour" lodging (Elmhurst Summary §14.2's surface form per
[[reference-elmhurst-only-test-pattern]]) mis-classified to False
and billed electric secondary heating at standard 13.19 p/kWh
instead of the 18-hour low rate 7.41 p/kWh across the 41-variant
corpus."""
if meter_type is None:
return False
try:
tariff = tariff_from_meter_type(meter_type)
except UnmappedSapCode:
return False
if tariff is not Tariff.STANDARD:
return True
# STANDARD branch — distinguish Single (always standard) from Unknown
# (off-peak heuristic for electric end-uses only). Per the
# `_METER_INT_TO_TARIFF` mapping both Single (code 2) and Unknown
# (code 3) land here; we need the code itself to decide.
if isinstance(meter_type, int):
code = meter_type
elif isinstance(meter_type, str):
s = meter_type.strip().lower()
if s in {"unknown", "3", ""}:
code = 3
else:
return False
else:
return False
return code in _RDSAP_UNKNOWN_METER and fuel_is_electric
def _is_electric_main(main: Optional[MainHeatingDetail]) -> bool:
"""Main heating fuel is electricity. Delegates to the canonical
Table-32-first normalisation in `table_32.is_electric_fuel_code`.
Pre-S0380.136 this hand-rolled a literal set check
`code in {10, 25, 29}` (API codes) `{30..40}` (Table 32 codes).
That silently mis-classified dual-fuel mains (Table 32 code 10 =
"dual fuel mineral+wood", S0380.135 EES dict BDI → 10) as electric,
re-routing space-heating cost to the 7-hour low electric rate
(5.50 p/kWh) instead of dual-fuel 3.99 p/kWh — solid fuel 6 SAP
residual 7.38 → 11.37.
"""
return is_electric_fuel_code(_main_fuel_code(main))
def _is_electric_water(water_heating_fuel: Optional[int]) -> bool:
"""Same as `_is_electric_main` for the water-heating fuel code.
See its docstring for the API/Table 32 collision rationale."""
return is_electric_fuel_code(water_heating_fuel)
# RdSAP 10 Table 32 (page 95) — (high_rate_p, low_rate_p) per tariff.
# Codes 31-34 cover E7/E10 directly; 38/40 cover 18-hour; 35 is the
# single-rate 24-hour heating tariff (no high/low split).
_TARIFF_HIGH_LOW_RATES_P_PER_KWH: Final[dict[Tariff, tuple[float, float]]] = {
Tariff.SEVEN_HOUR: (15.29, 5.50), # Table 32 codes 32, 31
Tariff.TEN_HOUR: (14.68, 7.50), # Table 32 codes 34, 33
Tariff.EIGHTEEN_HOUR: (13.67, 7.41), # Table 32 codes 38, 40
Tariff.TWENTY_FOUR_HOUR: (6.61, 6.61), # Table 32 code 35 (no split)
}
def _tariff_high_low_rates_p_per_kwh(tariff: Tariff) -> tuple[float, float]:
"""RdSAP 10 Table 32 (page 95) per-tariff (high, low) rate tuples.
STANDARD has no split (callers must early-return before this fires);
the remaining 4 tariffs all have spec rates. Strict-dispatch per
[[reference-unmapped-sap-code]]: any future Tariff enum addition
must add an entry — this raise enforces."""
if tariff in _TARIFF_HIGH_LOW_RATES_P_PER_KWH:
return _TARIFF_HIGH_LOW_RATES_P_PER_KWH[tariff]
raise UnmappedSapCode("tariff_high_low_rates", tariff)
def _off_peak_low_rate_gbp_per_kwh(tariff: Tariff) -> float:
"""Off-peak low-rate £/kWh for an off-peak tariff. Per RdSAP 10 §19
Table 32 (p.95) the low-rate price varies by tariff: code 31 for
7-hour (5.50), code 33 for 10-hour (7.50), code 40 for 18-hour
(7.41), code 35 for 24-hour heating (6.61). Pre-S0380.138 every
off-peak callsite read `prices.e7_low_rate_p_per_kwh` (5.50 — code
31 only) for every tariff, under-counting 18-hour cost by
1.91 p/kWh × off-peak kWh. Routes through
`_tariff_high_low_rates_p_per_kwh` so STANDARD raises (callers
early-return) and any future Tariff enum addition surfaces as a
strict-raise per [[reference-unmapped-sap-code]]."""
_high, low = _tariff_high_low_rates_p_per_kwh(tariff)
return low * _PENCE_TO_GBP
def _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type: object) -> float:
"""Off-peak low-rate £/kWh for callsites that detect off-peak via the
`_is_off_peak_meter` heuristic (RdSAP meter code 3 = Unknown is
treated as off-peak for electric end-uses; see _is_off_peak_meter
docstring). When the meter resolves to a known off-peak tariff
(codes 1/4/5), bills at that tariff's Table 32 low rate; when the
meter resolves to STANDARD (codes 2 = Single, 3 = Unknown), falls
back to the SEVEN_HOUR rate (5.50, Table 32 code 31). Codifies the
heuristic that pre-S0380.138 was baked into the literal
`prices.e7_low_rate_p_per_kwh` constant."""
tariff = tariff_from_meter_type(meter_type)
if tariff is Tariff.STANDARD:
_high, low = _tariff_high_low_rates_p_per_kwh(Tariff.SEVEN_HOUR)
return low * _PENCE_TO_GBP
return _off_peak_low_rate_gbp_per_kwh(tariff)
# Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2
# Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the
# Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into
# distinct Table 12d profiles; 18-hour (38/40) and 24-hour (35) fall
# through to standard code 30 monthly factors in Table 12d itself, so
# no dual-rate split applies for them.
_TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12: Final[dict[Tariff, tuple[int, int]]] = {
Tariff.SEVEN_HOUR: (32, 31), # 7-hour high, 7-hour low
Tariff.TEN_HOUR: (34, 33), # 10-hour high, 10-hour low
}
def _table_12a_system_for_main(
main: Optional[MainHeatingDetail],
) -> Optional[Table12aSystem]:
"""Map a main heating system to its Table 12a Grid 1 (SH) row.
Heat pumps lodge as `ASHP_APP_N` when a PCDB Table 362 record is
available (Appendix N efficiency cascade) and `ASHP_OTHER`
otherwise. The "other" rows split by water-heating route — for
SH-cost purposes the differentiation doesn't matter (the SH
column carries the same fraction across ASHP_OTHER / _OFF_PEAK_
IMMERSION / _NO_IMMERSION on Grid 1), so ASHP_OTHER is the
canonical default.
Coverage as fixtures land:
- ASHP / GSHP (codes 211-224, 521-524, PCDB index) — wired
- Storage heaters (401-409) — TODO
- Underfloor heating (421-422) — TODO
- Direct-acting electric (191) / CPSU (192) / electric storage
boiler (193, 195) — TODO
"""
if main is None:
return None
code = main.sap_main_heating_code
has_pcdb_hp = (
main.main_heating_index_number is not None
and heat_pump_record(main.main_heating_index_number) is not None
)
# ASHP — Table 4a rows 211-217 (earlier generations) + 221-227
# (2013+) cover the air-source space. Warm-air ASHPs are 521-524.
if code is not None and (
211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524
):
return Table12aSystem.ASHP_APP_N if has_pcdb_hp else Table12aSystem.ASHP_OTHER
return None
def _space_heating_fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail],
tariff: Tariff,
prices: PriceTable,
) -> float:
"""Space heating bills at the main fuel's rate. For electric mains
on an off-peak tariff, applies the SAP 10.2 Table 12a Grid 1 SH
high-rate fraction → blended scalar rate. Mathematically equivalent
to splitting kWh into high and low components and pricing each
separately at Table 32 rates. When Grid 1 has no SH row yet for the
electric system (storage / direct-acting / UFH coverage queued),
falls back to the tariff's 100% low-rate per Table 32."""
if not _is_electric_main(main) or tariff is Tariff.STANDARD:
return _fuel_cost_gbp_per_kwh(main, prices)
system = _table_12a_system_for_main(main)
if system is None:
return _off_peak_low_rate_gbp_per_kwh(tariff)
try:
high_frac = space_heating_high_rate_fraction(system, tariff)
except NotImplementedError:
return _off_peak_low_rate_gbp_per_kwh(tariff)
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP
def _hot_water_fuel_cost_gbp_per_kwh(
water_heating_fuel: Optional[int],
main: Optional[MainHeatingDetail],
tariff: Tariff,
prices: PriceTable,
*,
inherit_main_for_community_heating: bool = False,
) -> float:
"""Hot water bills at the *water-heating* fuel's rate. When the
water-heating fuel is electric AND tariff is off-peak, bill at the
off-peak rate (immersion / HP DHW running on the timer). When the
water fuel is a non-electric fuel (gas / oil / LPG), tariff is
not consulted — those fuels are single-rate per Table 32. For
cert 000565 HW routes to gas combi via WHC 914 → tariff branch
not taken. TODO: Table 12a Grid 1 WH high-rate-fraction split for
electric WH on off-peak (currently uses 100% low rate).
`inherit_main_for_community_heating`: per S0380.173, when WHC
{901, 902, 914} AND main is a heat network, ignore the cert-
lodged HW fuel (which Elmhurst defaults to "Mains gas") and route
HW cost through `_fuel_cost_gbp_per_kwh(main, prices)` — same
helper that applies the .171 CHP heat-fraction blend for SAP 302
+ heat-network rate for code 41 / 51 / 53 / 54.
"""
if inherit_main_for_community_heating:
return _fuel_cost_gbp_per_kwh(main, prices)
water_electric = _is_electric_water(water_heating_fuel)
if water_electric and tariff is not Tariff.STANDARD:
return _off_peak_low_rate_gbp_per_kwh(tariff)
if water_heating_fuel is not None:
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
return _fuel_cost_gbp_per_kwh(main, prices)
def _secondary_fraction(
main: Optional[MainHeatingDetail], secondary_heating_type: object
) -> float:
"""SAP 10.2 Table 11 lookup by main heating category, applied only
when (a) the cert has a secondary system lodged OR (b) the main
heating code is in the §A.2.2 forced-secondary set (electric storage
heaters). Returns 0.0 when neither applies — the most common case
for gas/oil main systems whose cert doesn't lodge a secondary.
`main_heating_fraction` on the cert is NOT consulted here: empirical
probe shows it tracks main-system-1 vs main-system-2 allocation in
multi-main configurations (99% of corpus has =1, meaning "single
main, 100% allocation"), not main-vs-secondary. Elmhurst applies
Table 11's 10% secondary even when main_heating_fraction=1; the
spec is silent on overriding (only the §A.2.2 forced-secondary rule
is explicit), and an S-B30 attempt to override yielded SAP MAE
+0.16 — the wrong direction.
Per-SAP-code dispatch via
`_SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE` (added S0380.144)
splits the Table 11 "Electric storage heaters (not integrated)"
row into its three Table 4a sub-types (not-fan-assisted 0.15,
fan-assisted 0.10, HHR 0.10). Pre-S0380.144 the Elmhurst mapper
left `main_heating_category=None` on every electric variant, and
the cascade fell through to the 0.10 default — missing the 0.15
not-fan-assisted sub-row on codes 401/402/403/405/406.
"""
if main is None:
return 0.0
code = main.sap_main_heating_code
has_lodged_secondary = secondary_heating_type is not None
force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES
if not has_lodged_secondary and not force:
return 0.0
if (
code is not None
and code in _SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE
):
return _SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE[code]
return _secondary_heating_fraction_for_category(main.main_heating_category)
def _secondary_heating_fraction_for_category(
main_heating_category: Optional[int],
) -> float:
"""SAP 10.2 Table 11 secondary-heating fraction by main heating
category. Strict-dispatch per [[reference-unmapped-sap-code]]:
absent (None) returns the modal default 0.10; present-but-unmapped
raises."""
if main_heating_category is None:
return _SECONDARY_HEATING_FRACTION_DEFAULT
if main_heating_category in _SECONDARY_HEATING_FRACTION_BY_CATEGORY:
return _SECONDARY_HEATING_FRACTION_BY_CATEGORY[main_heating_category]
raise UnmappedSapCode("main_heating_category", main_heating_category)
def _secondary_efficiency(
sap_heating, main_code: Optional[int], main_fuel: Optional[int]
) -> float:
"""Look up secondary efficiency from cert's secondary_heating_type
code, falling back to portable electric heater (code 693, eff 1.0)
per SAP §A.2.2 default."""
code = _int_or_none(sap_heating.secondary_heating_type)
if code is None:
code = _DEFAULT_SECONDARY_HEATING_CODE
return seasonal_efficiency(code, None, None)
def _secondary_fuel_cost_gbp_per_kwh(
sap_heating,
main: Optional[MainHeatingDetail],
meter_type: object,
prices: PriceTable,
) -> float:
"""Secondary fuel cost. When secondary_fuel_type is missing, default
to portable-electric (code 30 standard electricity, or off-peak
under E7-eligible meter). The cert's secondary is an electric room
heater per the §A.2.2 default."""
sec_fuel = sap_heating.secondary_fuel_type
if sec_fuel is None:
# Default to electricity since the default secondary system is
# portable electric heaters (code 693).
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
# When secondary_fuel_type is electricity, apply off-peak if applicable.
if _is_electric_water(sec_fuel) and _is_off_peak_meter(
meter_type, fuel_is_electric=True
):
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP
def _pv_array_generation_kwh_per_yr(
array: PhotovoltaicArray,
climate: "int | PostcodeClimate",
) -> float:
"""SAP 10.2 Appendix M (M1) for a single array: EPV = 0.8 × kWp × S × ZPV.
S is the Appendix U3.3 annual solar radiation for the array's
orientation and tilt under `climate` (UK average region 0 for ratings,
PCDB Table 172 PostcodeClimate for demand); ZPV is the Table M1
overshading factor. Arrays with missing peak power contribute zero."""
if array.peak_power is None:
return 0.0
s = _pv_annual_s_kwh_per_m2(array.orientation, array.pitch, climate)
z = _pv_overshading_factor(array.overshading)
return _PV_MODULE_EFFICIENCY_FACTOR * array.peak_power * s * z
def _pv_generation_kwh_per_yr(
epc: EpcPropertyData,
climate: "int | PostcodeClimate",
) -> float:
"""Annual PV generation (kWh/yr) summed per-array. Per SAP 10.2
Appendix M §M1: "If there is more than one PV array … apply this
process to each and sum the monthly electricity generation figures."
`climate` selects UK-average (region 0) for the rating cascade or
postcode-specific (PCDB Table 172) for the demand cascade.
Falls back to RdSAP 10 §11.1 b) when the cert lodges only a "% of
roof area" PV figure (no detailed kWp): synthesize a single PV
array with kWp = 0.12 × PV area, South orientation, 30° pitch,
Modest overshading.
"""
arrays = epc.sap_energy_source.photovoltaic_arrays
if not arrays:
arrays = _synthesize_pv_arrays_from_percent_roof_area(epc)
if not arrays:
return 0.0
return sum(_pv_array_generation_kwh_per_yr(a, climate) for a in arrays)
def _pv_array_monthly_generation_kwh(
array: PhotovoltaicArray,
climate: "int | PostcodeClimate",
) -> tuple[float, ...]:
"""SAP 10.2 Appendix M1 §2 (p.92) — apportion the annual E_PV of one
array to months in proportion to monthly solar radiation:
E_PV,m = 0.8 × kWp × ZPV × (days_m × S_m × 24 / 1000)
where S_m is the §U3.2 surface flux (W/m²). Returns a 12-zero tuple
for arrays whose orientation isn't mapped in
`ORIENTATION_BY_SAP10_CODE` (defensive — current cert lodgements
always cover 1..8)."""
orientation = ORIENTATION_BY_SAP10_CODE.get(array.orientation)
if orientation is None:
return (0.0,) * 12
pitch_deg = _pv_pitch_deg(array.pitch)
z = _pv_overshading_factor(array.overshading)
monthly: list[float] = []
for month_idx, days in enumerate(_DAYS_PER_MONTH):
s_m_w_per_m2 = surface_solar_flux_w_per_m2(
orientation=orientation,
pitch_deg=pitch_deg,
region=climate,
month=month_idx + 1,
)
s_m_kwh_per_m2 = days * s_m_w_per_m2 * _HOURS_PER_DAY_OVER_1000
epv_m = _PV_MODULE_EFFICIENCY_FACTOR * array.peak_power * z * s_m_kwh_per_m2
monthly.append(epv_m)
return tuple(monthly)
def _pv_monthly_generation_kwh(
epc: EpcPropertyData,
climate: "int | PostcodeClimate",
) -> tuple[float, ...]:
"""SAP 10.2 Appendix M1 §2 (p.92) — monthly E_PV summed across all
PV arrays. Annual sum matches `_pv_generation_kwh_per_yr` to
float precision."""
arrays = epc.sap_energy_source.photovoltaic_arrays
if not arrays:
arrays = _synthesize_pv_arrays_from_percent_roof_area(epc)
if not arrays:
return (0.0,) * 12
monthly_sum: list[float] = [0.0] * 12
for arr in arrays:
for m, kwh in enumerate(_pv_array_monthly_generation_kwh(arr, climate)):
monthly_sum[m] += kwh
return tuple(monthly_sum)
def _pv_battery_capacity_kwh(epc: EpcPropertyData) -> float:
"""SAP 10.2 Appendix M1 §3c — total usable battery capacity (kWh)
for the dwelling. Sums lodged `pv_battery.battery_capacity` across
the lodged `pv_battery_count`. Returns 0 when no battery lodged.
`pv_split_monthly` caps Cbat at 15 per spec; that cap is applied
inside `pv_beta_coefficients` and not duplicated here."""
es = epc.sap_energy_source
if es.pv_batteries is None:
return 0.0
per_battery_kwh = float(es.pv_batteries.pv_battery.battery_capacity)
if per_battery_kwh <= 0.0:
return 0.0
count = es.pv_battery_count if es.pv_battery_count > 0 else 1
return per_battery_kwh * count
# SAP 10.2 Appendix M1 §3a (p.93) — Table-12 fuel codes whose monthly
# kWh count toward E_space,m (electricity used for space heating, not
# at the off-peak low-rate). Per the spec footnote 32: "excludes
# electricity used for off-peak space and water heating".
_PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES: Final[frozenset[int]] = frozenset(
{30, 32, 34, 35, 38}
)
# SAP 10.2 Appendix M1 §3a — fuel codes for which E_water,m is the
# full monthly water-heating fuel kWh (no (243) immersion-off-peak
# scaling). Per spec: "E_water,m = (219)m if water heating fuel code
# applied in Section 10a of the SAP worksheet is 30". For simplicity
# the off-peak immersion × (243) branch is deferred; non-30 electric
# water heating fuels contribute zero E_water,m.
_PV_ELIGIBLE_WATER_HEATING_FUEL_CODES: Final[frozenset[int]] = frozenset({30})
def _pv_eligible_demand_monthly_kwh(
*,
lighting_monthly_kwh: tuple[float, ...],
appliances_monthly_kwh: tuple[float, ...],
cooking_monthly_kwh: tuple[float, ...],
electric_shower_monthly_kwh: tuple[float, ...],
pumps_fans_monthly_kwh: tuple[float, ...],
main_1_fuel_monthly_kwh: tuple[float, ...],
hot_water_monthly_kwh: tuple[float, ...],
main_fuel_code_table_12: Optional[int],
water_heating_fuel_code_table_12: Optional[int],
) -> tuple[float, ...]:
"""SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand
D_PV,m. Always includes lighting + appliances + cooking + electric
shower + pumps & fans. Includes E_space,m only when the main
heating fuel is electricity at the standard tariff (codes 30, 32,
34, 35, 38 per spec). Includes E_water,m only when the water
heating fuel code is 30 (standard electricity) per spec.
The off-peak immersion × (243) Ewater branch and the Appendix G4
PV diverter adjustment are deferred — current cohort fixtures
don't exercise them."""
include_space = (
main_fuel_code_table_12 is not None
and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES
)
include_water = (
water_heating_fuel_code_table_12 is not None
and water_heating_fuel_code_table_12 in _PV_ELIGIBLE_WATER_HEATING_FUEL_CODES
)
monthly: list[float] = []
for m in range(12):
d = (
lighting_monthly_kwh[m]
+ appliances_monthly_kwh[m]
+ cooking_monthly_kwh[m]
+ electric_shower_monthly_kwh[m]
+ pumps_fans_monthly_kwh[m]
)
if include_space:
d += main_1_fuel_monthly_kwh[m]
if include_water:
d += hot_water_monthly_kwh[m]
monthly.append(d)
return tuple(monthly)
# RdSAP 10 §11.1 b): when the kWp is not lodged but the cert lodges a
# "% of roof area" PV figure, derive the PV peak power as
# `0.12 × PV area`, with PV area being the dwelling's roof area for
# heat loss (Σ top-floor areas across BPs, divided by cos(35°) for
# pitched parts), times the percent coverage. Defaults: South, 30°,
# Modest overshading.
_PV_PEAK_POWER_KWP_PER_M2: Final[float] = 0.12
_PV_PITCHED_ROOF_COS_FACTOR_DEG: Final[float] = 35.0
_PV_PERCENT_ROOF_AREA_DEFAULT_ORIENTATION_CODE: Final[int] = 5 # South
_PV_PERCENT_ROOF_AREA_DEFAULT_PITCH_CODE: Final[int] = 2 # 30°
_PV_PERCENT_ROOF_AREA_DEFAULT_OVERSHADING_CODE: Final[int] = 2 # Modest
def _synthesize_pv_arrays_from_percent_roof_area(
epc: EpcPropertyData,
) -> Optional[list[PhotovoltaicArray]]:
"""RdSAP 10 §11.1 b) "Proportion of roof area" PV synthesis.
The spec text (RdSAP 10 specification, page 60):
"If the kWp (or DNC) is not known use the following: PV area is
roof area for heat loss (before amendment for any room-in-roof),
times percent of roof area covered by PVs, and if pitched roof
divided by cos(35°). If there is an extension, the roof area is
adjusted by the cosine factor only for those parts having a
pitched roof. kWp is 0.12 × PV area."
Returns None when the percent_roof_area lodgement is missing or
zero, or when no building-part geometry is available. Otherwise
returns a single-array list (RdSAP's "% of roof area" path lodges
one aggregate figure, not per-array).
"""
pv_supply = epc.sap_energy_source.photovoltaic_supply
if pv_supply is None:
return None
pct = pv_supply.none_or_no_details.percent_roof_area
if pct <= 0:
return None
parts = epc.sap_building_parts or []
if not parts:
return None
cos_factor = math.cos(math.radians(_PV_PITCHED_ROOF_COS_FACTOR_DEG))
pv_area_m2 = 0.0
for part in parts:
if not part.sap_floor_dimensions:
continue
# Roof area for heat loss per RdSAP 10 §3.8 = the greatest of
# the floor areas on each level (i.e. the top floor's area).
top_floor_area = max(
(fd.total_floor_area_m2 or 0.0) for fd in part.sap_floor_dimensions
)
roof_type = (part.roof_construction_type or "").lower()
is_pitched = "pitched" in roof_type or "sloping" in roof_type
bp_pv_area = top_floor_area * (pct / 100.0)
if is_pitched:
bp_pv_area /= cos_factor
pv_area_m2 += bp_pv_area
# RdSAP10 §15 p.66: "kWp for photovoltaics, etc.: 2 d.p." — round
# before the EPV cascade so it matches the worksheet's "Cells Peak"
# column (cert 6835: cascade 0.12 × 36.9 × 0.40 / cos(35°) = 2.16224
# → 2.16, matching worksheet "Cells Peak = 2.16"). The 0.0022 kWp
# delta otherwise feeds straight into (233) PV generation as a
# +1.5 kWh/yr over-credit and a +0.015 SAP residual.
kwp = _round_half_up(_PV_PEAK_POWER_KWP_PER_M2 * pv_area_m2, 2)
if kwp <= 0:
return None
return [
PhotovoltaicArray(
peak_power=kwp,
pitch=_PV_PERCENT_ROOF_AREA_DEFAULT_PITCH_CODE,
orientation=_PV_PERCENT_ROOF_AREA_DEFAULT_ORIENTATION_CODE,
overshading=_PV_PERCENT_ROOF_AREA_DEFAULT_OVERSHADING_CODE,
),
]
def _pv_export_credit_gbp_per_kwh() -> float:
"""PV cost credit per kWh generated. Per ADR-0010 §10 the rating
cascade uses RdSAP10 Table 32 prices; code 60 (PV export to grid)
= 13.19 p/kWh (same as standard electricity — PV gen displaces
grid imports at the standard rate). The legacy SAP 10.2 Table 12
value (5.59 p/kWh) is no longer the target and is intentionally
not read here, so the CalculatorInputs boundary reports the same
rate _fuel_cost applies internally."""
return table_32_unit_price_p_per_kwh(_PV_EXPORT_TARIFF_CODE) * _PENCE_TO_GBP
def _pv_dwelling_import_price_gbp_per_kwh(
meter_type: object, prices: PriceTable
) -> float:
"""PV dwelling-consumption price per kWh per SAP 10.2 Appendix M1 §6
(p.94): "apply the normal import electricity price to PV energy used
within the dwelling". Onsite-consumed PV displaces grid IMPORTS, so
it bills at the standard electricity import tariff (Table 32 code 30
under the RdSAP10 amendment per ADR-0010 §10 = 13.19 p/kWh — the
same rate `_fuel_cost`'s `other_uses_p_per_kwh` already pays for
lighting/pumps/fans, and crucially the same rate Table 32 code 60
pays for the EXPORT credit. In Table 32 these collapse to a single
13.19 p value, so the IMPORT/EXPORT split is mathematically
equivalent to the legacy single-rate-EXPORT credit — but the
distinction matters when an off-peak tariff lands: §6 then directs
a weighted Table 12a high/low rate, deferred until the first off-
peak cost cert ships."""
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
# Off-peak weighted Table 12a rate (deferred — `_fuel_cost`
# short-circuits Tariff != STANDARD before reaching this path).
# Routes through the meter-heuristic helper so an Unknown-meter
# cert (code 3 = "treat as off-peak for electric end-uses" per
# _is_off_peak_meter) falls back to the SEVEN_HOUR low rate
# rather than raising on STANDARD.
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return table_32_unit_price_p_per_kwh(30) * _PENCE_TO_GBP
def _other_fuel_cost_gbp_per_kwh(
tariff: Tariff, prices: PriceTable
) -> float:
"""Pumps, fans, and lighting are always electric. When the dwelling
is on an off-peak tariff, applies the Table 12a Grid 2
ALL_OTHER_USES high-rate fraction → blended Table 32 rate. Standard
tariff bypasses to the prices table's flat scalar (preserves the
cohort fixture cost cascade at 1e-4).
SAP 10.2 §12 (PDF p.45) + Appendix F2 (PDF p.63) — for the 18-hour
tariff, "the 18-hour high rate applies to all other electricity
uses" (i.e. fraction = 1.0 at the high rate). Table 12a Grid 2 omits
18-hour and 24-hour from its 7-hour/10-hour table; for 18-hour the
spec rule is explicit (fraction 1.0 at the high rate per Appendix
F2), so route directly to the 18-hour high rate (Table 32 code 38 =
13.67 p/kWh). 24-hour heating tariff is a heating-only single-rate
tariff (Table 32 code 35 = 6.61 p/kWh) — non-heating uses fall back
to the standard electricity rate."""
if tariff is Tariff.STANDARD:
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
try:
high_frac = other_use_high_rate_fraction(
OtherUse.ALL_OTHER_USES, tariff,
)
except NotImplementedError:
if tariff is Tariff.EIGHTEEN_HOUR:
high_rate, _low = _tariff_high_low_rates_p_per_kwh(tariff)
return high_rate * _PENCE_TO_GBP
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP
def _pumps_fans_fuel_cost_gbp_per_kwh(
*,
tariff: Tariff,
mev_kwh_per_yr: float,
total_pumps_fans_kwh_per_yr: float,
) -> Optional[float]:
"""SAP 10.2 Table 12a Grid 2 — MEV/MVHR fan electricity bills at the
`FANS_FOR_MECH_VENT` high-rate fraction (10-hour: 0.58; 7-hour:
0.71), distinct from `ALL_OTHER_USES` (10-hour: 0.80; 7-hour: 0.90)
which covers central-heating circulation pumps, flue fans, solar
HW pump, and locally-generated electricity.
Returns the kWh-weighted blended rate across the two Grid 2
categories — `(mev_kwh × fans_rate + non_mev_kwh × other_rate) /
total_kwh`. Returns None on STANDARD tariff (no off-peak split
applies; the calculator's `other_fuel_cost_gbp_per_kwh` already
yields the right scalar) and when no MEV is lodged (no split
needed; the same `other_fuel_cost_gbp_per_kwh` applies).
Worksheet pin for cert 000565 (TEN_HOUR + MEV 127.5 kWh + 125 kWh
other pumps/fans):
fans_blend = 0.58 × 14.68 + 0.42 × 7.50 = 11.6644 p/kWh
other_blend = 0.80 × 14.68 + 0.20 × 7.50 = 13.2440 p/kWh
weighted = (127.5159 × 11.6644 + 125.0 × 13.2440) / 252.5159
= 12.4467 p/kWh
The (249) line in the worksheet uses the same weighting to bill
MEV at the lower 11.6644 rate; without this helper the cascade
over-counted by £2.01 / yr.
"""
if tariff is Tariff.STANDARD:
return None
if mev_kwh_per_yr <= 0.0 or total_pumps_fans_kwh_per_yr <= 0.0:
return None
try:
fans_high_frac = other_use_high_rate_fraction(
OtherUse.FANS_FOR_MECH_VENT, tariff,
)
other_high_frac = other_use_high_rate_fraction(
OtherUse.ALL_OTHER_USES, tariff,
)
except NotImplementedError:
return None
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
fans_blend = (
fans_high_frac * high_rate + (1.0 - fans_high_frac) * low_rate
)
other_blend = (
other_high_frac * high_rate + (1.0 - other_high_frac) * low_rate
)
non_mev_kwh = max(0.0, total_pumps_fans_kwh_per_yr - mev_kwh_per_yr)
weighted_p_per_kwh = (
mev_kwh_per_yr * fans_blend + non_mev_kwh * other_blend
) / total_pumps_fans_kwh_per_yr
return weighted_p_per_kwh * _PENCE_TO_GBP
# Water-heating codes that say "inherit from the main system" — the
# `seasonal_efficiency` cascade returns 0 as a sentinel for these in the
# legacy `domain.sap10_ml.sap_efficiencies` module. We need to inherit through
# the SAME cascade the main heating uses, including the main_heating_
# category fallback (e.g. heat pumps return 2.30 via category 4).
_WATER_INHERIT_FROM_MAIN_CODES: Final[frozenset[int]] = frozenset({901, 902, 914})
# Water-heating code 901 = "From main heating system" — used by the
# SAP 10.2 Appendix D §D2.1 (2) Equation D1 gate, which only applies
# when "the boiler provides both space and water heating".
_WHC_FROM_MAIN_HEATING: Final[int] = 901
# SAP 10.2 Table 4a (PDF p.163-164) — heat-pump rows have TWO efficiency
# columns ("space" and "water"). For low-temperature ground/water-source
# HPs (codes 211, 213) and all gas-fired HPs (215, 216, 217) the water
# column is lower than the space column because the HP loses efficiency
# raising water to ~55°C DHW temperatures vs ~35°C space-heating flow.
# Mirror in Category 5 warm-air HPs (codes 521, 523, 525, 526, 527).
#
# When WHC ∈ {901, 902, 914} ("HW from main heating") the cascade
# inherits the main system's efficiency for HW. For Table 4a HP codes
# the inherit must consult this Water column, NOT the Space column.
# `seasonal_efficiency` returns the Space column verbatim; this dict
# overrides for the codes where the two columns diverge.
_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY: Final[dict[int, float]] = {
# Electric heat pumps with flow temperature <= 35°C
211: 1.70, # Ground source HP (space 230)
213: 1.70, # Water source HP (space 230)
# Gas-fired heat pumps with flow temperature <= 35°C
215: 0.84, # Ground source HP (space 120)
216: 0.84, # Water source HP (space 120)
217: 0.77, # Air source HP (space 110)
# Category 5 warm-air heat pumps — same shape as Category 4
521: 1.70, # Electric GSHP warm-air (space 230)
523: 1.70, # Electric WSHP warm-air (space 230)
525: 0.84, # Gas-fired GSHP warm-air (space 120)
526: 0.84, # Gas-fired WSHP warm-air (space 120)
527: 0.77, # Gas-fired ASHP warm-air (space 110)
}
def _water_efficiency_with_category_inherit(
*,
water_heating_code: Optional[int],
main_code: Optional[int],
main_category: Optional[int],
main_fuel: Optional[int],
) -> float:
"""When the cert says "hot water comes from the main system" (codes
901 / 902 / 914), inherit the main system's efficiency — and crucially
inherit the cascade that maps `main_heating_category` to a default
when `sap_main_heating_code` is None. The legacy water_heating_efficiency
only passes main_code through and so collapses heat pumps (cat 4) +
no-code lodgements into the 0.80 gas-boiler default.
SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency
into Space and Water columns. For Table 4a HP codes with diverging
columns (`_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY`) we return the
Water value directly; `seasonal_efficiency` returns the Space value
so unconditionally inheriting through it gives the wrong number for
DHW (HP loses efficiency at higher DHW temperatures).
"""
if water_heating_code is None:
return _legacy_water_heating_efficiency(None, main_code)
if water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES:
if (
main_code is not None
and main_code in _TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY
):
return _TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY[main_code]
return seasonal_efficiency(main_code, main_category, main_fuel)
return _legacy_water_heating_efficiency(water_heating_code, main_code)
def _effective_monthly_factor(
monthly_kwh: tuple[float, ...],
monthly_factors: Optional[tuple[float, ...]],
) -> Optional[float]:
"""Days-weighted effective annual factor = Σ(kWh_m × factor_m) / Σ kWh_m.
Used to translate SAP 10.2 Table 12d (CO2) and Table 12e (PE) monthly
cascades into the calculator's annual × factor shape. Returns None
when factors are None (non-electricity fuel — caller falls back to the
annual Table 12 factor) or when total kWh is zero."""
if monthly_factors is None:
return None
total_kwh = sum(monthly_kwh)
if total_kwh <= 0.0:
return None
return sum(k * f for k, f in zip(monthly_kwh, monthly_factors)) / total_kwh
def _effective_monthly_co2_factor(
monthly_kwh: tuple[float, ...], fuel_code: int
) -> Optional[float]:
"""SAP 10.2 Table 12d (p.194) monthly CO2 cascade. Thin wrapper over
`_effective_monthly_factor` for the CO2 lookup."""
return _effective_monthly_factor(
monthly_kwh, co2_monthly_factors_kg_per_kwh(fuel_code)
)
def _effective_monthly_pe_factor(
monthly_kwh: tuple[float, ...], fuel_code: int
) -> Optional[float]:
"""SAP 10.2 Table 12e (p.195) monthly PE cascade. Thin wrapper over
`_effective_monthly_factor` for the PE lookup."""
return _effective_monthly_factor(
monthly_kwh, pe_monthly_factors_kwh_per_kwh(fuel_code)
)
def _days_in_month_proportioned(
annual_kwh: float, days_in_month: tuple[int, ...]
) -> tuple[float, ...]:
"""Distribute an annual scalar across months proportional to days. Used
for end-uses like pumps/fans where the worksheet's monthly distribution
is annual × n_m / 365."""
total_days = sum(days_in_month)
return tuple(annual_kwh * d / total_days for d in days_in_month)
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
_STANDARD_ELECTRICITY_FUEL_CODE: Final[int] = 30
# SAP 10.2 Appendix L equation L20 (p.91) — annual cooking electricity
# in kWh: E_cook = 138 + 28 × N (typical-gains Column A). Distinct from
# the L18 cooking HEAT GAIN constants (35 + 7N watts) used for §5
# internal gains.
_COOKING_ELECTRICITY_BASE_KWH_L20: Final[float] = 138.0
_COOKING_ELECTRICITY_PER_OCCUPANT_KWH_L20: Final[float] = 28.0
# SAP 10.2 Table 12 code 60 — "electricity sold to grid, PV". Used as
# the EXPORT factor key for the Appendix M1 §6/§7/§8 PV split:
# (1-β)·E_PV credits at this code's monthly Table 12d/12e factor.
_PV_EXPORT_FUEL_CODE_TABLE_12: Final[int] = 60
def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float:
"""SAP 10.2 Table 12 CO2 emission factor by fuel code."""
return co2_factor_kg_per_kwh(_main_fuel_code(main))
def _main_heating_co2_factor_kg_per_kwh(
main: Optional[MainHeatingDetail],
tariff: Tariff,
main_fuel_monthly_kwh: tuple[float, ...],
) -> float:
"""SAP 10.2 Table 12a Grid 1 (SH) + Table 12d (p.195) dual-rate
monthly CO2 factor for electric mains.
Per Table 12d header (p.195): "Where electricity is the fuel used,
the relevant set of factors in the table below should be used to
calculate the monthly CO2 emissions instead the annual average
factor given in Table 12." Electric mains therefore route through
the monthly cascade Σ(F_m × CO2_m) regardless of tariff:
- **STANDARD tariff** — single Table 12d code 30 (standard
electricity) monthly factors weighted by the cert's
main_fuel_monthly_kwh profile. For an ASHP STANDARD-tariff cert
with a winter-peaked load this lands at ~0.151 vs the annual
flat 0.136 (Δ ≈ +0.015, ≈ +30 kg/yr CO2 per typical ASHP).
- **Dual-rate tariff** (off-peak / 10-hour / 18-hour / etc.) —
Table 12a Grid 1 SH high-rate fraction blends Table 12d high-
rate code + low-rate code monthly factors over the same profile.
For TEN_HOUR + ASHP_OTHER (Grid 1 high_frac=0.6) the worksheet
blends code 34 (10h high) and code 33 (10h low) → cert 000565
worksheet line 261 lands at 0.1533 kg/kWh (was 0.136 pre-S0380.65).
Fallback to annual `_co2_factor_kg_per_kwh` for:
- non-electric mains (gas, oil, LPG — Table 12d only covers
electricity; non-electric uses the annual Table 12 factor per
the Table 12d header's "Where electricity is the fuel used"
scope restriction)
- dual-rate electric mains without a Table 12a Grid 1 row (storage
heaters, direct-acting electric — TODO mirrors the cost-helper
coverage gap)
- dual-rate tariffs without a Table 12d high/low split
(EIGHTEEN_HOUR, TWENTY_FOUR_HOUR fall through to single-code 30
via the STANDARD branch above)
- zero-fuel cases (sum monthly_kwh == 0 → effective factor None;
annual factor is the safe degenerate value)
"""
if not _is_electric_main(main):
# Heat-network mains (SAP codes 301 / 304) are non-electric per
# `_is_electric_main` but require a heat-source-efficiency scaling
# per spec block 12b (363)/(367) = network_input × 100 /
# heat_source_eff × Table 12 CO2 factor. The cascade meters
# network_input directly so scale the factor by 1/eff to land at
# the spec's fuel-input × factor.
return (
_co2_factor_kg_per_kwh(main)
* _heat_network_heat_source_efficiency_scaling(main)
)
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
if monthly is None:
return _co2_factor_kg_per_kwh(main)
return monthly
system = _table_12a_system_for_main(main)
if system is None:
return _co2_factor_kg_per_kwh(main)
try:
high_frac = space_heating_high_rate_fraction(system, tariff)
except NotImplementedError:
return _co2_factor_kg_per_kwh(main)
codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff)
if codes is None:
return _co2_factor_kg_per_kwh(main)
high_code, low_code = codes
high_factor = _effective_monthly_co2_factor(main_fuel_monthly_kwh, high_code)
low_factor = _effective_monthly_co2_factor(main_fuel_monthly_kwh, low_code)
if high_factor is None or low_factor is None:
return _co2_factor_kg_per_kwh(main)
return high_frac * high_factor + (1.0 - high_frac) * low_factor
def _main_heating_primary_factor(
main: Optional[MainHeatingDetail],
tariff: Tariff,
main_fuel_monthly_kwh: tuple[float, ...],
) -> float:
"""SAP 10.2 Table 12a Grid 1 (SH) + Table 12e (p.196) primary
energy factor for electric mains. PE-side mirror of
`_main_heating_co2_factor_kg_per_kwh`.
Per Table 12e header (p.196): "Where electricity is the fuel used,
the relevant set of factors in the table below should be used to
calculate the monthly primary energy instead the annual average
factor given in Table 12." Electric mains route through monthly
Σ(F_m × PE_m) regardless of tariff:
- **STANDARD tariff** — single Table 12e code 30 monthly factors
weighted by the cert's main_fuel_monthly_kwh. For a winter-
peaked ASHP load this lands at ~1.57 vs annual flat 1.501
(Δ ≈ +0.07, ≈ +2.7 kWh/m² PE per typical ASHP — closes the
S0380.70 cohort cluster of 20 STANDARD-tariff ASHPs at PE
residual 2.6 to 4.2 kWh/m²).
- **Dual-rate tariff** — Table 12a Grid 1 SH high-rate fraction
blends Table 12e high-rate / low-rate code monthly factors over
the profile. Mirror of the dual-rate CO2 path landed in
S0380.65 (cert 000565 ASHP+TEN_HOUR).
Fallback to annual `primary_energy_factor` for non-electric mains
and the same edge cases as the CO2 helper (no Table 12a row,
unknown dual-rate codes, zero-fuel)."""
fuel = _main_fuel_code(main)
if not _is_electric_main(main):
# PE-side mirror of `_main_heating_co2_factor_kg_per_kwh`
# heat-network heat-source-eff scaling. Spec block 13a (463)/
# (467) = network_input × 100 / heat_source_eff × Table 12 PE
# factor; cascade meters network_input directly so scale by
# 1/eff at lookup time.
return (
primary_energy_factor(fuel)
* _heat_network_heat_source_efficiency_scaling(main)
)
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
if monthly is None:
return primary_energy_factor(fuel)
return monthly
system = _table_12a_system_for_main(main)
if system is None:
return primary_energy_factor(fuel)
try:
high_frac = space_heating_high_rate_fraction(system, tariff)
except NotImplementedError:
return primary_energy_factor(fuel)
codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff)
if codes is None:
return primary_energy_factor(fuel)
high_code, low_code = codes
high_factor = _effective_monthly_pe_factor(main_fuel_monthly_kwh, high_code)
low_factor = _effective_monthly_pe_factor(main_fuel_monthly_kwh, low_code)
if high_factor is None or low_factor is None:
return primary_energy_factor(fuel)
return high_frac * high_factor + (1.0 - high_frac) * low_factor
def _pumps_fans_co2_factor_kg_per_kwh(
*,
tariff: Tariff,
mev_kwh_per_yr: float,
total_pumps_fans_kwh_per_yr: float,
monthly_kwh: tuple[float, ...],
) -> Optional[float]:
"""SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d (PDF p.194) —
CO2-side mirror of `_pumps_fans_fuel_cost_gbp_per_kwh` (Slice
S0380.103).
MEV/MVHR-fan electricity bills at the `FANS_FOR_MECH_VENT` high-rate
fraction (10-hour: 0.58; 7-hour: 0.71) on dual-rate tariffs, while
the remaining pumps_fans portion (central-heating circulation
pumps, flue fans, solar HW pumps, electric keep-hot) bills at
`ALL_OTHER_USES` (10-hour: 0.80; 7-hour: 0.90). The two Grid 2
categories blend Table 12d high/low-rate codes at different ratios
→ two distinct effective CO2 factors. Returns the kWh-weighted
blend across the two streams.
Returns the existing `_other_use_co2_factor_kg_per_kwh(
ALL_OTHER_USES, ...)` rate on STANDARD tariff (no Grid 2 split
applies — Table 12d code 30 monthly cascade only), and when no MEV
is lodged (no split needed).
Worksheet pin for cert 000565 (TEN_HOUR + MEV 127.5159 kWh + 125
kWh other pumps/fans):
F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 0.13872 kg/kWh
F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/kWh
F_eff = (127.5159 × 0.13872 + 125.0 × 0.14116) / 252.5159
= 0.13993 kg/kWh
Worksheet line (267): 252.5159 × 0.13993 = 35.3349 kg/yr; pre-slice
the cascade applied 0.14116 to all pumps_fans → 35.6457 → +0.31
over ws.
"""
other_factor = _other_use_co2_factor_kg_per_kwh(
OtherUse.ALL_OTHER_USES, tariff, monthly_kwh,
)
if (
tariff is Tariff.STANDARD
or mev_kwh_per_yr <= 0.0
or total_pumps_fans_kwh_per_yr <= 0.0
):
return other_factor
fans_factor = _other_use_co2_factor_kg_per_kwh(
OtherUse.FANS_FOR_MECH_VENT, tariff, monthly_kwh,
)
if fans_factor is None or other_factor is None:
return other_factor
non_mev_kwh = max(0.0, total_pumps_fans_kwh_per_yr - mev_kwh_per_yr)
return (
mev_kwh_per_yr * fans_factor + non_mev_kwh * other_factor
) / total_pumps_fans_kwh_per_yr
def _other_use_co2_factor_kg_per_kwh(
other_use: OtherUse,
tariff: Tariff,
monthly_kwh: tuple[float, ...],
) -> Optional[float]:
"""SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d (PDF p.194)
dual-rate monthly CO2 factor for "other electricity uses" (lighting,
pumps + fans, electric shower, etc.).
Per Table 12d header (p.194): "Where electricity is the fuel used,
the relevant set of factors in the table below should be used to
calculate the monthly CO2 emissions INSTEAD of the annual average
factor given in Table 12." For STANDARD tariff this means single
Table 12d code 30 monthly factors weighted by the end-use's profile.
For Grid-2-eligible off-peak tariffs (SEVEN_HOUR / TEN_HOUR) the
Grid 2 ALL_OTHER_USES / FANS_FOR_MECH_VENT high-rate fraction
blends Table 12d high-rate × low-rate codes per:
F_blended = high_frac × F_high + (1 high_frac) × F_low
Grid 2 doesn't list EIGHTEEN_HOUR / TWENTY_FOUR_HOUR rows; those
tariffs fall through to single-code-30 monthly.
Mirrors `_main_heating_co2_factor_kg_per_kwh` for the Grid 2
end-uses. Returns None when the cascade can't form a factor (zero
monthly kWh in every month); callers fall back to the annual
`_STANDARD_ELECTRICITY_FUEL_CODE` Table 12 value."""
if tariff is Tariff.STANDARD:
return _effective_monthly_co2_factor(
monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
try:
high_frac = other_use_high_rate_fraction(other_use, tariff)
except NotImplementedError:
return _effective_monthly_co2_factor(
monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff)
if codes is None:
return _effective_monthly_co2_factor(
monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
high_code, low_code = codes
high_factor = _effective_monthly_co2_factor(monthly_kwh, high_code)
low_factor = _effective_monthly_co2_factor(monthly_kwh, low_code)
if high_factor is None or low_factor is None:
return None
return high_frac * high_factor + (1.0 - high_frac) * low_factor
def _pumps_fans_primary_factor(
*,
tariff: Tariff,
mev_kwh_per_yr: float,
total_pumps_fans_kwh_per_yr: float,
monthly_kwh: tuple[float, ...],
) -> Optional[float]:
"""SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12e (PDF p.195) —
PE-side mirror of `_pumps_fans_co2_factor_kg_per_kwh` (Slice
S0380.105) and `_pumps_fans_fuel_cost_gbp_per_kwh` (Slice
S0380.103).
MEV/MVHR-fan electricity bills at the `FANS_FOR_MECH_VENT` high-
rate fraction (10-hour: 0.58; 7-hour: 0.71) on dual-rate tariffs,
while the remaining pumps_fans portion uses `ALL_OTHER_USES`
(10-hour: 0.80; 7-hour: 0.90). Returns the kWh-weighted blend of
the two PE factors.
Returns the existing `_other_use_primary_factor(ALL_OTHER_USES,
...)` rate on STANDARD tariff (no Grid 2 split — Table 12e code 30
monthly cascade only), and when no MEV is lodged.
Worksheet pin for cert 000565 (TEN_HOUR + MEV 127.5159 kWh + 125
kWh other pumps/fans):
F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 1.51268 kWh/kWh
F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 1.52391 kWh/kWh
F_eff = (127.5159 × 1.51268 + 125.0 × 1.52391) / 252.5159
= 1.51824 kWh/kWh
Worksheet line (281): 252.5159 × 1.51824 = 383.3796 kWh/yr; pre-
slice the cascade applied 1.52391 to all pumps_fans → 384.81 →
+1.43 over ws.
"""
other_factor = _other_use_primary_factor(
OtherUse.ALL_OTHER_USES, tariff, monthly_kwh,
)
if (
tariff is Tariff.STANDARD
or mev_kwh_per_yr <= 0.0
or total_pumps_fans_kwh_per_yr <= 0.0
):
return other_factor
fans_factor = _other_use_primary_factor(
OtherUse.FANS_FOR_MECH_VENT, tariff, monthly_kwh,
)
if fans_factor is None or other_factor is None:
return other_factor
non_mev_kwh = max(0.0, total_pumps_fans_kwh_per_yr - mev_kwh_per_yr)
return (
mev_kwh_per_yr * fans_factor + non_mev_kwh * other_factor
) / total_pumps_fans_kwh_per_yr
def _other_use_primary_factor(
other_use: OtherUse,
tariff: Tariff,
monthly_kwh: tuple[float, ...],
) -> Optional[float]:
"""SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12e (PDF p.195)
dual-rate monthly PE factor for "other electricity uses" — PE-side
mirror of `_other_use_co2_factor_kg_per_kwh`. Same dispatch shape:
STANDARD tariff → code 30 monthly cascade; SEVEN_HOUR / TEN_HOUR →
Grid 2 ALL_OTHER_USES / FANS_FOR_MECH_VENT blend; EIGHTEEN_HOUR /
TWENTY_FOUR_HOUR fall through to single-code-30. Returns None for
the zero-monthly-kWh degenerate case."""
if tariff is Tariff.STANDARD:
return _effective_monthly_pe_factor(
monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
try:
high_frac = other_use_high_rate_fraction(other_use, tariff)
except NotImplementedError:
return _effective_monthly_pe_factor(
monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff)
if codes is None:
return _effective_monthly_pe_factor(
monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
high_code, low_code = codes
high_factor = _effective_monthly_pe_factor(monthly_kwh, high_code)
low_factor = _effective_monthly_pe_factor(monthly_kwh, low_code)
if high_factor is None or low_factor is None:
return None
return high_frac * high_factor + (1.0 - high_frac) * low_factor
def _hot_water_co2_factor_kg_per_kwh(
epc: EpcPropertyData,
hw_monthly_kwh: tuple[float, ...],
tariff: Tariff,
) -> float:
"""SAP 10.2 Table 12 / 12d (p.195) per-end-use CO2 factor for the
cert's lodged water-heating fuel.
Per Table 12d header (p.195): "Where electricity is the fuel
used, the relevant set of factors in the table below should be
used to calculate the monthly CO2 emissions instead the annual
average factor given in Table 12." Read literally this would apply
monthly Table 12d to every electric end-use including dual-rate HW.
**Elmhurst-mirror divergence (S0380.163).** The BRE-approved
Elmhurst rdSAP engine applies Table 12 ANNUAL factors (0.136 CO2 /
1.501 PE) for the (278) "Water heating (low-rate cost)" worksheet
line on dual-rate tariffs (7-hour / 10-hour / 18-hour / 24-hour),
NOT the Table 12d/12e monthly cascade. STANDARD tariff (where HW
bills via Table 12d row "standard tariff" code 30 monthly) still
uses the monthly cascade. We mirror the engine per
[[feedback-software-no-special-handling]] — see
`domain/sap10_calculator/docs/SAP_CALCULATOR.md §8` for the full
documentation of this divergence. Non-electric HW fuels (mains
gas, oil, etc.) always pass through the annual Table 12 factor.
`hw_monthly_kwh` is the monthly HW demand profile (proxy for
monthly HW fuel kWh — the calculator uses an annual-flat HW
efficiency so the SHAPE of fuel monthly is identical to demand
monthly, and `_effective_monthly_co2_factor` is shape-only)."""
# Community heating + WHC ∈ {901, 902, 914}: HW heat is delivered
# through the heat-network main, so HW CO2 must read the same
# Table 12 heat-network code factor as SH, scaled by 1/heat_source_
# eff per spec block 12b (363)/(367). Cert-lodged HW fuel "Mains
# gas" is an Elmhurst placeholder that mis-routes the lookup.
if _is_community_heating_hw_from_main(epc):
main = _water_heating_main(epc)
return (
_co2_factor_kg_per_kwh(main)
* _heat_network_heat_source_efficiency_scaling(main)
)
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_CO2_KG_PER_KWH
table_12_code = (
fuel if fuel in CO2_KG_PER_KWH
else API_FUEL_TO_TABLE_12.get(fuel, fuel)
)
if tariff is not Tariff.STANDARD:
return co2_factor_kg_per_kwh(table_12_code)
monthly = _effective_monthly_co2_factor(hw_monthly_kwh, table_12_code)
if monthly is not None:
return monthly
return co2_factor_kg_per_kwh(fuel)
def _hot_water_primary_factor(
epc: EpcPropertyData,
hw_monthly_kwh: tuple[float, ...],
tariff: Tariff,
) -> float:
"""SAP 10.2 Table 12 / 12e (p.196) per-end-use PE factor for the
cert's lodged water-heating fuel. PE-side mirror of
`_hot_water_co2_factor_kg_per_kwh` — same Elmhurst-mirror
divergence: dual-rate tariffs use Table 12 annual (1.501),
STANDARD tariff uses Table 12e monthly cascade.
Cohort closure context: cert 9796 (ASHP, STANDARD tariff via
water_heating_fuel=29 → Table 12 code 30) lands at 1.5177 monthly-
weighted PE vs 1.501 annual flat (≈ +0.30 kWh/m² for the cert).
Same routing across the 20-cert STANDARD-tariff ASHP cohort
averages ~+0.3 kWh/m² closure on top of the S0380.71 main heating
fix.
On dual-rate tariffs (S0380.163) the cascade now returns 1.501
exactly to match the Elmhurst worksheet's (278) annual factor.
The 41-variant heating-systems corpus closes its HW PE residual
+25/+48 → 0 with this gate."""
# Mirror of `_hot_water_co2_factor_kg_per_kwh` community-heating
# branch (S0380.173): WHC ∈ {901, 902, 914} on a heat-network main
# routes HW PE through the same Table 12 heat-network code as SH,
# scaled by 1/heat_source_eff per spec block 13a (463)/(467).
if _is_community_heating_hw_from_main(epc):
main = _water_heating_main(epc)
return (
primary_energy_factor(_main_fuel_code(main))
* _heat_network_heat_source_efficiency_scaling(main)
)
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_PEF
table_12_code = (
fuel if fuel in PRIMARY_ENERGY_FACTOR
else API_FUEL_TO_TABLE_12.get(fuel, fuel)
)
if tariff is not Tariff.STANDARD:
return primary_energy_factor(table_12_code)
monthly = _effective_monthly_pe_factor(hw_monthly_kwh, table_12_code)
if monthly is not None:
return monthly
return primary_energy_factor(fuel)
def _secondary_fuel_code(epc: EpcPropertyData) -> int:
"""SAP 10.2 secondary fuel code, resolved through the API mapper's
Appendix M Table 4a spec-fuel routing. When no `secondary_fuel_type`
is lodged (a secondary still required per Table 11 / §A.2.2), the
cascade falls back to standard electricity (Table 12 code 30) —
the assumed portable-electric default that
`_secondary_fuel_cost_gbp_per_kwh` already mirrors on the cost side.
`sap_heating.secondary_fuel_type` is heterogeneous: it carries
either a gov API enum code (when the mapper passes through the
lodged value unchanged) or a Table 32/12 code (when the mapper's
`_api_secondary_fuel_type` override resolves Appendix M Table 4a
spec-fuel — e.g. cert 2102 lodges API code 33 and the mapper
rewrites to Table 32 code 11 = House coal). Mirror the dual
accept-either-API-or-Table-12 logic from `co2_factor_kg_per_kwh`:
keep Table 12 codes as-is (so House coal 11 stays 11) and
translate raw API codes via `API_FUEL_TO_TABLE_12` so the Table
12d/12e monthly lookups resolve consistently (e.g. lodged API 29
→ Table 12 30 → monthly electricity factors apply)."""
code = _int_or_none(epc.sap_heating.secondary_fuel_type)
if code is None:
return _STANDARD_ELECTRICITY_FUEL_CODE
if code in CO2_KG_PER_KWH:
return code
return API_FUEL_TO_TABLE_12.get(code, code)
def _secondary_heating_co2_factor_kg_per_kwh(
epc: EpcPropertyData,
secondary_fuel_monthly_kwh: tuple[float, ...],
) -> Optional[float]:
"""SAP 10.2 Table 12 / Table 12d (p.195) per-end-use CO2 factor for
the cert's lodged secondary fuel.
Per Table 12d header: "Where electricity is the fuel used, the
relevant set of factors in the table below should be used to
calculate the monthly CO2 emissions instead the annual average
factor given in Table 12." → electricity end-uses Σ(kWh_m × CO2_m);
non-electric fuels (House coal, wood logs, mineral oil, etc.) pass
through the annual Table 12 factor.
Cohort-2 cert 2102 lodges `secondary_fuel_type=11` (House coal,
after Appendix M Table 4a spec-fuel resolution from the lodged
physically-incompatible electricity code) → 0.395 annual factor,
not the 0.136 electricity flat that the pre-S0380.70 hardcoded
`_STANDARD_ELECTRICITY_FUEL_CODE` path produced."""
code = _secondary_fuel_code(epc)
monthly = _effective_monthly_co2_factor(secondary_fuel_monthly_kwh, code)
if monthly is not None:
return monthly
return co2_factor_kg_per_kwh(code)
def _secondary_heating_primary_factor(
epc: EpcPropertyData,
secondary_fuel_monthly_kwh: tuple[float, ...],
) -> float:
"""SAP 10.2 Table 12 / Table 12e (p.196) per-end-use PE factor for
the cert's lodged secondary fuel. Mirror of
`_secondary_heating_co2_factor_kg_per_kwh` on the PE side per the
Table 12e header's identical "Where electricity is the fuel used …
instead the annual average factor given in Table 12" rubric. House
coal (Table 12 code 11) → 1.064 annual factor, not the 1.501
electricity flat that the pre-S0380.70 hardcoded path produced."""
code = _secondary_fuel_code(epc)
monthly = _effective_monthly_pe_factor(secondary_fuel_monthly_kwh, code)
if monthly is not None:
return monthly
return primary_energy_factor(code)
def _int_or_none(value: object) -> Optional[int]:
return value if isinstance(value, int) else None
@dataclass(frozen=True)
class _VentilationCounts:
open_flues: int = 0
closed_fire_chimneys: int = 0
solid_fuel_boiler_chimneys: int = 0
other_heater_chimneys: int = 0
intermittent_fans: int = 0
passive_vents: int = 0
flueless_gas_fires: int = 0
def _ventilation_counts(vent: Optional[SapVentilation]) -> _VentilationCounts:
if vent is None:
return _VentilationCounts()
return _VentilationCounts(
open_flues=vent.open_flues_count or 0,
closed_fire_chimneys=vent.closed_flues_count or 0,
solid_fuel_boiler_chimneys=vent.boiler_flues_count or 0,
other_heater_chimneys=vent.other_flues_count or 0,
intermittent_fans=vent.extract_fans_count or 0,
passive_vents=vent.passive_vents_count or 0,
flueless_gas_fires=vent.flueless_gas_fires_count or 0,
)
def _rdsap_extract_fans_default(
age_band: str, habitable_rooms: int, *, is_park_home: bool,
) -> int:
"""RdSAP 10 §4.1 Table 5 (PDF p.28) — extract-fans default when the
lodged number is unknown. Spec verbatim:
Not park home:
Age bands A to E: all cases → 0
Age bands F to G: all cases → 1
Age bands H to M: up to 2 hab. rooms → 1
3 to 5 hab. rooms → 2
6 to 8 hab. rooms → 3
more than 8 hab. rooms → 4
Park home:
Age band F: all cases → 0
Age bands G onwards: all cases → 2
The Elmhurst Summary §12.0 renders "No. of intermittent extract
fans: 0" as the form for *unknown*; every other §2 chimney/flue
item follows "number if known, or 0 if not present" and zero is
literal absence. Only extract fans have a non-zero age-band default
— this helper plus a `max(lodged, default)` wiring at the call
site applies the spec when the lodging is below the minimum.
"""
band = age_band.strip().upper() if age_band else ""
if is_park_home:
return 0 if band in {"A", "B", "C", "D", "E", "F"} else 2
if band in {"A", "B", "C", "D", "E"}:
return 0
if band in {"F", "G"}:
return 1
# Age bands H to M scale by habitable rooms
if habitable_rooms <= 2:
return 1
if habitable_rooms <= 5:
return 2
if habitable_rooms <= 8:
return 3
return 4
def water_heating_section_from_cert(
epc: EpcPropertyData,
) -> Optional[WaterHeatingResult]:
"""SAP 10.2 §4 cert→inputs cascade. Returns the final
`WaterHeatingResult` (every (42)..(65) line ref breakdown) after
PCDB Table 3b/3c combi-loss override, exactly as cert_to_inputs
computes internally.
Returns `None` when TFA is missing — the legacy fallback path
bypasses §4 entirely; tests using this helper should skip those
fixtures.
"""
if epc.total_floor_area_m2 is None:
return None
main = _first_main_heating(epc)
pcdb_main = (
gas_oil_boiler_record(main.main_heating_index_number)
if main is not None and main.main_heating_index_number is not None
else None
)
has_electric_shower = _has_electric_shower_from_cert(epc)
electric_shower_count = _electric_shower_count_from_cert(epc)
bootstrap = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
combi_loss_override = pcdb_combi_loss_override(
pcdb_main,
energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
)
return water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmission:
"""SAP 10.2 §3 cert→inputs cascade for `heat_transmission_from_cert`.
Wraps the `_window_total_area_and_avg_u` + `_dwelling_exposure`
derivations cert_to_inputs makes internally and returns the full
`HeatTransmission` (every (26)..(37) line ref breakdown). Exposed
so cascade pin tests can assert each §3 line ref against the U985
PDF.
"""
window_total_area, window_avg_u = _window_total_area_and_avg_u(epc.sap_windows)
exposure = _dwelling_exposure(epc.dwelling_type)
return heat_transmission_from_cert(
epc,
window_total_area_m2=window_total_area,
window_avg_u_value=window_avg_u,
door_count=epc.door_count,
insulated_door_count=epc.insulated_door_count,
insulated_door_u_value=epc.insulated_door_u_value,
exposure=exposure,
)
def _rooflight_total_area_m2_from_cert(epc: EpcPropertyData) -> float:
"""Σ area of `epc.sap_roof_windows` for §5 daylight-factor L2a +
§6 horizontal solar gain. Returns 0.0 when none are lodged.
Roof windows behave as rooflights for §5 L2a (Z_L = 1.0 per Table 6d
note 2) — same treatment as horizontal rooflights for the daylight
bonus. Areas are 2-d.p.-rounded inputs (RdSAP10 §15) when lodged on
the SapRoofWindow datatype."""
return sum(float(rw.area_m2) for rw in epc.sap_roof_windows or [])
def internal_gains_section_from_cert(
epc: EpcPropertyData,
) -> Optional[InternalGainsResult]:
"""SAP 10.2 §5 cert→inputs cascade for `internal_gains_from_cert`.
Composes §1 (dim.volume_m3) + §4 (heat_gains_from_water_heating
monthly_kwh, line (65)m) and threads them through the §5 orchestrator
— exactly as `cert_to_inputs` computes internally. Returns the full
`InternalGainsResult` (every (66)..(73) line ref + annual lighting kWh
line (232)) so cascade pin tests can assert each §5 line ref against
the U985 PDF.
Returns `None` when TFA is missing (matches the §4 helper contract;
tests using this helper should skip those fixtures).
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
wh = water_heating_section_from_cert(epc)
hw_heat_gains_monthly_kwh = (
wh.heat_gains_monthly_kwh if wh is not None else (0.0,) * 12
)
return internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=dim.volume_m3,
heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
rooflight_total_area_m2=_rooflight_total_area_m2_from_cert(epc),
)
def _roof_windows_for_solar_gains(
epc: EpcPropertyData,
) -> tuple[RoofWindowInput, ...]:
"""Convert `epc.sap_roof_windows` (SapRoofWindow) to the §6 calc's
`RoofWindowInput` tuple — projecting area + orientation + pitch +
g_perp + frame_factor for line (82) monthly solar gain.
Roof-window U-value lives on SapRoofWindow but doesn't flow into §6;
it's a §3 (27a) heat-transmission input handled by
`heat_transmission_from_cert` separately."""
return tuple(
RoofWindowInput(
area_m2=float(rw.area_m2),
orientation=ORIENTATION_BY_SAP10_CODE.get(
rw.orientation, list(ORIENTATION_BY_SAP10_CODE.values())[0]
),
g_perpendicular=float(rw.g_perpendicular),
frame_factor=float(rw.frame_factor),
pitch_deg=float(rw.pitch_deg),
)
for rw in epc.sap_roof_windows or []
)
def mean_internal_temperature_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[MeanInternalTemperatureResult]:
"""SAP 10.2 §7 cert→inputs cascade for `mean_internal_temperature_monthly`.
Composes §1 (dim) + §2 (effective_monthly_ach) + §3 (total HLC) + §5
(internal gains) + §6 (solar gains) + climate (external temp) and
threads them through the §7 orchestrator — exactly as cert_to_inputs
computes internally. Returns the full
`MeanInternalTemperatureResult` (every (85)..(94) line ref) so
cascade pin tests can assert each §7 line ref against the U985 PDF.
Returns `None` when TFA is missing (matches other section helpers).
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
ht = heat_transmission_section_from_cert(epc)
ig = internal_gains_section_from_cert(epc)
sg = solar_gains_section_from_cert(epc, postcode_climate=postcode_climate)
assert ig is not None, "internal_gains None despite TFA present"
internal_gains_monthly_w = ig.total_internal_gains_monthly_w
solar_gains_monthly_w = sg.total_solar_gains_monthly_w
monthly_total_gains_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
monthly_htc_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12)
)
main = _first_main_heating(epc)
climate = _climate_source(postcode_climate)
tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type)
return mean_internal_temperature_monthly(
monthly_external_temp_c=tuple(
external_temperature_c(climate, m) for m in range(1, 13)
),
monthly_total_gains_w=monthly_total_gains_w,
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=_control_type(main),
responsiveness=_responsiveness(main, tariff=tariff),
living_area_fraction=_living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
),
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
)
def space_heating_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[SpaceHeatingResult]:
"""SAP 10.2 §8 cert→inputs cascade for `space_heating_monthly_kwh`.
Composes §1 (dim) + §2 (ventilation) + §3 (HLC) + §5+§6 (gains) +
§7 (MIT + η_whole) + climate (external temp) and threads them through
the §8 orchestrator. Returns the full `SpaceHeatingResult` (every
(95)..(99) line ref) so cascade pin tests can assert each §8 line
ref against the U985 PDF.
`postcode_climate` selects the demand cascade (postcode wind/temp/solar
via PCDB Table 172); None uses UK-average rating climate.
Returns `None` when TFA is missing (matches other section helpers).
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
ht = heat_transmission_section_from_cert(epc)
ig = internal_gains_section_from_cert(epc)
sg = solar_gains_section_from_cert(epc, postcode_climate=postcode_climate)
mit = mean_internal_temperature_section_from_cert(
epc, postcode_climate=postcode_climate
)
assert ig is not None, "internal_gains None despite TFA present"
assert mit is not None, "mit None despite TFA present"
monthly_total_gains_w = tuple(
ig.total_internal_gains_monthly_w[m] + sg.total_solar_gains_monthly_w[m]
for m in range(12)
)
monthly_htc_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12)
)
climate = _climate_source(postcode_climate)
monthly_external_temp_c = tuple(
external_temperature_c(climate, m) for m in range(1, 13)
)
return space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
monthly_internal_temperature_c=mit.adjusted_mean_internal_temp_monthly,
monthly_external_temperature_c=monthly_external_temp_c,
monthly_utilisation_factor=mit.utilisation_factor_whole_monthly,
monthly_total_gains_w=monthly_total_gains_w,
total_floor_area_m2=dim.total_floor_area_m2,
)
def space_cooling_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[SpaceCoolingResult]:
"""SAP 10.2 §8c cert→inputs cascade for `space_cooling_monthly_kwh`.
Composes §1 (dim) + §2 (ventilation) + §3 (HLC) + climate; cooling
gains and cooled-area fraction default to 0 (RdSAP convention — the
cert never lodges cooled-area data, and for `has_fixed_air_conditioning
=False` certs the f_C=0 zeros (107) regardless of gains). Returns the
full `SpaceCoolingResult` (every (100)..(108) line ref) so cascade pin
tests can assert each §8c line ref against the U985 PDF.
`postcode_climate` selects the demand cascade; None uses UK-average.
Returns `None` when TFA is missing (matches other section helpers).
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
ht = heat_transmission_section_from_cert(epc)
monthly_htc_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12)
)
climate = _climate_source(postcode_climate)
monthly_external_temp_c = tuple(
external_temperature_c(climate, m) for m in range(1, 13)
)
return space_cooling_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
monthly_external_temperature_c=monthly_external_temp_c,
monthly_total_gains_w=(0.0,) * 12,
total_floor_area_m2=dim.total_floor_area_m2,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
cooled_area_fraction=0.0,
intermittency_factor=0.25,
)
def fabric_energy_efficiency_from_cert(epc: EpcPropertyData) -> Optional[float]:
"""SAP 10.2 §8f cert→inputs cascade for `fabric_energy_efficiency_kwh_
per_m2_yr` — line (109) = (98a)/(4) + (108). Composes §8 (space heating)
+ §8c (space cooling) + §1 (TFA). Returns None when TFA missing.
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
sh = space_heating_section_from_cert(epc)
sc = space_cooling_section_from_cert(epc)
assert sh is not None, "space_heating None despite TFA present"
assert sc is not None, "space_cooling None despite TFA present"
return fabric_energy_efficiency_kwh_per_m2_yr(
space_heating_kwh_per_yr=sh.space_heating_requirement_kwh_per_yr,
total_floor_area_m2=dim.total_floor_area_m2,
space_cooling_per_m2_kwh=sc.space_cooling_per_m2_kwh,
)
@dataclass(frozen=True)
class SapRatingSection:
"""SAP 10.2 §11a worksheet line refs (256)..(258) — Energy Cost Factor
and SAP rating. Returned by `sap_rating_section_from_cert`."""
energy_cost_deflator: float # (256) — Table 12 constant 0.42
energy_cost_factor: float # (257) — (255) × (256) / ((4) + 45)
sap_continuous: float # SAP value (un-rounded)
sap_integer: int # (258) — round half-up to nearest int
@dataclass(frozen=True)
class EnvironmentalSection:
"""SAP 10.2 §12 worksheet line refs (261)..(274) — CO2 emissions.
Per-end-use CO2 breakdown plus the total + per-m² + EI rating.
Returned by `environmental_section_from_cert`."""
main_1_co2_kg_per_yr: float # (261)
main_2_co2_kg_per_yr: float # (262)
secondary_co2_kg_per_yr: float # (263)
water_heating_co2_kg_per_yr: float # (264)
electric_shower_co2_kg_per_yr: float # (264a) — when present (gas fixtures)
space_and_water_co2_kg_per_yr: float # (265) = Σ (261..264a)
space_cooling_co2_kg_per_yr: float # (266)
pumps_fans_co2_kg_per_yr: float # (267)
lighting_co2_kg_per_yr: float # (268)
pv_co2_credit_kg_per_yr: float # (269) — negative when present
total_co2_kg_per_yr: float # (272)
co2_per_m2_kg_per_yr: float # (273)
ei_value_continuous: float # un-rounded EI value
ei_rating_integer: int # (274)
def environmental_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[EnvironmentalSection]:
"""SAP 10.2 §12 cert→inputs cascade. Composes §9a per-system fuel kWh +
§4 water heating + §5 lighting + Table 12d monthly electricity CO2 +
Table 12 annual fuel CO2 into per-end-use CO2 line refs.
`postcode_climate` selects the demand cascade (postcode climate via
PCDB Table 172 — used for EPC Current Carbon); None uses UK-average.
Returns None when TFA missing."""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
er = energy_requirements_section_from_cert(
epc, postcode_climate=postcode_climate,
)
assert er is not None, "energy_requirements None despite TFA present"
main = _first_main_heating(epc)
main_fuel = _main_fuel_code(main)
main_factor = co2_factor_kg_per_kwh(main_fuel)
# Compute per-end-use CO2. For electricity end-uses, monthly Table 12d
# cascade Σ(kWh_m × CO2_m); for gas end-uses, annual_kwh × annual factor.
main_1_co2 = er.main_1_fuel_kwh_per_yr * main_factor
main_2_co2 = er.main_2_fuel_kwh_per_yr * main_factor # scope A → 0
secondary_eff = _secondary_heating_co2_factor_kg_per_kwh(
epc, er.secondary_fuel_monthly_kwh,
)
secondary_co2 = er.secondary_fuel_kwh_per_yr * (
secondary_eff if secondary_eff is not None else 0.0
)
# Hot water kWh: derived from wh_result via cert_to_inputs.
full_inputs = cert_to_inputs(epc, postcode_climate=postcode_climate)
water_co2 = full_inputs.hot_water_kwh_per_yr * (
full_inputs.hot_water_co2_factor_kg_per_kwh
if full_inputs.hot_water_co2_factor_kg_per_kwh is not None
else 0.0
)
# Electric shower (264a) — distinct line ref when present.
electric_shower_co2 = (
full_inputs.electric_shower_kwh_per_yr
* (full_inputs.electric_shower_co2_factor_kg_per_kwh or 0.0)
)
pumps_fans_co2 = full_inputs.pumps_fans_kwh_per_yr * (
full_inputs.pumps_fans_co2_factor_kg_per_kwh or 0.0
)
lighting_co2 = full_inputs.lighting_kwh_per_yr * (
full_inputs.lighting_co2_factor_kg_per_kwh or 0.0
)
space_cooling_co2 = 0.0 # no AC in any Elmhurst fixture
pv_credit = 0.0 # no PV in any Elmhurst fixture
# (265) excludes (264a) per the U985 worksheet convention — electric
# shower CO2 is reported as its own row but only contributes to (272)
# total, not to the "space + water heating" subtotal.
space_and_water = (
main_1_co2 + main_2_co2 + secondary_co2 + water_co2
)
total = (
space_and_water + electric_shower_co2 + space_cooling_co2
+ pumps_fans_co2 + lighting_co2 - pv_credit
)
# (273) is rounded to 2 d.p. half-up — the PDF displays it with
# trailing zeros to 4 d.p. but precision is 2 d.p. throughout.
per_m2_raw = total / dim.total_floor_area_m2 if dim.total_floor_area_m2 > 0 else 0.0
per_m2 = _round_half_up(per_m2_raw, 2)
ei_continuous = environmental_impact_rating(
co2_emissions_kg_per_yr=total,
total_floor_area_m2=dim.total_floor_area_m2,
)
return EnvironmentalSection(
main_1_co2_kg_per_yr=main_1_co2,
main_2_co2_kg_per_yr=main_2_co2,
secondary_co2_kg_per_yr=secondary_co2,
water_heating_co2_kg_per_yr=water_co2,
electric_shower_co2_kg_per_yr=electric_shower_co2,
space_and_water_co2_kg_per_yr=space_and_water,
space_cooling_co2_kg_per_yr=space_cooling_co2,
pumps_fans_co2_kg_per_yr=pumps_fans_co2,
lighting_co2_kg_per_yr=lighting_co2,
pv_co2_credit_kg_per_yr=pv_credit,
total_co2_kg_per_yr=total,
co2_per_m2_kg_per_yr=per_m2,
ei_value_continuous=ei_continuous,
ei_rating_integer=max(1, round(ei_continuous)),
)
@dataclass(frozen=True)
class PrimaryEnergySection:
"""SAP 10.2 §13a worksheet line refs (275)..(286) — Primary Energy.
Per-end-use PE breakdown plus the total. Pin against the U985 Block 2
(postcode climate) §13a values to verify the EPC Current Primary
Energy output."""
main_1_pe_kwh_per_yr: float # (275)
main_2_pe_kwh_per_yr: float # (276)
secondary_pe_kwh_per_yr: float # (277)
water_heating_pe_kwh_per_yr: float # (278)
electric_shower_pe_kwh_per_yr: float # (278a) — when present
space_and_water_pe_kwh_per_yr: float # (279)
pumps_fans_pe_kwh_per_yr: float # (281)
lighting_pe_kwh_per_yr: float # (282)
total_pe_kwh_per_yr: float # (286)
def primary_energy_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[PrimaryEnergySection]:
"""SAP 10.2 §13a cert→inputs cascade. Composes §9a per-system fuel kWh
× Table 12 (gas) / Table 12e (electricity, monthly) PE factors.
`postcode_climate` selects the demand cascade (EPC Current PE).
Returns None when TFA missing."""
if epc.total_floor_area_m2 is None:
return None
er = energy_requirements_section_from_cert(
epc, postcode_climate=postcode_climate,
)
assert er is not None, "energy_requirements None despite TFA present"
full_inputs = cert_to_inputs(epc, postcode_climate=postcode_climate)
main = _first_main_heating(epc)
main_fuel = _main_fuel_code(main)
main_pe = primary_energy_factor(main_fuel)
main_1 = er.main_1_fuel_kwh_per_yr * main_pe
main_2 = er.main_2_fuel_kwh_per_yr * main_pe
secondary_pe_factor = _secondary_heating_primary_factor(
epc, er.secondary_fuel_monthly_kwh,
)
secondary = er.secondary_fuel_kwh_per_yr * secondary_pe_factor
water = full_inputs.hot_water_kwh_per_yr * full_inputs.hot_water_primary_factor
electric_shower = (
full_inputs.electric_shower_kwh_per_yr
* (full_inputs.electric_shower_primary_factor or 0.0)
)
pumps_fans = full_inputs.pumps_fans_kwh_per_yr * (
full_inputs.pumps_fans_primary_factor or 0.0
)
lighting = full_inputs.lighting_kwh_per_yr * (
full_inputs.lighting_primary_factor or 0.0
)
# (279) excludes (278a) per the U985 worksheet convention — electric
# shower PE is reported as its own row but only contributes to (286)
# total, not to the "space + water heating" subtotal (mirrors the
# §12 (265) exclusion of (264a)).
space_and_water = main_1 + main_2 + secondary + water
total = space_and_water + electric_shower + pumps_fans + lighting
return PrimaryEnergySection(
main_1_pe_kwh_per_yr=main_1,
main_2_pe_kwh_per_yr=main_2,
secondary_pe_kwh_per_yr=secondary,
water_heating_pe_kwh_per_yr=water,
electric_shower_pe_kwh_per_yr=electric_shower,
space_and_water_pe_kwh_per_yr=space_and_water,
pumps_fans_pe_kwh_per_yr=pumps_fans,
lighting_pe_kwh_per_yr=lighting,
total_pe_kwh_per_yr=total,
)
def sap_rating_section_from_cert(
epc: EpcPropertyData,
) -> Optional[SapRatingSection]:
"""SAP 10.2 §11a cert→inputs cascade. Composes §10a (255) + §1 TFA via
`_fuel_cost` + `dimensions_from_cert`, then runs the SAP rating equations
(`energy_cost_factor`, `sap_rating`, `sap_rating_integer`). Returns the
full `SapRatingSection`; None when TFA missing."""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
fc = fuel_cost_section_from_cert(epc)
assert fc is not None, "fuel_cost None despite TFA present"
ecf = energy_cost_factor(
total_cost_gbp=fc.total_cost_gbp, total_floor_area_m2=dim.total_floor_area_m2
)
return SapRatingSection(
energy_cost_deflator=ENERGY_COST_DEFLATOR,
energy_cost_factor=ecf,
sap_continuous=sap_rating(ecf=ecf),
sap_integer=sap_rating_integer(ecf=ecf),
)
def fuel_cost_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[FuelCostResult]:
"""SAP 10.2 §10a cert→inputs cascade for `fuel_cost`. Off-peak certs
return the zero sentinel (Table 12a high-rate-fraction split deferred).
For STANDARD-tariff certs returns the full (240)..(255) FuelCostResult.
Composes via `cert_to_inputs(epc)` — `_fuel_cost` is invoked there with
all upstream §4/§5/§6/§7/§8/§9a values plumbed in. `postcode_climate`
selects the demand cascade (EPC Fuel Bill). Returns None when
TFA missing.
"""
if epc.total_floor_area_m2 is None:
return None
return cert_to_inputs(epc, postcode_climate=postcode_climate).fuel_cost
def energy_requirements_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[EnergyRequirementsResult]:
"""SAP 10.2 §9a cert→inputs cascade for `space_heating_fuel_monthly_kwh`.
Composes §8 (98c)m + Table 11 secondary fraction + per-system
efficiencies into the (201)..(221) line refs. Single-main scope A
(no (203)/(207)/(213)/(209)/(221)). `postcode_climate` selects the
demand cascade (Current Carbon / Current PE on EPC); None uses UK-avg.
Returns None when TFA missing.
"""
if epc.total_floor_area_m2 is None:
return None
sh = space_heating_section_from_cert(epc, postcode_climate=postcode_climate)
assert sh is not None, "space_heating None despite TFA present"
main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None
main_category = main.main_heating_category if main is not None else None
main_fuel = _main_fuel_code(main)
secondary_fraction_value = _secondary_fraction(
main, epc.sap_heating.secondary_heating_type if epc.sap_heating else None
)
# When no secondary system is lodged the worksheet displays (208) = 0;
# the per-system fuel formula already collapses to 0 via fraction_201 = 0
# so this is presentation-only.
secondary_efficiency_value = (
_secondary_efficiency(epc.sap_heating, main_code, main_fuel)
if secondary_fraction_value > 0.0 else 0.0
)
eff = _main_heating_efficiency(epc)
return space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=sh.total_space_heating_monthly_kwh,
secondary_heating_fraction=secondary_fraction_value,
main_heating_efficiency_pct=eff * 100.0,
secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0,
)
def solar_gains_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> SolarGainsResult:
"""SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`.
Returns the full `SolarGainsResult` (every (74)..(83) per-orientation
line ref + (82)/(82a) roof-window/rooflight monthly tuples) computed
from the cert's `sap_windows` (vertical wall windows) and
`sap_roof_windows` (pitched roof windows for line (82)) at default
AVERAGE overshading.
`postcode_climate` selects the demand cascade (postcode horizontal
solar irradiance + latitude via PCDB Table 172); None uses UK-average
region 0 — the SAP-rating pass.
Rooflights (horizontal Z=1.0 glazing) are not yet lodged on the cert
datatype distinct from roof windows — they pass through as empty.
"""
return solar_gains_from_cert(
epc=epc,
region=_climate_source(postcode_climate),
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
roof_windows=_roof_windows_for_solar_gains(epc),
)
_AGE_BANDS_F_TO_M: Final[frozenset[str]] = frozenset({"F", "G", "H", "I", "J", "K", "L", "M"})
_AGE_BANDS_A_TO_E: Final[frozenset[str]] = frozenset({"A", "B", "C", "D", "E"})
_SUSPENDED_TIMBER_FLOOR_TYPE: Final[str] = "Suspended timber"
_GROUND_FLOOR_TYPE: Final[str] = "Ground floor"
_FLOOR_U_SEALED_THRESHOLD: Final[float] = 0.5
def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]:
"""Compute the Main bp's ground-floor U-value via the same path the
cascade uses in `heat_transmission_section_from_cert`. Returns None
when the Main bp has no usable ground-floor dimension.
Used by `_has_suspended_timber_floor_per_spec` to apply the RdSAP 10
§5 (12) rule, which keys on whether the floor U-value < 0.5 W/m²K.
Mirrors the `effective_floor_description` rule from
`heat_transmission_section_from_cert`: the per-bp
`floor_construction_type` lodgement ("Suspended timber" / "Solid")
takes precedence over the global `epc.floors[].description` because
it's the explicit per-part Elmhurst Summary §3/§9 lodgement. Without
it the cascade routes via `_DEFAULT_FLOOR_BY_AGE` (solid) and can
return a low U on geometries where the BS EN ISO 13370 calc gives
<0.5, incorrectly triggering RdSAP10 §5 (12) rule (a) "U<0.5 →
sealed" for what is actually a suspended-timber floor (cert 9796
fixture: cascade U=0.49 routed through solid default vs the real
suspended-timber U=0.56 — the worksheet's (12)=0.2 unsealed).
"""
if not epc.sap_building_parts:
return None
main = epc.sap_building_parts[0]
ground_fd = next(
(fd for fd in main.sap_floor_dimensions if fd.floor == 0),
main.sap_floor_dimensions[0] if main.sap_floor_dimensions else None,
)
if ground_fd is None or ground_fd.is_exposed_floor or main.has_basement:
return None
raw_floor_ins = getattr(main, "floor_insulation_thickness", None)
floor_ins_mm: Optional[int] = (
int(raw_floor_ins) if isinstance(raw_floor_ins, (int, float))
else (0 if raw_floor_ins == "NI" else None)
)
# Mirror heat_transmission's `effective_floor_description`: the per-bp
# `floor_construction_type` takes precedence over a joined
# `epc.floors[].description` since the per-part lodgement is the
# explicit Elmhurst Summary §3/§9 surface. Inline the join (vs
# importing from heat_transmission) to keep cert_to_inputs free of
# cross-module private symbol imports.
if main.floor_construction_type:
effective_floor_description = main.floor_construction_type
else:
descs = [
d for d in
(getattr(f, "description", None) for f in (epc.floors or []))
if d
]
effective_floor_description = " | ".join(descs) if descs else None
return u_floor(
country=Country.from_code(epc.country_code) if epc.country_code else None,
age_band=main.construction_age_band,
construction=_int_or_none(ground_fd.floor_construction),
insulation_thickness_mm=floor_ins_mm,
area_m2=ground_fd.total_floor_area_m2,
perimeter_m=ground_fd.heat_loss_perimeter_m,
wall_thickness_mm=main.wall_thickness_mm,
description=effective_floor_description,
)
def _has_suspended_timber_floor_per_spec(
epc: EpcPropertyData,
) -> tuple[bool, bool]:
"""RdSAP 10 Specification §5 (page 29) — "Floor infiltration
(suspended timber ground floor only)" rule.
Returns `(has_suspended_timber_floor, suspended_timber_floor_sealed)`
derived mechanically from the lodged cert data (per the spec's
deterministic decision tree).
Spec text (verbatim):
Default infiltration when:
- Age band of main dwelling A to E:
a) if floor U-value is < 0.5, assume "sealed" and use floor
infiltration 0.1
b) if floor insulation is 'retro-fitted' and no U-value is
supplied, assume "sealed" and use 0.1;
otherwise "unsealed" and use floor infiltration 0.2.
- Age band of main dwelling F to M: sealed
(the floor infiltration for the whole dwelling is determined
by the floor type of the main dwelling)
- Park home: assume unsealed suspended timber and use floor
infiltration 0.2.
The rule only applies when the Main bp's lowest floor is a
"Ground floor" with "Suspended timber" construction. All other
combinations fall through to `(False, False)` and the cascade
enters 0 for (12).
"""
if not epc.sap_building_parts:
return False, False
main = epc.sap_building_parts[0]
# Park home short-circuit (always unsealed suspended timber per spec).
if (epc.property_type or "").strip().lower() == "park home":
return True, False
if main.floor_type != _GROUND_FLOOR_TYPE:
return False, False
if main.floor_construction_type != _SUSPENDED_TIMBER_FLOOR_TYPE:
return False, False
age = (main.construction_age_band or "").strip().upper()
if age in _AGE_BANDS_F_TO_M:
return True, True # sealed
if age in _AGE_BANDS_A_TO_E:
# (a) U-value < 0.5 → sealed
main_floor_u = _main_floor_u_value(epc)
if main_floor_u is not None and main_floor_u < _FLOOR_U_SEALED_THRESHOLD:
return True, True
# (b) retro-fitted insulation + no U-value supplied → sealed
ins_type_str = (main.floor_insulation_type_str or "").strip().lower()
u_value_known = bool(getattr(main, "floor_u_value_known", False))
if "retro" in ins_type_str and not u_value_known:
return True, True
# otherwise → unsealed
return True, False
# Unknown age band — default to unsealed (matches the spec's general
# case for old housing stock; cohort certs have B/C bands).
return True, False
def ventilation_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> VentilationResult:
"""SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`.
Reads dimensions + sap_ventilation lodgement from `epc` and produces
the full `VentilationResult` (every (6a)..(25)m line ref) — the
exact same call cert_to_inputs makes internally. Exposed so cascade
pin tests can assert every §2 line ref against the U985 PDF.
`postcode_climate` overrides the UK-average wind tuple (Table U2 row 0)
with PCDB Table 172 postcode-district wind for the demand cascade
(Current Carbon / Current Primary Energy on the EPC).
Defaults track the same conventions as cert_to_inputs (sheltered
sides → 2 when missing, MV kind → NATURAL until cert→MV mapping is
documented).
"""
dim = dimensions_from_cert(epc)
vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0
storeys = max(1, dim.storey_count)
vc = _ventilation_counts(epc.sap_ventilation)
sv = epc.sap_ventilation
# RdSAP 10 §4.1 Table 5 (PDF p.28) — extract-fans default when the
# lodged count is below the age-band minimum. The Elmhurst Summary
# renders "0" as the form for unknown; the worksheet applies the
# default via `max(lodged, table_5_default)`.
age_band = (
epc.sap_building_parts[0].construction_age_band
if epc.sap_building_parts else ""
)
is_park_home = (epc.property_type or "").strip().lower() == "park home"
table_5_fan_default = _rdsap_extract_fans_default(
age_band, epc.habitable_rooms_count, is_park_home=is_park_home,
)
intermittent_fans = max(vc.intermittent_fans, table_5_fan_default)
wind_kwargs: dict[str, tuple[float, ...]] = (
{"monthly_wind_speed_m_s": postcode_climate.monthly_wind_speed_m_per_s}
if postcode_climate is not None else {}
)
# RdSAP 10 §5 (12) suspended-timber floor infiltration is mechanically
# derived from age band + floor U-value + insulation type. When the
# lodgement carries an explicit value (cohort hand-built fixtures
# do, to mirror their U985 worksheet line (12) verbatim), it
# overrides the spec derivation; otherwise the spec rule applies.
spec_has_susp, spec_sealed = _has_suspended_timber_floor_per_spec(epc)
eff_has_susp = (
bool(sv.has_suspended_timber_floor)
if sv is not None and sv.has_suspended_timber_floor is not None
else spec_has_susp
)
eff_sealed = (
bool(sv.suspended_timber_floor_sealed)
if sv is not None and sv.suspended_timber_floor_sealed is not None
else spec_sealed
)
# SAP 10.2 §2 (17a) — AP4 pressure-test reading routes to the
# cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value
# falls through to the components-based (16) ach.
ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None
# SAP 10.2 §2 (23a)/(24a..d) — MV kind dispatch chooses the (25)m
# effective-ach formula. The Elmhurst mapper translates the lodged
# "Mechanical Ventilation Type" string to an enum *name*; resolve
# back to the enum here. Unmapped names default to NATURAL (24d).
mv_kind = MechanicalVentilationKind.NATURAL
mv_system_ach = 0.0
mv_kind_name = sv.mechanical_ventilation_kind if sv is not None else None
if mv_kind_name is not None:
try:
mv_kind = MechanicalVentilationKind[mv_kind_name]
except KeyError:
mv_kind = MechanicalVentilationKind.NATURAL
if mv_kind is not MechanicalVentilationKind.NATURAL:
# SAP 10.2 §2 (23a) "If mechanical ventilation: air change
# rate through system = 0.5" (PDF p.13). PCDB-lodged systems
# can override via a future plumbing slice; the spec default
# is what every MEV / MV / MVHR cohort cert lodges today.
mv_system_ach = 0.5
return ventilation_from_inputs(
volume_m3=vol,
storey_count=storeys,
is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts),
open_chimneys=epc.open_chimneys_count or 0,
blocked_chimneys=epc.blocked_chimneys_count or 0,
open_flues=vc.open_flues,
closed_fire_chimneys=vc.closed_fire_chimneys,
solid_fuel_boiler_chimneys=vc.solid_fuel_boiler_chimneys,
other_heater_chimneys=vc.other_heater_chimneys,
intermittent_fans=intermittent_fans,
passive_vents=vc.passive_vents,
flueless_gas_fires=vc.flueless_gas_fires,
has_suspended_timber_floor=eff_has_susp,
suspended_timber_floor_sealed=eff_sealed,
has_draught_lobby=bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False,
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2,
air_permeability_ap4=ap4,
mv_kind=mv_kind,
mv_system_ach=mv_system_ach,
**wind_kwargs,
)
# SAP 10.2 Table J4 — default mixer-shower flow rate for an existing
# dwelling with a vented hot water system (the existing-dwelling minimum).
# Both validation worksheets (000474 + 000490) lodge this value. Combi-
# pumped showers (11 L/min) and instantaneous-electric showers (handled
# via line (64a)m, not here) need shower-outlet-type plumbing in a later
# slice.
_SHOWER_FLOW_VENTED_L_PER_MIN: Final[float] = 7.0
def _mixer_shower_flow_rates_from_cert(
epc: EpcPropertyData,
) -> tuple[float, ...]:
"""Pull mixer-shower flow rates from the cert.
When `sap_heating.mixer_shower_count` is lodged, use that count
of vented mixers @ Table J4's 7 L/min row. When None, default to a
single vented outlet — the modal RdSAP lodging. Combi-pumped
showers (11 L/min) need a richer cert surface in a future slice.
"""
count = (
epc.sap_heating.mixer_shower_count
if epc.sap_heating is not None
else None
)
if count is None:
count = 1
return tuple(_SHOWER_FLOW_VENTED_L_PER_MIN for _ in range(max(0, count)))
def _has_electric_shower_from_cert(epc: EpcPropertyData) -> bool:
"""True iff cert lodges ≥ 1 instantaneous electric shower.
Electric showers don't draw warm water from the main HW system but
count in `Noutlets` for SAP10.2 Appendix J (p.81) step 1a and route
Nbath through the "shower also present" branch in step 2a (0.13N +
0.19 instead of 0.35N + 0.50). Defaults False when unlodged."""
n = epc.sap_heating.electric_shower_count if epc.sap_heating is not None else None
return (n or 0) >= 1
def _electric_shower_count_from_cert(epc: EpcPropertyData) -> int:
"""Cert-lodged count of instantaneous electric showers. Drives the
LINE_64A energy derivation in `water_heating_from_cert` per SAP10.2
Appendix J (p.82) step 8."""
n = epc.sap_heating.electric_shower_count if epc.sap_heating is not None else None
return max(0, n or 0)
def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
"""True iff cert lodges ≥ 1 bath. `number_baths is None` is treated as
bath present (modal UK lodging — bathless dwellings are rare and
typically explicitly lodged as 0)."""
n = epc.sap_heating.number_baths
return n is None or n >= 1
class UnresolvedPcdbCombiLoss(ValueError):
"""Raised when a cert lodges a PCDB Table 105 combi whose keep-hot
configuration falls outside the SAP 10.2 Table 3a rows the cascade
has implemented.
Current trigger: `keep_hot_facility ∈ {2, 3}` (keep-hot heated by
electricity, or a mix of electricity + fuel — Table 3a Note 2 routes
the electric portion of the loss to worksheet (219)m rather than
leaving it in (61)m). The cascade does not yet split the loss across
fuels, so surface the gap rather than silently mis-route.
Rows the cascade now handles (Slice S0380.21):
- `keep_hot_facility ∈ {0, None}` → Table 3a row 1 (no keep-hot)
`600 × fu × n_m / 365` with `fu = min(1, V_d/100)`.
- `keep_hot_facility=1, keep_hot_timer=1` → Table 3a row 3
(keep-hot, time-clock) `600 × n_m / 365` (cascade default).
- `keep_hot_facility=1, keep_hot_timer ∈ {0, None}` → Table 3a
row 4 (keep-hot, no time clock) `900 × n_m / 365`.
"""
def __init__(
self, *, pcdf_index: Optional[int], boiler: str, reason: str
) -> None:
super().__init__(
f"PCDB combi {boiler!r} (PCDF {pcdf_index}): {reason}"
)
self.pcdf_index = pcdf_index
self.boiler = boiler
def pcdb_combi_loss_override(
pcdb_record: Optional[GasOilBoilerRecord],
*,
energy_content_monthly_kwh: tuple[float, ...],
daily_hot_water_monthly_l_per_day: tuple[float, ...],
) -> Optional[tuple[float, ...]]:
"""Route a PCDB combi record to the matching SAP10.2 Appendix J row.
PCDF Spec Rev 6b field 48 (`separate_dhw_tests`) encodes which EN
13203-2 / OPS 26 schedules the lab tested under, and that selects
the SAP Table:
= 1 → schedule 2 only (profile M) → Table 3b row 1
= 2 → schedules 2 and 3 (profiles M + L) → Table 3c, DVF = M+L
= 3 → schedules 2 and 1 (profiles M + S) → Table 3c, DVF = M+S
= 0 / None falls through to Table 3a, dispatched by the PCDB
keep-hot fields (`keep_hot_facility`, `keep_hot_timer`):
kh ∈ {0, None} → row 1 (no keep-hot) 600 × fu × n/365
kh = 1, timer = 1 → row 3 (time-clock) 600 × n / 365
kh = 1, timer ∈ {0, None} → row 4 (no time-clock) 900 × n / 365
kh ∈ {2, 3} → electric keep-hot, raises
`UnresolvedPcdbCombiLoss` (Table 3a
Note 2 fuel-split deferred).
Storage-FGHRS and storage-combi variants (`subsidiary_type` ∈ {1, 2,
3} → integral FGHRS / HP+boiler combinations; `store_type` ∈ {1, 2,
3} → primary / secondary store / CPSU) gate Rows 2-5 of both Tables
3b and 3c. Those rows are deferred until a fixture exercises them
— defaulting to Table 3a is safe (matches the pre-§4 behaviour) but
loses spec accuracy for those configurations.
"""
if pcdb_record is None:
return None
if pcdb_record.subsidiary_type not in (None, 0):
return None
if pcdb_record.store_type not in (None, 0):
return None
sdt = pcdb_record.separate_dhw_tests
if sdt in (0, None):
# No EN 13203-2 lab data → dispatch via Table 3a keep-hot fields.
kh = pcdb_record.keep_hot_facility
timer = pcdb_record.keep_hot_timer
if kh in (0, None):
# SAP 10.2 Table 3a row 1: 600 × fu × n_m / 365 (spec p.160).
return combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot(
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
)
if kh == 1:
if timer == 1:
# SAP 10.2 Table 3a row 3: 600 × n_m / 365. Cascade's
# `water_heating_from_cert` default — return None so the
# default fires.
return None
# SAP 10.2 Table 3a row 4: 900 × n_m / 365 (no time-clock).
return combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock()
# kh ∈ {2, 3} — electric or mixed keep-hot. Table 3a Note 2 routes
# the electric portion of the loss to (219)m rather than (61)m;
# the cascade doesn't yet split across fuels.
raise UnresolvedPcdbCombiLoss(
pcdf_index=pcdb_record.pcdb_id,
boiler=(
f"{pcdb_record.brand_name} {pcdb_record.model_name} "
f"{pcdb_record.model_qualifier}".strip()
),
reason=(
f"keep_hot_facility={kh} indicates electric or mixed "
f"keep-hot — Table 3a Note 2 fuel-split not yet "
f"implemented (cascade can't route part of (61) to (219))."
),
)
r1 = pcdb_record.rejected_energy_proportion_r1
if r1 is None:
return None
match sdt:
case 1:
f1 = pcdb_record.loss_factor_f1_kwh_per_day
if f1 is None:
return None
return combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
rejected_energy_proportion_r1=r1,
loss_factor_f1_kwh_per_day=f1,
energy_content_monthly_kwh=energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
)
case 2 | 3:
f2 = pcdb_record.loss_factor_f2_kwh_per_day
f3 = pcdb_record.rejected_factor_f3_per_litre
if f2 is None or f3 is None:
return None
profile_pair: Literal["M+L", "M+S"] = (
"M+L" if sdt == 2 else "M+S"
)
return combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
rejected_energy_proportion_r1=r1,
loss_factor_f2_kwh_per_day=f2,
rejected_factor_f3_per_litre=f3,
profile_pair=profile_pair,
energy_content_monthly_kwh=energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
)
case _:
return None
# SAP 10.2 §4 line 7702 gates the Table 3a keep-hot combi loss default
# to combi boilers ("enter '0' if not a combi boiler"). The Open EPC API
# typically lodges `sap_main_heating_code = None` so we cannot key off the
# precise SAP code; the next best signal is `main_heating_category`.
# Categories 1 and 2 enumerate the gas / oil / solid-fuel boiler family
# (which contains all combi boilers); categories 3 and 6 are community
# heat networks (treated as boiler-like by the cascade and the existing
# DLF-scaling regression test). Categories 4 (heat pump), 5 (warm air),
# 7 (electric storage), 10 (room heaters) etc. are never combis and must
# zero (61)m per the spec.
_TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset(
{1, 2, 3, 6}
)
# RdSAP 10 §10.5 Table 28: lodged "Cylinder size" descriptors → SAP
# calculation litres. The Open EPC API encodes the descriptor as an
# integer per the cohort below (ground-truthed against worksheet (47)
# line refs in /sap worksheets/Additional data with api/<cert>/dr87-*.pdf
# and /sap worksheets/additional with api 2/<cert>/dr87-*.pdf):
# code 1 → no cylinder (gated via `has_hot_water_cylinder`)
# code 2 → Normal (110 litres) (certs 2536, 9421 — worksheet (47)
# lodges 110.0)
# code 3 → Medium (160 litres) (certs 0350, 0380, 2225, 2636,
# 3800, 9285)
# code 4 → Large (210 litres) (cert 9418)
# Codes 5 / 6 (Inaccessible / Exact) not yet observed.
_CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
2: 110.0, 3: 160.0, 4: 210.0
}
# RdSAP 10 §10.5 code 7-11: cylinder insulation type. Empirical mapping
# from the ASHP cohort (all 7 certs lodge code 1, worksheet shows
# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2).
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
# RdSAP 10 §10.7 (PDF p.55) "No water heating system": SAP water-heating
# code 999 (Elmhurst §15.0 "NON") signals that no DHW system was
# identified. Per spec the calculation is then done for an electric
# immersion heater + a cylinder defined by the first row of Table 28
# (110 litres) and the first row of Table 29 (age-band insulation).
_WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999
# Table 28 row 1 "Inaccessible — otherwise: 110 litres" → SAP cylinder
# size code 2 (Normal, 110 L). The immersion is single unless the meter
# is dual; the corpus "no system" cert's worksheet header lodges
# "Immersion Heater Type: Single" so the single-immersion path is used.
_CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2
# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
# accessible" — the §10.7 default cylinder uses the age-band insulation,
# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket
# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam.
_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = {
"G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38,
}
def _apply_rdsap_no_water_heating_system_default(
epc: EpcPropertyData,
) -> EpcPropertyData:
"""RdSAP 10 §10.7 (PDF p.55) — when no water heating system is
identified (`water_heating_code == 999`), substitute the spec
default: an electric immersion heater (single — dual handling not
yet exercised) on a Table 28 row-1 110 L cylinder with Table 29
row-1 age-band insulation and an assumed cylinder thermostat
(Table 29: "A cylinder thermostat should be assumed to be present
when DHW is from ... an immersion heater ...").
Returns `epc` unchanged when a real water heating system is lodged.
Otherwise returns a copy with `has_hot_water_cylinder=True` and the
`sap_heating` fields the WHC-903 electric-immersion + cylinder
cascade reads, so every downstream gate (storage loss, combi-loss
suppression, primary loss) sees the spec default. This mirrors the
Elmhurst engine's worksheet header for the corpus "no system" cert
(WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G).
Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket) —
no corpus member exercises that combination and the SAP 10.2 Table 2
loss-factor dispatch only has the factory-foam path plumbed.
"""
if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM:
return epc
age_band = (
epc.sap_building_parts[0].construction_age_band
if epc.sap_building_parts else None
)
band = (age_band or "")[:1].upper()
thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band)
if thickness_mm is None:
raise UnmappedSapCode(
"rdsap_10_7_default_cylinder_insulation_age_band", age_band
)
sap_heating = replace(
epc.sap_heating,
water_heating_code=_WHC_ELECTRIC_IMMERSION,
water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE,
cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L,
cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY,
cylinder_insulation_thickness_mm=thickness_mm,
cylinder_thermostat="Y",
)
return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating)
# SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent
# boilers (151, 153, 155, 159), open-fire + back boiler (156), closed
# room heater + back boiler (158), range cooker boiler (160, 161).
# Per the structure described in §9.2.4 these systems do not ship with
# dual programmers; DHW timing follows the appliance burn schedule, NOT
# a separate cylinder programmer.
_TABLE_4A_SOLID_FUEL_BOILER_CODES: Final[frozenset[int]] = frozenset(
{151, 153, 155, 156, 158, 159, 160, 161}
)
def _separately_timed_dhw(
epc: EpcPropertyData, main: Optional[MainHeatingDetail],
) -> bool:
"""SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature
Factor by 0.9 if there is separate time control of domestic hot
water (boiler systems, warm air systems and heat pump systems)".
The spec restricts the ×0.9 reduction to those three system types
— electric immersion DHW is NOT in the list, so the ×0.9 multiplier
must NOT apply when the water-heating fuel is electric (whether
on a standard meter or off-peak immersion timer).
Same flag drives SAP 10.2 Table 3 (PDF p.160) primary-loss row
selection: "Cylinder thermostat, water heating separately timed"
gives winter h=3 / summer h=3; "not separately timed" gives winter
h=5 / summer h=3.
RdSAP §3 default: when a hot-water cylinder is lodged AND the
cylinder is fed by a boiler / warm-air / HP, DHW timing is separate
from space heat — the cylinder is heated on its own programmer /
overnight boost regardless of which heat generator feeds it.
Solid-fuel boilers (Table 4a codes 151-161) are the exception. Per
SAP 10.2 §9.2.4 these systems are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler"
the appliance itself is the timer. DHW timing follows the burn
schedule, NOT a separate cylinder programmer, so the middle Table
3 row applies (winter h=5 / summer h=3). Worksheet evidence from
the heating-systems corpus property 001431: solid fuel 3 (code
160 + WHC=901 + cylinder thermostat) lodges (59)m winter = 64.58
(h=5, p=0) and (59)m summer = 41.92 / 43.31 (h=3, p=0). Pre-slice
the cascade returned True here, routing through h=3 year-round.
Combi-only dwellings (no cylinder) skip the multiplier — DHW is
instantaneous and shares the boiler's space-heating cycle, so
there's no separate timer. Heat pumps (cat 4) keep their existing
always-True default for the HP-without-cylinder edge case the
earlier cohort calibration was sized around.
Pre-S0380.140 this returned True for any cylinder-lodged cert
regardless of HW fuel, which over-applied the ×0.9 multiplier on
electric-immersion certs. Combined with the cascade's
`cylinder_thermostat is None → False` fallback (over-applying ×1.3),
these compounded to TF=0.702 vs the worksheet's TF=0.60, over-
counting (56)m storage loss by ~76 kWh/yr × 17 corpus variants.
"""
if main is None:
return False
# SAP 10.2 Table 2b note b) verbatim system-type list — "boiler
# systems, warm air systems and heat pump systems". Electric
# immersion is not in that list because the immersion isn't a
# heat-generator system feeding DHW: it sits inside the cylinder.
# The ×0.9 multiplier reflects shorter cylinder-heating periods
# when a boiler / HP / warm-air operates on a separate timer for
# DHW vs SH — when the heat generator doesn't feed the cylinder at
# all (because the immersion does), there's no such timing effect.
# The Elmhurst WHC=903 lodging signals "HW from a separate electric
# immersion heater" — the cylinder is independent of the main
# heating, regardless of what the main heating is (HP / boiler /
# warm-air). Same principle as the [[S0380.156]] Table 3 primary-
# loss WHC=903 guard.
if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False
if main.main_heating_category == 4:
return True
if _is_electric_water(epc.sap_heating.water_heating_fuel):
return False
if main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES:
return False
return bool(epc.has_hot_water_cylinder)
def _table_2b_note_b_multiplier_applies(
epc: EpcPropertyData, main: Optional[MainHeatingDetail],
) -> bool:
"""SAP 10.2 Table 2b note b) (PDF p.159) verbatim:
"Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems)."
The system-type list "boiler / warm air / heat pump systems" omits
community heating. The ×0.9 reduction therefore does NOT fire for
heat-network mains even when DHW IS separately timed — for Table 3
primary-loss hours the cascade still treats community-heating DHW
as separately timed (h=3) because Table 3 is system-type-agnostic.
Worksheet evidence for heating-systems corpus 001431 community
heating 1 (Table 4a code 301, cylinder + thermostat + WHC=901):
(53) Temperature factor lodged as 0.6000 (Table 2b base) — NOT
0.54 (= 0.6 × 0.9). Pre-slice the cascade routed community heating
through `_separately_timed_dhw=True` and applied the ×0.9 multiplier,
under-counting (57)m storage loss by ~10% × 12 months ≈ 45 kWh/yr.
"""
if not _separately_timed_dhw(epc, main):
return False
if main is None:
return False
if _is_heat_network_main(main):
return False
return True
# RdSAP §3 default table (PDF p.56) — "Insulation of primary pipework":
# age bands A-J → none (p=0.0); age bands K, L, M → full (p=1.0). The
# default applies when the cert does not lodge an explicit insulation
# fraction — which is the modal case for the Open EPC API (no field).
_PIPEWORK_FULL_INSULATION_AGE_BANDS: Final[frozenset[str]] = frozenset(
{"K", "L", "M"}
)
def _pipework_insulation_fraction_table_3(primary_age: Optional[str]) -> float:
"""RdSAP §3 default for primary pipework insulation by age band.
Bands K, L, M (post-2007) → 1.0 fully insulated; A-J → 0.0
uninsulated. Unknown age band defaults to 0.0 (the conservative
older-stock assumption matching cert 0380's worksheet 'Uninsulated
primary pipework' lodgement).
"""
if primary_age in _PIPEWORK_FULL_INSULATION_AGE_BANDS:
return PIPEWORK_INSULATED_FULLY
return PIPEWORK_INSULATED_UNINSULATED
# SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary circuit
# loss for INSULATED pipework and cylinderstat should be included (see
# Table 3)." The spec literal "insulated pipework" pins the Table 3
# pipework_insulation_fraction at p=1.0 for community-heating mains,
# overriding the age-band default in `_pipework_insulation_fraction_
# table_3`. Worksheet evidence for heating-systems corpus 001431 CH1
# (age G, age-band default p=0): the P960 (59)m monthly back-solves to
# h=3 + p=1 (n × 14 × (0.0091×3 + 0.0263) = 23.26 Jan), not h=3 + p=0
# (which would give n × 14 × (0.0245×3 + 0.0263) = 43.4 Jan).
_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION: Final[float] = (
PIPEWORK_INSULATED_FULLY
)
# SAP 10.2 PDF p.100 line 5950: design heat loss = (39) × ΔT, where ΔT
# = 24.2 K. The HLC × ΔT product feeds the PSR denominator per line 5946.
_SAP_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2
# Cohort-derived in-use factors per SAP 10.2 Appendix N3.6 / N3.7 (PDF
# p.108 + the cylinder criteria table at p.6097). 0.95 applies only when
# the cert's cylinder matches the PCDB-lodged volume / heat exchanger
# area / heat loss; 0.60 otherwise (or when any criterion is unknown).
_HP_SPACE_HEATING_IN_USE_FACTOR_N3_6: Final[float] = 0.95
_HP_IN_USE_FACTOR_CRITERIA_MET: Final[float] = 0.95
_HP_IN_USE_FACTOR_CRITERIA_FAIL: Final[float] = 0.60
def _heat_pump_cylinder_meets_pcdb_criteria(
epc: EpcPropertyData,
hp_record: "HeatPumpRecord",
) -> bool:
"""Spec PDF p.6097 — "in-use factor 0.95 applies when the actual
cylinder has performance parameters at least equal to those in the
PCDB record, namely:
- cylinder volume not less than that in the PCDB record
- heat transfer area not less than that in the PCDB record
(unless the PCDB heat exchanger area is zero — see footnote 53)
- heat loss (kWh/day) [either (48) or (47) × (51) × (52)] not
greater than that in the PCDB record.
If any of these conditions are not fulfilled, or are unknown, the
in-use factor is 0.60."
The Open EPC API does not lodge cylinder heat exchanger area, so
for the cohort this criterion is always "unknown" → returns False.
"""
sh = epc.sap_heating
size_code = _int_or_none(sh.cylinder_size)
if size_code is None:
return False
cert_volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
if cert_volume_l is None:
return False
# Volume criterion.
if hp_record.vessel_volume_l is None or cert_volume_l < hp_record.vessel_volume_l:
return False
# Heat exchanger area criterion. The footnote 53 carve-out (PCDB
# area = 0 → test does not apply) doesn't fire here because cohort
# records lodge non-zero areas (3.0 m² for 104568 / 0.415 for
# 102421). Open EPC certs don't lodge HX area → always fail.
if (
hp_record.vessel_heat_exchanger_area_m2 is not None
and hp_record.vessel_heat_exchanger_area_m2 > 0.0
):
return False # cert HX area is unknown per API schema → criterion fails
# Heat loss criterion.
if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY:
return False
thickness_mm = sh.cylinder_insulation_thickness_mm
if thickness_mm is None:
return False
cert_heat_loss_kwh_per_day = (
cert_volume_l
* cylinder_storage_loss_factor_table_2(
insulation_type="factory_insulated",
thickness_mm=float(thickness_mm),
)
* cylinder_volume_factor_table_2a(cert_volume_l)
)
pcdb_heat_loss = hp_record.vessel_heat_loss_kwh_per_day
if pcdb_heat_loss is None or cert_heat_loss_kwh_per_day > pcdb_heat_loss:
return False
return True
def _heat_pump_apm_efficiencies(
*,
main: Optional[MainHeatingDetail],
hp_record: Optional["HeatPumpRecord"],
hlc_annual_avg_w_per_k: float,
epc: EpcPropertyData,
) -> Optional[tuple[float, float]]:
"""Compute `(main_heating_efficiency, water_efficiency_pct)` per
SAP 10.2 Appendix N3.6 (space) + N3.7(a) (water, footnote 49).
Returns None when APM is not applicable (no HP, no PCDB record, no
PSR groups, no max output) so the caller keeps the Table 4a default.
"""
if main is None or main.main_heating_category != 4:
return None
if hp_record is None or not hp_record.psr_groups:
return None
if hp_record.max_output_kw is None or hp_record.max_output_kw <= 0:
return None
if hlc_annual_avg_w_per_k <= 0:
return None
psr = (hp_record.max_output_kw * 1000.0) / (
hlc_annual_avg_w_per_k * _SAP_DESIGN_HEAT_LOSS_DELTA_T_K
)
eta_space_1_pct, eta_water_3_pct = interpolate_heat_pump_efficiency_at_psr(
hp_record.psr_groups, target_psr=psr,
)
in_use_water = (
_HP_IN_USE_FACTOR_CRITERIA_MET
if _heat_pump_cylinder_meets_pcdb_criteria(epc, hp_record)
else _HP_IN_USE_FACTOR_CRITERIA_FAIL
)
main_heating_efficiency = (
_HP_SPACE_HEATING_IN_USE_FACTOR_N3_6 * eta_space_1_pct / 100.0
)
water_efficiency_pct = in_use_water * eta_water_3_pct / 100.0
return (main_heating_efficiency, water_efficiency_pct)
def _heat_pump_extended_heating_days_per_month(
*,
main: Optional[MainHeatingDetail],
hp_record: Optional["HeatPumpRecord"],
hlc_annual_avg_w_per_k: float,
) -> Optional[tuple[tuple[int, int], ...]]:
"""SAP 10.2 Appendix N3.5 (PDF p.106-107) — per-month (N24,9, N16,9)
day allocations for a heat-pump package's extended heating schedule.
Returns None when extended heating doesn't apply, so the upstream
`mean_internal_temperature_monthly` orchestrator falls through to
the standard SAP heating schedule (bimodal 9-hour day).
Per `heating_duration_code` from the PCDB record (SAP 10.2 PDF
p.105 line 6099):
- "V" (Variable, modern default per footnote 48): Table N5 PSR
interpolation + cold-first allocation via
`allocate_extended_heating_days_to_months`.
- "24": Table N4 N24,9 = 365 — every day operates at 24-hour
heating (no off period), so each month's tuple is
(days_in_month, 0).
- "16": Table N4 N16,9 = 365 — every day unimodal (one 8h off),
each month's tuple is (0, days_in_month).
- "9" or other: standard 9-hour schedule = no extended heating →
return None so the orchestrator's bimodal fallback applies.
"""
if main is None or main.main_heating_category != 4:
return None
if hp_record is None:
return None
code = hp_record.heating_duration_code
if code == "V":
if hp_record.max_output_kw is None or hp_record.max_output_kw <= 0:
return None
if hlc_annual_avg_w_per_k <= 0:
return None
psr = (hp_record.max_output_kw * 1000.0) / (
hlc_annual_avg_w_per_k * _SAP_DESIGN_HEAT_LOSS_DELTA_T_K
)
n24, n16 = extended_heating_days_from_psr_variable(psr=psr)
return allocate_extended_heating_days_to_months(
n24_9_year=n24, n16_9_year=n16,
)
if code == "24":
return tuple((d, 0) for d in _DAYS_IN_MONTH)
if code == "16":
return tuple((0, d) for d in _DAYS_IN_MONTH)
return None
# SAP 10.2 Table 4b (PDF p.168) sub-rows that are explicitly combi or
# CPSU boilers — i.e. on the Table 3 zero-loss list ("Combi boiler ...
# CPSU ..."). Every other Table 4b code (101-141) is a regular or
# back-boiler / range-cooker boiler that incurs primary circuit loss
# when feeding a hot-water cylinder.
#
# Combi codes:
# 103, 104 — combi gas 1998+ (non-condensing / condensing)
# 107, 108 — combi gas 1998+ permanent pilot
# 112, 113 — combi gas pre-1998 fan-assisted flue
# 118 — combi gas pre-1998 balanced/open flue
# 128, 129, 130 — combi oil (pre-1998 / 1998+ / condensing)
# CPSU codes:
# 120, 121, 122, 123 — CPSU gas (auto/permanent × non/condensing)
_TABLE_4B_COMBI_OR_CPSU_CODES: Final[frozenset[int]] = frozenset({
103, 104, 107, 108, 112, 113, 118,
120, 121, 122, 123,
128, 129, 130,
})
_TABLE_4B_CODE_RANGE: Final[range] = range(101, 142)
def _primary_loss_applies(
main: Optional[MainHeatingDetail],
cylinder_present: bool,
hp_record: Optional[HeatPumpRecord],
water_heating_code: Optional[int] = None,
) -> bool:
"""SAP 10.2 Table 3 (PDF p.160) zero-loss configurations — primary
loss only fires when a cylinder is present AND the lodgement falls
outside the zero list. The cohort path: heat-pump main heating with
a separate (not integral) vessel per the PCDB Table 362 record.
Combi boilers, CPSUs, thermal stores within 1.5 m insulated pipe,
direct-acting electric boilers, electric immersion heaters, and
HPs with `hw_vessel_mode = 1` (integral) all skip the loss. For
cohort coverage we model four paths:
- HP with PCDB record: gate on `hp_record.hw_vessel_mode != 1`
- Boiler (cat 1, 2) with cylinder: primary loss applies (the
cascade's pre-slice-102d behaviour was zero, masking ~516
kWh/yr on certs with cylinders).
- PCDB Table 322 (gas/oil boiler) record with cylinder, when
main_heating_category is not lodged: primary loss applies
(cylinder presence + PCDB boiler = "boiler connected to hot-
water storage vessel" per Table 3 row 1 — the spec category
for this fixture is 1, but the Elmhurst mapper currently
leaves `main_heating_category=None`, so the cascade dispatch
falls through to this branch instead of the boiler-category
branch above).
- Table 4b non-PCDB boiler (sap_main_heating_code 101-141)
with cylinder, when main_heating_category is not lodged:
primary loss applies UNLESS the code is on the Table 3 zero
list (combi sub-rows + CPSU sub-rows per
`_TABLE_4B_COMBI_OR_CPSU_CODES`). Mirror of the PCDB Table
322 branch — Elmhurst's heating-systems corpus leaves
`main_heating_category=None` for Table 4b oil 1 (code 127
"Condensing oil boiler" + 110 L cylinder), so the boiler-
category branch above misses it; this branch picks it up.
"""
if not cylinder_present:
return False
if main is None:
return False
# SAP 10.2 Table 3 (PDF p.160) zero-loss list — verbatim:
# "Primary loss is set to zero for the following: Electric immersion
# heater ...". Elmhurst WHC=903 lodges "HW from a separate electric
# immersion heater": the cylinder is heated by an immersion element
# inside the tank, no primary pipework between any heat generator
# and the cylinder. Applies universally — regardless of which main
# heating system exists for space heating (Cat 4 HP, Cat 1/2 boiler,
# Table 4b non-PCDB, PCDB Table 322). Pre-slice the WHC check only
# gated the Table 4a wet-boiler branch; the other branches falsely
# returned True for HP / boiler mains with WHC=903, adding ~510
# kWh/yr primary loss to a system with no primary circuit at all.
if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False
if main.main_heating_category == 4:
if hp_record is None:
# No PCDB record → assume separate-vessel (conservative; the
# zero-loss "integral vessel" branch requires explicit PCDB
# confirmation per spec).
return True
# Spec p.159: zero for "Heat pump from PCDB with hot water vessel
# integral to package". Vessel mode 1 = integral.
return hp_record.hw_vessel_mode != 1
if main.main_heating_category in {1, 2}:
return True
# Elmhurst-path fallback: when the cert lodges a PCDB Table 322
# record (gas/oil boiler) but `main_heating_category` is None, the
# presence of the PCDB boiler record is sufficient evidence that
# the main is a boiler — Table 3 row 1 applies ("hot water is
# heated by a heat generator (e.g. boiler) connected to a hot
# water storage vessel via insulated or uninsulated pipes").
if main.main_heating_index_number is not None:
if gas_oil_boiler_record(main.main_heating_index_number) is not None:
return True
# Elmhurst-path fallback for Table 4b non-PCDB boilers: a lodged
# `sap_main_heating_code` in the 101-141 gas/liquid-fuel-boiler
# range that is NOT a combi or CPSU sub-row is a regular / back-
# boiler / range-cooker boiler — primary loss applies per Table 3
# row 1 (boiler + cylinder via primary pipework).
code = main.sap_main_heating_code
if (
code is not None
and code in _TABLE_4B_CODE_RANGE
and code not in _TABLE_4B_COMBI_OR_CPSU_CODES
):
return True
# Table 4a solid-fuel + electric boilers (codes 151-161 / 191-196):
# the spec rule applies to ANY heat generator connected to a cylinder
# via primary pipework — not just Table 4b gas/oil boilers. The
# discriminator is the cert's `water_heating_code`: 901 / 902 / 914
# (HW from main heating) means the back-boiler / electric boiler
# feeds the cylinder through a primary loop and the loss applies.
# WHC=903 (HW from a separate electric immersion) means the cylinder
# isn't on the boiler's primary loop and no loss applies. Cohort
# evidence (1431 corpus, age G, cylinder thermostat lodged):
# - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr → apply
# - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr → apply
# - solid fuel 5 (code 153, WHC=903): ws (59) = 0 → skip
# - solid fuel 4..11 (codes 633/636 non-boiler, WHC=903): skip
if (
code is not None
and _is_wet_boiler_main(main)
and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
):
return True
# SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary
# circuit loss for insulated pipework and cylinderstat should be
# included (see Table 3)." Heat-network mains with WHC=901/902/914
# feed the dwelling-side cylinder via primary pipework from the
# HIU/connection — Table 3 row 1 (heat generator connected to a
# cylinder via primary pipework) applies.
if (
_is_heat_network_main(main)
and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
):
return True
return False
# SAP 10.2 §12.4.4 (PDF p.36-37) — Table 4a back-boiler combos that the
# spec routes through summer immersion. Verbatim spec scope: "open fire
# back boilers or closed room heaters with boilers" → Table 4a codes
# 156 (Open fire with back boiler to radiators) + 158 (Closed room heater
# with boiler to radiators). Range cookers (160, 161), stoves with
# boilers (159), and independent solid-fuel boilers (151, 153, 155) are
# NOT in §12.4.4's list — they run year-round per the spec's preceding
# sentence "Independent boilers that provide domestic hot water usually
# do so throughout the year".
_TABLE_4A_BACK_BOILER_CODES: Final[frozenset[int]] = frozenset({156, 158})
# Summer months Jun-Sep (0-indexed Jan=0 .. Dec=11) per the §12.4.4 rule
# verbatim: "water heating is provided by the boiler for months October
# to May and by the alternative system for months June to September".
_SECTION_12_4_4_SUMMER_MONTH_INDICES: Final[frozenset[int]] = frozenset(
{5, 6, 7, 8}
)
def _section_12_4_4_summer_immersion_applies(
epc: EpcPropertyData, main: Optional[MainHeatingDetail]
) -> bool:
"""SAP 10.2 §12.4.4 (PDF p.36-37): "With open fire back boilers or
closed room heaters with boilers, an alternative system (electric
immersion) may be provided for heating water in summer. In that case
water heating is provided by the boiler for months October to May
and by the alternative system for months June to September."
Applies when:
- main heating is a Table 4a back-boiler combo (SAP code 156 or 158)
- water heating sources from the main heating
(WHC ∈ {901, 902, 914} = "HW from main heating")
- a hot-water cylinder is lodged (the immersion needs a tank)
The Elmhurst P960 worksheet for heating-systems corpus property 001431
SF2 (code 158 + WHC=901 + cylinder thermostat) lodges this
arrangement via §1 "Water Heating" block fields `Immersion Heater
Type: Dual` + `Summer Immersion: Yes` — neither field is surfaced on
the Summary PDF the cascade reads. Per the spec's "may be provided"
permissive language, the rule is applied deterministically when the
main heating SAP code identifies the back-boiler combo, matching
Elmhurst's worksheet output (SF2 (59)m winter = 64.58 [h=5, p=0],
summer Jun-Sep = 0; SF3 code 160 range-cooker boiler with the same
WHC=901 lodging has summer (59)m ≈ 41-43 because §12.4.4 does NOT
apply to range cookers).
"""
if not epc.has_hot_water_cylinder:
return False
if main is None:
return False
if main.sap_main_heating_code not in _TABLE_4A_BACK_BOILER_CODES:
return False
return (
epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
)
# RdSAP 10 §10.11 Table 29 "Heating and hot water parameters" row
# "Solar panel" (p.58) — the spec defaults to use when the cert
# lodges "Solar collector details known: No". Verbatim:
#
# "If solar panel present, the parameters for the calculation not
# provided in the RdSAP data set are:
# - panel aperture area 3 m²
# - flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01
# - facing South, pitch 30°, modest overshading
# - …
# - pump for solar-heated water is electric (75 kWh/year)
# - showers are both electric and non-electric"
#
# Lodged collector orientation / pitch / overshading on the Summary
# §16.0 (when "Are details known? Yes") override the South / 30° /
# Modest defaults. The remaining parameters (aperture, η₀, a₁, a₂)
# always take the Table 29 default unless a separate SAP-style
# detailed lodgement is present (not exposed by the Summary today;
# follow-on slice when the P960 detail extraction lands).
_TABLE_29_APERTURE_M2: Final[float] = 3.0
_TABLE_29_ETA_0: Final[float] = 0.8
_TABLE_29_A1: Final[float] = 4.0
_TABLE_29_A2: Final[float] = 0.01
_TABLE_29_LOOP_EFF: Final[float] = 0.9
_TABLE_29_IAM_FLAT_PLATE: Final[float] = 0.94
_TABLE_29_DEDICATED_SOLAR_STORAGE_L: Final[float] = 75.0
_TABLE_29_DEFAULT_ORIENTATION: Final[Orientation] = Orientation.S
_TABLE_29_DEFAULT_PITCH_DEG: Final[float] = 30.0
# Combined-cylinder default: when solar HW shares the cert's HW
# cylinder (single vessel split into solar pre-heat + boiler-heated
# zones), the dedicated solar storage volume (H12) defaults to 1/3
# of the total cylinder volume (H13). Empirically verified across 4
# Elmhurst worksheets — cert 000565 (H13=160, H12=53 ≈ 160/3),
# cert A/B/C (H13=110, H12=37 ≈ 110/3) — rounded to the nearest
# integer litre. The SAP 10.2 spec p.75 only states the effective-
# volume formula `H14 = H12 + 0.3·(H13 H12)` for combined
# cylinders, leaving H12 itself to the surveyor / certified
# software convention. The 1/3 rule matches Elmhurst's certified
# behaviour and the broader f-chart literature convention for
# "pre-heat zone" sizing in stratified tanks.
_COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION: Final[float] = 1.0 / 3.0
# SAP 10.2 Table H2 (p.78) — overshading factor (H8). RdSAP uses the
# string lodgement on Summary §16.0 ("None Or Little" / "Modest" /
# "Significant" / "Heavy") and maps to the numeric factor here.
_TABLE_H2_OVERSHADING_FACTOR: Final[dict[str, float]] = {
"None Or Little": 1.0,
"Modest": 0.8,
"Significant": 0.65,
"Heavy": 0.5,
}
# SAP 10.2 Appendix U §U3.1 (p.124) Table U1 — monthly average external
# air temperature for region 0 (UK average, Block 1 SAP rating). Used
# by Appendix H (H20)m/(H21)m. The demand-cascade uses postcode-PCDB
# climate instead; this constant is only the SAP-rating fallback.
_APPENDIX_U_REGION_0_EXT_TEMP_C: Final[tuple[float, ...]] = (
4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2,
)
def _solar_hw_monthly_override(
*,
epc: EpcPropertyData,
hw_demand_monthly_kwh: tuple[float, ...],
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 Appendix H — (63c)m / (H24)m solar HW contribution.
Returns None when the cert doesn't lodge solar HW; otherwise calls
the Appendix H orchestrator with RdSAP 10 §10.11 Table 29 defaults
for the parameters the Summary doesn't carry (aperture, η₀, a₁,
a₂, loop efficiency, IAM, dedicated solar storage) and the cert-
lodged collector orientation / pitch / overshading. Falls back to
South / 30° / Modest when the Summary doesn't lodge those either.
Block 1 SAP rating uses region 0 (UK average) per Appendix U §U3.1;
the demand cascade's postcode-climate override is wired in a
follow-on slice.
"""
if not epc.solar_water_heating:
return None
orientation = _orientation_from_summary_string(
epc.solar_hw_collector_orientation
) or _TABLE_29_DEFAULT_ORIENTATION
pitch_deg = (
float(epc.solar_hw_collector_pitch_deg)
if epc.solar_hw_collector_pitch_deg is not None
else _TABLE_29_DEFAULT_PITCH_DEG
)
overshading = _TABLE_H2_OVERSHADING_FACTOR.get(
epc.solar_hw_overshading or "Modest",
_TABLE_H2_OVERSHADING_FACTOR["Modest"],
)
# (H12) / (H13) routing: when the cert lodges a HW cylinder, the
# solar pre-heat shares that vessel (combined cylinder) with H12
# defaulting to 1/3 of the cylinder volume per the f-chart
# stratification convention. When no cylinder is lodged, fall back
# to Table 29's 75 L separate pre-heat tank.
cylinder_volume_l = _hot_water_cylinder_volume_l(epc)
if cylinder_volume_l is not None:
dedicated_solar_storage_l = round(
cylinder_volume_l * _COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION
)
combined_cylinder_l: Optional[float] = cylinder_volume_l
else:
dedicated_solar_storage_l = _TABLE_29_DEDICATED_SOLAR_STORAGE_L
combined_cylinder_l = None
h24_kwh_positive = solar_water_heating_input_monthly_kwh(
collector_orientation=orientation,
collector_pitch_deg=pitch_deg,
region=0,
aperture_area_m2=_TABLE_29_APERTURE_M2,
zero_loss_efficiency=_TABLE_29_ETA_0,
linear_heat_loss_a1=_TABLE_29_A1,
second_order_heat_loss_a2=_TABLE_29_A2,
loop_efficiency=_TABLE_29_LOOP_EFF,
incidence_angle_modifier=_TABLE_29_IAM_FLAT_PLATE,
overshading_factor=overshading,
dedicated_solar_storage_volume_l=dedicated_solar_storage_l,
combined_cylinder_total_volume_l=combined_cylinder_l,
hot_water_demand_monthly_kwh=hw_demand_monthly_kwh,
wwhrs_monthly_kwh=(0.0,) * 12,
cold_water_temperatures_monthly_c=TABLE_J1_TCOLD_FROM_MAINS_C,
external_temperatures_monthly_c=_APPENDIX_U_REGION_0_EXT_TEMP_C,
solar_hot_water_only=True,
)
# SAP 10.2 §4 line (64)m sign convention: heat displaced from the
# boiler is entered NEGATIVE (so the line sums to delivered HW).
# The Appendix H orchestrator returns positive (H24)m kWh of solar
# contribution; negate at the boundary.
return tuple(-v for v in h24_kwh_positive)
# Compass strings as lodged on the Summary §16.0 "Collector orientation"
# row. SAP 10.2 §6 ORIENTATION_BY_SAP10_CODE indexes by integer code;
# this dict maps the surveyor-typed strings.
_SUMMARY_ORIENTATION_BY_STRING: Final[dict[str, Orientation]] = {
"North": Orientation.N,
"North East": Orientation.NE,
"NE": Orientation.NE,
"East": Orientation.E,
"South East": Orientation.SE,
"SE": Orientation.SE,
"South": Orientation.S,
"South West": Orientation.SW,
"SW": Orientation.SW,
"West": Orientation.W,
"North West": Orientation.NW,
"NW": Orientation.NW,
}
def _orientation_from_summary_string(raw: Optional[str]) -> Optional[Orientation]:
"""Look up a §16.0 / §19.0 compass-string lodgement against
`_SUMMARY_ORIENTATION_BY_STRING`. Returns None when absent.
"""
if raw is None:
return None
return _SUMMARY_ORIENTATION_BY_STRING.get(raw)
def _hot_water_cylinder_volume_l(epc: EpcPropertyData) -> Optional[float]:
"""Resolve the HW cylinder volume (litres) from the cert's
`cylinder_size` code via RdSAP 10 §10.5 Table 28. Returns None
when no cylinder is lodged or the size code falls outside the
cohort-observed range (codes 2-4 → Normal / Medium / Large)."""
if not epc.has_hot_water_cylinder:
return None
size_code = _int_or_none(epc.sap_heating.cylinder_size)
if size_code is None:
return None
return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool:
"""Gate for the Table 3a keep-hot 600 kWh/yr fall-through per SAP 10.2
§4 line 7702. Returns True only when the main heating system is in the
boiler family or a community heat network — outside that set the spec's
"enter '0' if not a combi boiler" rule fires and the cascade must zero
(61)m.
The `main_heating_category` route covers the Open EPC API path where
the cert lodges a SAP 10.2 boiler / heat-network category integer.
The `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-through covers the Elmhurst-
path case where the mapper leaves `main_heating_category=None` but
the cert lodges a Table 4b combi sub-row directly (oil 3 / oil 4 in
heating-systems corpus 001431 — SAP codes 128 / 129 "Combi oil
boiler, pre-/post-1998", FAME fuel — Elmhurst's mapper artifact
leaves the category unset).
"""
if main is None:
return False
if main.main_heating_category in _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES:
return True
code = main.sap_main_heating_code
if isinstance(code, int) and code in _TABLE_4B_COMBI_OR_CPSU_CODES:
return True
return False
def _water_heating_worksheet_and_gains(
*,
epc: EpcPropertyData,
water_efficiency_pct: float,
is_instantaneous: bool,
primary_age: Optional[str],
pcdb_record: Optional[GasOilBoilerRecord],
) -> tuple[Optional[WaterHeatingResult], tuple[float, ...]]:
"""SAP10.2 §4 worksheet — run (45..65) and return (`wh_result`,
`heat_gains_monthly_kwh`) for downstream §5/§7/§8. HW fuel kWh is
deferred to after §8 produces (98c)m (Equation D1 needs both).
Returns (None, zero-tuple) when TFA is missing — the legacy
`predicted_hot_water_kwh` fallback fires later in the caller and
bypasses the worksheet path entirely."""
zero_monthly = (0.0,) * 12
if epc.total_floor_area_m2 is None:
return None, zero_monthly
has_electric_shower = _has_electric_shower_from_cert(epc)
electric_shower_count = _electric_shower_count_from_cert(epc)
bootstrap = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
combi_loss_override = pcdb_combi_loss_override(
pcdb_record,
energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
)
main = _first_main_heating(epc)
# SAP 10.2 §4 line 7702 (PDF p.137): "Combi loss for each month
# from Table 3a, 3b or 3c (enter '0' if not a combi boiler)". The
# SAP 10.2 Table 3 zero-loss list (PDF p.160) defines a combi boiler
# by its instantaneous-DHW operation: combis don't feed a cylinder
# because their heat exchanger heats DHW on demand. A lodged hot-
# water cylinder therefore means the heat generator is NOT a combi
# — even when the cert lodges a PCDB Table 105 record that would
# otherwise route through `pcdb_combi_loss_override` to a Table 3a/
# 3b/3c row. Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder)
# exposes this: pre-slice the cascade applied Table 3a row 1
# 600 kWh/yr "keep-hot" loss to a PCDB regular oil boiler.
if epc.has_hot_water_cylinder:
combi_loss_override = zero_monthly
elif combi_loss_override is None and not _table_3a_combi_loss_default_applies(
main
):
# SAP 10.2 §4 line 7702 fallback: non-combi main heating → (61)m
# = 0. Without this gate the cascade falls through to `combi_
# loss_monthly_kwh_table_3a_keep_hot_time_clock()` (600 kWh/yr)
# on every cert lacking a PCDB Table 105 boiler record —
# including all heat pump certs.
combi_loss_override = zero_monthly
# SAP 10.2 §4 lines 7670-7693 + Tables 2/2a/2b — cylinder storage loss
# (56)m. Spec p.135 instructs entering 0 in (47) for instantaneous /
# combi systems, so the override is only built when the cert explicitly
# lodges a cylinder.
storage_loss_override = _cylinder_storage_loss_override(epc, main)
# SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss
# (59)m. Only fires for indirect cylinders; HPs with integral
# vessels and combi boilers are in the spec's zero list. The gate
# keys off the *DHW* main (`_water_heating_main`) so WHC 914 ("from
# second main system") routes the primary-loss eligibility check
# to the heat generator that actually feeds the cylinder.
primary_loss_override = _primary_loss_override(epc, primary_age)
# SAP 10.2 Appendix H — solar HW contribution (63c)m. Only fires
# when the cert lodges solar HW; orchestrator drives off lodged
# collector geometry + RdSAP 10 §10.11 Table 29 defaults for
# parameters the Summary doesn't carry (aperture, η₀, a₁, a₂,
# IAM, storage). See `_solar_hw_monthly_override` for the spec
# breakdown. The orchestrator's (H17)m = (62)m must include the
# storage / primary / combi losses, so we re-run the cascade
# *without* solar to land (62)m before sizing the solar credit.
demand_pass = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
solar_storage_monthly_kwh_override=storage_loss_override,
primary_loss_monthly_kwh_override=primary_loss_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
solar_hw_override = _solar_hw_monthly_override(
epc=epc,
hw_demand_monthly_kwh=demand_pass.total_demand_monthly_kwh,
)
wh_result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
solar_storage_monthly_kwh_override=storage_loss_override,
primary_loss_monthly_kwh_override=primary_loss_override,
solar_water_heating_monthly_kwh_override=solar_hw_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
return wh_result, wh_result.heat_gains_monthly_kwh
def _primary_loss_override(
epc: EpcPropertyData,
primary_age: Optional[str],
) -> Optional[tuple[float, ...]]:
"""Resolve (59)m for `water_heating_from_cert` from the cert + PCDB
Table 362 record (for HP mains). Returns None when primary loss does
not apply (combi boiler, integral-vessel HP, no cylinder, etc.) so
the cascade keeps its zero default. Pipework insulation fraction p
comes from RdSAP §3 age-band default (no API field); circulation
hours h come from Table 3 keyed on cylinder thermostat + separately-
timed-DHW lodgement.
The gate keys off the DHW main resolved via `_water_heating_main`
(the WHC-914 "from second main system" routing) rather than
`_first_main_heating`. SAP 10.2 §4 line 7700 + Table 3 (PDF p.159)
define primary loss as the loss between the *heat generator that
heats the water* and the storage vessel — so the eligibility check
must follow the DHW routing. Cert 000565 (ASHP Main 1 + gas combi
Main 2 + WHC 914 + 160 L cylinder) is the cohort case: Main 1's
HP record is irrelevant; Main 2's combi feeds the cylinder via
primary pipework and incurs the loss.
"""
main = _water_heating_main(epc)
cylinder_present = bool(epc.has_hot_water_cylinder)
hp_record: Optional[HeatPumpRecord] = None
if main is not None and main.main_heating_index_number is not None:
hp_record = heat_pump_record(main.main_heating_index_number)
if not _primary_loss_applies(
main,
cylinder_present,
hp_record,
water_heating_code=epc.sap_heating.water_heating_code,
):
return None
# SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482) pins community-
# heating primary pipework to "insulated" (p=1.0), overriding the
# RdSAP §3 age-band default which would otherwise return 0 for
# pre-2007 stock. See `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`.
pipework_p = (
_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION
if _is_heat_network_main(main)
else _pipework_insulation_fraction_table_3(primary_age)
)
base = primary_loss_monthly_kwh(
pipework_insulation_fraction=pipework_p,
has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y",
separately_timed_dhw=_separately_timed_dhw(epc, main),
)
# SAP 10.2 §12.4.4 (PDF p.36-37): for back-boiler combos summer DHW
# comes from an electric immersion, not from the boiler — the boiler
# primary circuit is not running Jun-Sep so (59)m = 0 for those four
# months. Winter (Oct-May) (59)m keeps the Table 3 row applicable
# to the boiler.
if _section_12_4_4_summer_immersion_applies(epc, main):
return tuple(
0.0 if i in _SECTION_12_4_4_SUMMER_MONTH_INDICES else v
for i, v in enumerate(base)
)
return base
def _cylinder_storage_loss_override(
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
) -> Optional[tuple[float, ...]]:
"""Resolve (57)m for `water_heating_from_cert` from the cert's lodged
cylinder fields. Returns None when no cylinder is lodged so the
cascade keeps its existing zero-storage-loss default for combi /
instantaneous systems.
SAP 10.2 §4 line 7693 (PDF p.137):
If the vessel contains dedicated solar storage or dedicated
WWHRS storage,
(57)m = (56)m × [(47) - Vs] ÷ (47), else (57)m = (56)m
where Vs is Vww from Appendix G3 or (H12) from Appendix H.
`water_heating_from_cert` feeds the override straight into (62)m
via `solar_storage_monthly_kwh`, so the helper returns the (57)m
series (solar-adjusted when applicable), not raw (56)m. Vs derives
from the same combined-cylinder ⅓-volume convention used by
`_solar_hw_monthly_override` per S0380.76.
"""
if not epc.has_hot_water_cylinder:
return None
sh = epc.sap_heating
size_code = _int_or_none(sh.cylinder_size)
if size_code is None:
return None
volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
if volume_l is None:
return None
if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY:
return None
thickness_mm = sh.cylinder_insulation_thickness_mm
if thickness_mm is None:
return None
storage_56m = cylinder_storage_loss_monthly_kwh(
volume_l=volume_l,
insulation_type="factory_insulated",
thickness_mm=float(thickness_mm),
has_cylinder_thermostat=sh.cylinder_thermostat == "Y",
# SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the
# ×0.9 multiplier to boiler / warm-air / heat-pump systems —
# community heating excluded. Gate via the dedicated helper so
# the storage-loss call site stays decoupled from Table 3's
# primary-loss `_separately_timed_dhw` (which still fires for
# community heating + cylinder → h=3 all year).
separately_timed_dhw=_table_2b_note_b_multiplier_applies(epc, main),
)
# (57)m solar adjustment when solar HW + dedicated solar storage
# share the cylinder. Vs follows the combined-cylinder convention.
if not epc.solar_water_heating:
return storage_56m
vs_l = round(volume_l * _COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION)
factor = (volume_l - vs_l) / volume_l
return tuple(s * factor for s in storage_56m)
def _apply_water_efficiency(
*,
wh_output_monthly_kwh: tuple[float, ...],
wh_output_annual_kwh: float,
water_efficiency_pct: float,
eq_d1_winter_summer_pct: Optional[tuple[float, float]],
space_heating_monthly_useful_kwh: tuple[float, ...],
interlock_penalty_pp: float = 0.0,
) -> float:
"""Divide §4 (64)m by the appropriate efficiency to land HW fuel kWh.
When (winter, summer) seasonal efficiencies are provided — either
from a PCDB Table 105 record OR from the SAP 10.2 Table 4b non-PCDB
fallback (`tables.table_4b.table_4b_seasonal_efficiencies_pct`) —
use the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade.
Otherwise stay on the legacy scalar `water_efficiency_pct` divisor
(single-value PCDB summer eff, Table 4a inherit, etc.).
`interlock_penalty_pp` is the SAP 10.2 §9.4.11 (PDF p.30) "Boiler
interlock" -5pp reduction (or 0 when the boiler IS interlocked).
Pre-S0380.165 the caller subtracted the penalty from the (winter,
summer) PCDB efficiencies BEFORE passing them in. The Elmhurst P960
worksheet for pcdb 1 (PCDB 716, Pwinter 65 / Psummer 53, Cylinder
Stat=No → no interlock) shows the -5pp applied to the η_water,
monthly OUTPUT of Eq D1, NOT to its inputs — the two interpretations
diverge because Eq D1 weights `1/η_winter` and `1/η_summer`
reciprocally and the penalty does not commute with the reciprocal
interp. The helper now takes the raw seasonal efficiencies + the
penalty separately, runs Eq D1 on the raw inputs, then subtracts
`interlock_penalty_pp / 100` from each monthly eff before dividing.
Matches worksheet (217)m for pcdb 1 to 1e-4 across all 12 months."""
if water_efficiency_pct <= 0:
return 0.0
if eq_d1_winter_summer_pct is not None:
winter_pct, summer_pct = eq_d1_winter_summer_pct
monthly_eff = water_efficiency_monthly_via_equation_d1(
winter_efficiency_pct=winter_pct,
summer_efficiency_pct=summer_pct,
space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh,
water_heating_output_monthly_kwh=wh_output_monthly_kwh,
)
penalty_frac = interlock_penalty_pp / 100.0
return sum(
output / max(eff - penalty_frac, 1e-9) if eff > 0 else 0.0
for output, eff in zip(wh_output_monthly_kwh, monthly_eff)
)
return wh_output_annual_kwh / water_efficiency_pct
# SAP 10.2 §12.4.4 summer-immersion constants. Per Table 13 (PDF p.197)
# the dual-immersion 18-hour tariff has 100% low-rate consumption (the
# 6.8 - 0.036V × N - 0.105V formula falls below zero for normal V/N
# combos, so the spec clamps to zero high-rate fraction). The Elmhurst
# P960 worksheet for SF2 (TFA 90 m², 110 L cylinder, 18-hour) bills the
# 684 kWh summer immersion entirely at the 18-hour low rate (Table 32
# code 40 = 7.41 p/kWh) — matching `_off_peak_low_rate_gbp_per_kwh`.
_SECTION_12_4_4_IMMERSION_EFFICIENCY_PCT: Final[float] = 100.0
# Table 12d / 12e monthly factor code for "standard electricity" — the
# dual immersion bills as a regular electric end-use in the cascade.
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12: Final[int] = 30
# Table 32 standing-charge owner code for the off-peak electric tariff.
# Mirror of `_OFF_PEAK_STANDING_CODE` in table_32.py — kept local to
# avoid importing a private mapping. Restricted to EIGHTEEN_HOUR / 7h /
# 10h / 24h in scope (STANDARD tariff path returns 0).
_SECTION_12_4_4_OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = {
Tariff.SEVEN_HOUR: 32,
Tariff.TEN_HOUR: 34,
Tariff.EIGHTEEN_HOUR: 38,
Tariff.TWENTY_FOUR_HOUR: 35,
}
def _section_12_4_4_hw_blend(
*,
wh_output_monthly_kwh: tuple[float, ...],
boiler_efficiency_pct: float,
boiler_fuel_code: Optional[int],
tariff: Tariff,
prices: PriceTable,
) -> tuple[float, float, float, float, float]:
"""SAP 10.2 §12.4.4 (PDF p.36-37) HW fuel-split blend for back-boiler
combos. Returns the 5-tuple:
(annual_hw_fuel_kwh, cost_gbp_per_kwh, co2_factor_kg_per_kwh,
primary_factor, extra_standing_charge_gbp)
where each of the rates is the kWh-weighted blend of the two fuels
feeding the cylinder: the boiler fuel (winter, Oct-May, at the
boiler efficiency) and the electric immersion (summer, Jun-Sep, at
100% efficiency).
Worksheet evidence for property 001431 SF2 (Table 4a code 158
closed-room-heater + back-boiler at 65% efficiency, anthracite,
18-hour tariff): annual (62) heat = 2890.35 kWh splits as 2205.80
winter / 684.55 summer. Winter fuel = 3393.5 anthracite kWh /
summer fuel = 684.55 electric kWh, total (219) = 4078.06 kWh.
Blended cost (anthr 3.64 + elec-low 7.41 p/kWh) = 4.27 p/kWh × 4078
= £174.25 (247). Off-peak electric standing charge £40 added at
(251).
"""
winter_heat = sum(
kwh for i, kwh in enumerate(wh_output_monthly_kwh)
if i not in _SECTION_12_4_4_SUMMER_MONTH_INDICES
)
summer_heat = sum(
kwh for i, kwh in enumerate(wh_output_monthly_kwh)
if i in _SECTION_12_4_4_SUMMER_MONTH_INDICES
)
if boiler_efficiency_pct <= 0:
return 0.0, 0.0, 0.0, 0.0, 0.0
winter_fuel = winter_heat / (boiler_efficiency_pct / 100.0)
summer_fuel = summer_heat / (
_SECTION_12_4_4_IMMERSION_EFFICIENCY_PCT / 100.0
)
total_fuel = winter_fuel + summer_fuel
if total_fuel <= 0:
return 0.0, 0.0, 0.0, 0.0, 0.0
# Cost: boiler fuel at its Table 32 unit price (winter) + electric
# at the tariff's low rate (summer). For SF2 18-hour: Table 32 code
# 40 = 7.41 p/kWh per Table 13's "100% low rate" clamp for normal
# V/N combos.
if boiler_fuel_code is None:
boiler_p_per_kwh = 0.0
else:
boiler_p_per_kwh = prices.unit_price_p_per_kwh(boiler_fuel_code)
summer_gbp_per_kwh = (
_off_peak_low_rate_gbp_per_kwh(tariff) if tariff is not Tariff.STANDARD
else prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
)
blended_cost_gbp_per_kwh = (
winter_fuel * boiler_p_per_kwh * _PENCE_TO_GBP
+ summer_fuel * summer_gbp_per_kwh
) / total_fuel
# CO2: boiler fuel at its Table 12 annual factor (winter) + electric
# at the summer-month-weighted Table 12d cascade (per Table 12d
# header — "monthly factors instead the annual average"). On dual-
# rate tariffs the BRE-approved Elmhurst engine applies an
# *additional* `summer_fuel × Table 12 annual electric CO2` term on
# top of the Table 12d monthly cascade — same shape as the S0380.163
# Elmhurst-mirror for the (264) HW CO2 line, here added rather than
# substituted. See SAP_CALCULATOR.md §8.2 for the single-cert
# worksheet evidence (SF2 (264) factor 0.371 = W×0.395 + S×(0.116
# monthly_summer + 0.136 annual) / total). STANDARD tariff keeps the
# spec-literal monthly-only cascade.
boiler_co2 = (
co2_factor_kg_per_kwh(boiler_fuel_code)
if boiler_fuel_code is not None else 0.0
)
elec_co2_monthly = co2_monthly_factors_kg_per_kwh(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_co2_kg_monthly = (
sum(
wh_output_monthly_kwh[i] * elec_co2_monthly[i]
for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES
)
if elec_co2_monthly is not None else 0.0
)
elec_co2_annual = co2_factor_kg_per_kwh(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_co2_kg_annual_mirror = (
summer_fuel * elec_co2_annual if tariff is not Tariff.STANDARD else 0.0
)
blended_co2 = (
winter_fuel * boiler_co2
+ summer_co2_kg_monthly
+ summer_co2_kg_annual_mirror
) / total_fuel
# PE: same shape (Table 12e monthly cascade for summer electric)
# with the same Elmhurst-mirror `summer_fuel × Table 12 annual` term
# on dual-rate tariffs. SF2 (278) factor 1.3771 = W×1.064 + S×(1.429
# monthly_summer + 1.501 annual) / total.
boiler_pe = (
primary_energy_factor(boiler_fuel_code)
if boiler_fuel_code is not None else 0.0
)
elec_pe_monthly = pe_monthly_factors_kwh_per_kwh(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_pe_kwh_monthly = (
sum(
wh_output_monthly_kwh[i] * elec_pe_monthly[i]
for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES
)
if elec_pe_monthly is not None else 0.0
)
elec_pe_annual = primary_energy_factor(
_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12
)
summer_pe_kwh_annual_mirror = (
summer_fuel * elec_pe_annual if tariff is not Tariff.STANDARD else 0.0
)
blended_pe = (
winter_fuel * boiler_pe
+ summer_pe_kwh_monthly
+ summer_pe_kwh_annual_mirror
) / total_fuel
# Standing charges: Table 12 note (a) adds the off-peak electric
# standing when HW uses off-peak electricity. The §12.4.4 summer
# immersion uses the off-peak low rate so the standing charge fires
# for any off-peak tariff. `additional_standing_charges_gbp` is
# called separately by the caller with the cert's lodged water-
# heating fuel code (anthracite) — it would miss this gate. Return
# the extra to add explicitly.
standing_code = _SECTION_12_4_4_OFF_PEAK_STANDING_CODE.get(tariff)
extra_standing = (
standing_charge_gbp(standing_code) if standing_code is not None else 0.0
)
return (
total_fuel,
blended_cost_gbp_per_kwh,
blended_co2,
blended_pe,
extra_standing,
)
# Sentinel zero FuelCostResult — returned from `_fuel_cost` on off-peak
# tariff certs so the calculator's slice-2c fallback branch fires and the
# legacy scalar-field cost math runs unchanged. Carries STANDARD-style
# fractions (high=1.0, low=0.0) for worksheet-shape parity.
_ZERO_FUEL_COST_FOR_OFF_PEAK: 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,
)
def _fuel_cost(
*,
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
energy_requirements_result: EnergyRequirementsResult,
hot_water_kwh: float,
pumps_fans_kwh: float,
lighting_kwh: float,
cooling_kwh: float,
climate: "int | PostcodeClimate",
prices: PriceTable,
pv_dwelling_kwh_per_yr: Optional[float],
pv_exported_kwh_per_yr: Optional[float],
electric_shower_kwh: float = 0.0,
) -> FuelCostResult:
"""SAP10.2 §10a fuel-cost precompute — produce a `FuelCostResult` from
the cert + the §9a `energy_requirements_result`. RdSAP10 target per
ADR-0010 amendment: Table 32 prices, Table 12a high-rate fractions,
Table 32 note (a) standing-charge gating.
Off-peak path raises until first off-peak fixture lands (scope A is
standard-tariff gas dwellings only). The `tariff != STANDARD` branch
is the natural extension point for the Table 12a `_SH_HIGH_RATE_
FRACTION` lookup + `Table12aSystem` mapping (deferred per slice 3
docs `Q11` follow-ups)."""
meter_type = epc.sap_energy_source.meter_type
tariff = tariff_from_meter_type(meter_type)
if tariff is not Tariff.STANDARD:
# Off-peak path defers to the legacy scalar fuel-cost fields on
# CalculatorInputs (the pre-§10a `_space_heating_fuel_cost_gbp_
# per_kwh` / `_hot_water_fuel_cost_gbp_per_kwh` / `_other_fuel_
# cost_gbp_per_kwh` helpers). Returning the zero sentinel makes
# the calculator's slice-2c fallback branch fire. Table 12a
# high-rate-fraction split + Table12aSystem mapping is the next
# slice of §10a after §4 HW tightening — see slice 3 deferred.
return _ZERO_FUEL_COST_FOR_OFF_PEAK
main_fuel_code = _main_fuel_code(main)
water_heating_fuel_code = _water_heating_fuel_code(epc)
# Std electricity for all single-row end-uses (pumps/fans, lighting,
# cooling). Table 32 code 30.
other_uses_p_per_kwh = table_32_unit_price_p_per_kwh(30)
other_uses_gbp_per_kwh = other_uses_p_per_kwh * _PENCE_TO_GBP
main_1_high_rate_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(main_fuel_code) * _PENCE_TO_GBP
)
water_high_rate_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(water_heating_fuel_code)
* _PENCE_TO_GBP
)
# Secondary fuel cost: route through the cert's `secondary_fuel_type`
# when lodged (e.g. mains-gas fire SAP code 605 → fuel 26 → Table 32
# gas price), otherwise default to standard electricity (the portable
# electric heater per §A.2.2 — same as the cohort's electric panel
# SAP code 691). Pre-slice this column hardcoded `other_uses_gbp_per_
# kwh` regardless of fuel type, charging gas-secondary kWh at the
# electric tariff and dropping ~£175/yr from the ECF on cert 001479.
secondary_fuel = (
epc.sap_heating.secondary_fuel_type if epc.sap_heating else None
)
secondary_high_rate_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(secondary_fuel) * _PENCE_TO_GBP
if secondary_fuel is not None
else other_uses_gbp_per_kwh
)
# Table 32 PV export credit (code 60 = 13.19 p/kWh, same as std
# electricity under RdSAP10 amendment).
pv_export_credit_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(60) * _PENCE_TO_GBP
)
standing = additional_standing_charges_gbp(
main_fuel_code=main_fuel_code,
water_heating_fuel_code=water_heating_fuel_code,
tariff=tariff,
)
# Worksheet display convention: when a row's kWh is zero (no main 2, no
# secondary system, etc.) the PDF reports the (high-rate fraction)
# column as 0 rather than 1. Cost columns already collapse to 0 via
# multiplication, so this is presentation-only.
main_2_kwh = energy_requirements_result.main_2_fuel_kwh_per_yr
secondary_kwh = energy_requirements_result.secondary_fuel_kwh_per_yr
return fuel_cost(
main_1_kwh_per_yr=energy_requirements_result.main_1_fuel_kwh_per_yr,
main_1_high_rate_gbp_per_kwh=main_1_high_rate_gbp_per_kwh,
main_1_low_rate_gbp_per_kwh=0.0,
main_1_high_rate_fraction=1.0,
main_2_kwh_per_yr=main_2_kwh,
main_2_high_rate_gbp_per_kwh=main_1_high_rate_gbp_per_kwh,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0 if main_2_kwh > 0.0 else 0.0,
secondary_kwh_per_yr=secondary_kwh,
secondary_high_rate_gbp_per_kwh=secondary_high_rate_gbp_per_kwh,
secondary_low_rate_gbp_per_kwh=0.0,
secondary_high_rate_fraction=1.0 if secondary_kwh > 0.0 else 0.0,
hot_water_kwh_per_yr=hot_water_kwh,
hot_water_high_rate_gbp_per_kwh=water_high_rate_gbp_per_kwh,
hot_water_low_rate_gbp_per_kwh=0.0,
hot_water_high_rate_fraction=1.0,
pumps_fans_kwh_per_yr=pumps_fans_kwh,
lighting_kwh_per_yr=lighting_kwh,
cooling_kwh_per_yr=cooling_kwh,
other_uses_gbp_per_kwh=other_uses_gbp_per_kwh,
instant_shower_kwh_per_yr=electric_shower_kwh,
instant_shower_gbp_per_kwh=other_uses_gbp_per_kwh,
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate),
pv_export_credit_gbp_per_kwh=pv_export_credit_gbp_per_kwh,
additional_standing_charges_gbp=standing,
appendix_q_saved_gbp=0.0,
appendix_q_used_gbp=0.0,
# SAP 10.2 Appendix M1 §6 (p.94): split the PV credit per the β-
# factor — onsite kWh bills at the dwelling IMPORT tariff (Table
# 12a standard / off-peak low), exported kWh keeps the EXPORT
# tariff (Table 32 code 60). None fall-through preserves the
# legacy single-rate path for synthetic test constructions.
pv_dwelling_kwh_per_yr=pv_dwelling_kwh_per_yr,
pv_exported_kwh_per_yr=pv_exported_kwh_per_yr,
pv_dwelling_import_price_gbp_per_kwh=(
_pv_dwelling_import_price_gbp_per_kwh(
epc.sap_energy_source.meter_type, prices
)
),
)
def cert_to_inputs(
epc: EpcPropertyData,
*,
prices: PriceTable = SAP_10_2_SPEC_PRICES,
postcode_climate: Optional[PostcodeClimate] = None,
) -> CalculatorInputs:
"""Build a typed `CalculatorInputs` aggregate from an `EpcPropertyData`.
`prices` defaults to `SAP_10_2_SPEC_PRICES` (SAP 10.2, 14-03-2025
amendment) and is the only price set the codebase uses, per
ADR-0010. The historical cert-calibration price table was deleted
in P2.3 (commit log) once its bug-masking role was understood;
parity validation now relies on the Validation Cohort filter
(inspection_date ≥ 2025-07-01) rather than a per-cert price
override."""
# RdSAP 10 §10.7 (PDF p.55) — substitute the electric-immersion +
# default-cylinder assumption before any section cascade runs when no
# water heating system is lodged (code 999). Rebinding `epc` here
# means every downstream helper sees the spec default; the demand
# cascade reuses this entry point so it is covered too.
epc = _apply_rdsap_no_water_heating_system_default(epc)
dim = dimensions_from_cert(epc)
# SAP §3 heat transmission + §2 ventilation cascades — see the
# respective `_from_cert` helpers for cert→inputs mapping rules.
ht = heat_transmission_section_from_cert(epc)
ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None
main_fuel = _main_fuel_code(main)
# SAP 10.2 Table 4f (p.174) — Main 1 circulation pump (per
# `central_heating_pump_age`) + Main 1 gas-boiler flue fan (45
# kWh when fan_flue_present + gas fuel) + Main 1 warm-air heating
# fans (SFP × 0.4 × V for Cat 5 / Cat 9 warm-air mains, suppressed
# under balanced MV per footnote e). HP wet mains (cat 4) return 0
# for the circulation-pump branch. Additive components add MEV,
# Main 2 flue fan, solar HW pump, and Main 1/2 liquid fuel boiler
# aux (100 kWh).
pumps_fans_kwh = (
_table_4f_circulation_pump_kwh(main)
+ _table_4f_main_1_gas_boiler_flue_fan_kwh(main)
+ _table_4f_warm_air_heating_fans_kwh(
main=main,
dwelling_volume_m3=dim.volume_m3,
has_balanced_mv=_has_balanced_mechanical_ventilation(epc),
)
)
pumps_fans_kwh += _table_4f_additive_components(epc)
# Track the MEV/MVHR-fan portion separately so the cost cascade can
# apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on
# 10-hour) instead of `ALL_OTHER_USES` (0.80) — see
# `_pumps_fans_fuel_cost_gbp_per_kwh`. Zero when no MEV is lodged.
mev_kwh_for_cost_split = _mev_decentralised_kwh_per_yr_from_cert(epc)
primary_age = (
epc.sap_building_parts[0].construction_age_band if epc.sap_building_parts else None
)
# SAP 10.2 Appendix D2.1: if the cert lodges a PCDB index number that
# resolves to a Table 105 (gas/oil boilers) record, the PCDB winter
# seasonal efficiency overrides the Table 4a/4b category default. The
# PCDB summer efficiency overrides the Table 4a water-heating default
# (scalar — equation D1 monthly cascade deferred per Q5 grilling).
# Heat-network DLF override (below) still applies regardless.
pcdb_main = (
gas_oil_boiler_record(main.main_heating_index_number)
if main is not None and main.main_heating_index_number is not None
else None
)
pcdb_hp_record = (
heat_pump_record(main.main_heating_index_number)
if main is not None and main.main_heating_index_number is not None
else None
)
# Heat-network override (Table 12 note (k)) sets efficiency = 1/DLF so
# `main_fuel_kwh = q_useful × DLF = q_generated`, matching the spec's
# "unit prices per kWh of heat generated" convention.
eff = _main_heating_efficiency(epc)
# Water-heating efficiency reads from the main that ACTUALLY services
# DHW per the cert's `water_heating_code` routing (Elmhurst WHC 914
# = "from second main system" → Main 2). For single-main certs and
# WHC 901/902 this resolves to Main 1, matching the prior behaviour.
water_main = _water_heating_main(epc)
water_pcdb_main = (
gas_oil_boiler_record(water_main.main_heating_index_number)
if water_main is not None and water_main.main_heating_index_number is not None
else None
)
if water_pcdb_main is not None and water_pcdb_main.summer_efficiency_pct is not None:
water_eff = water_pcdb_main.summer_efficiency_pct / 100.0
else:
water_eff = _water_efficiency_with_category_inherit(
water_heating_code=epc.sap_heating.water_heating_code,
main_code=water_main.sap_main_heating_code if water_main is not None else None,
main_category=water_main.main_heating_category if water_main is not None else None,
main_fuel=_main_fuel_code(water_main),
)
# SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": "For the purposes
# of the SAP, an interlocked system is one in which both the space
# and stored water heating are interlocked. If either is not, the
# 5% seasonal efficiency reduction is applied to BOTH space and
# water heating; if both are interlocked no reductions are made."
# Table 4c (PDF p.169-170) row "No boiler interlock — regular
# boiler" lodges -5 for both Space and DHW columns. Table 4c
# Note c): "These do not accumulate as no thermostatic control or
# presence of a bypass means that there is no boiler interlock."
#
# RdSAP §3 (PDF p.57) defines boiler interlock as "Assumed present
# if there is a room thermostat and (for stored hot water systems
# heated by the boiler) a cylinder thermostat. Otherwise not
# interlocked." A combi-fed cylinder routes the boiler as a
# regular boiler for the DHW circuit (the combi's instantaneous-
# DHW capability is bypassed), so the regular-boiler row applies.
#
# The DHW path adjusts (a) the `water_eff` scalar fallback and
# (b) the PCDB winter/summer efficiencies fed into the Equation D1
# monthly cascade so worksheet (217)m matches (e.g. pcdb 1: PCDB
# 716 winter 65, summer 53 → 60, 48). The SH path adjusts `eff`
# only when the SH main is itself a PCDB gas/oil boiler — §9.4.11
# only applies to "gas and liquid fuel boilers", so cert 000565
# (ASHP Main 1) keeps its raw SH eff. Cert pcdb 1 (PCDB 716 + 110 L
# cylinder + Cylinder Stat: No) closes 65% → 60% — matches
# worksheet (210) exactly. Cert 000565 closes WH 79% → 74%
# unchanged from S0380.79.
# RdSAP 10 §3 (PDF p.57): a gas/liquid-fuel boiler is interlocked iff
# it has BOTH a room thermostat AND (for stored hot water) a cylinder
# thermostat. Two independent ways to lose interlock:
# (a) no room thermostat — control code 2101 / 2102 (Table 4e
# Group 1 "no thermostatic control of room temperature"), e.g.
# oil 6 (B30K, code 2101; P960 header "Boiler Interlock: No"
# despite "Cylinder Stat: Yes");
# (b) stored HW from the boiler with no cylinder thermostat.
# Either triggers the Table 4c(2) (PDF p.169) -5pp seasonal-
# efficiency adjustment. The DHW leg is additionally gated on a
# cylinder being present (regular boiler — Table 4c(2) "no
# thermostatic control / no interlock combi" takes DHW 0).
no_room_thermostat = (
main is not None
and main.main_heating_control
in _BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES
)
no_stored_hw_interlock = (
epc.has_hot_water_cylinder
and epc.sap_heating.cylinder_thermostat != "Y"
)
no_interlock = no_room_thermostat or no_stored_hw_interlock
if (
no_interlock
and water_pcdb_main is not None
and epc.has_hot_water_cylinder
):
water_eff -= 0.05
# Resolve the (winter, summer) seasonal efficiency pair that feeds
# the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade.
# Priority order:
# 1. PCDB Table 105 record on the SH main (gas/oil boiler) —
# `pcdb_main.{winter,summer}_efficiency_pct` are spec-derived.
# 2. SAP 10.2 Table 4b (PDF p.168) non-PCDB fallback when the
# cert's `sap_main_heating_code` is in the 101-141 boiler
# range AND the DHW is from the main (WHC 901). Eq D1 only
# applies when "the boiler provides both space and water
# heating" per spec — WHC 901 is the cert form of that.
# Codes on the Table 3 zero-loss list (combi, CPSU) get no
# primary loss but ARE still eligible for Eq D1 — the spec's
# §D2.1 (2) test is "summer < winter" + "boiler provides both",
# not the primary-loss test.
eq_d1_winter_summer_pct: Optional[tuple[float, float]] = None
if (
pcdb_main is not None
and pcdb_main.winter_efficiency_pct is not None
and pcdb_main.summer_efficiency_pct is not None
):
eq_d1_winter_summer_pct = (
pcdb_main.winter_efficiency_pct,
pcdb_main.summer_efficiency_pct,
)
elif (
pcdb_main is None
and main is not None
and epc.sap_heating.water_heating_code == _WHC_FROM_MAIN_HEATING
):
# Non-PCDB Table 4b boiler + DHW from main. SAP 10.2 Appendix D
# §D2.1 (2) applies whenever "the boiler provides both space
# and water heating" — combi (no cylinder) and regular (with
# cylinder) alike. Spec text doesn't gate on cylinder presence.
eq_d1_winter_summer_pct = table_4b_seasonal_efficiencies_pct(
main.sap_main_heating_code
)
# Space leg of the Table 4c(2) adjustment — applies to PCDB-record
# boilers AND Table 4b non-PCDB boilers (code 101-141), regular and
# combi alike (both take Space -5). oil 6 (Table 4b code 126, pcdb_
# main None) reaches the penalty only via the Table 4b branch.
if no_interlock and (
pcdb_main is not None
or (
main is not None
and main.sap_main_heating_code in _TABLE_4B_CODE_RANGE
)
):
eff -= 0.05
# SAP 10.2 §9.4.11 -5pp interlock is applied to the Eq D1 OUTPUT
# via `_apply_water_efficiency`'s `interlock_penalty_pp` kwarg —
# NOT pre-subtracted from the Pwinter / Psummer inputs. The two
# forms differ because Eq D1's reciprocal weighting is non-linear
# in η; the worksheet's (217)m for pcdb 1 matches the post-Eq-D1
# form. See `_apply_water_efficiency` docstring + S0380.165 commit.
eq_d1_interlock_penalty_pp = (
5.0
if no_interlock
and eq_d1_winter_summer_pct is not None
and epc.has_hot_water_cylinder
else 0.0
)
# SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB
# Table 362 record, the cascade replaces the Table 4a defaults with
# APM-interpolated η_space and η_water at the dwelling's PSR.
hlc_annual_avg_w_per_k = ht.total_w_per_k + 0.33 * dim.volume_m3 * sum(
ventilation.effective_monthly_ach
) / 12.0
apm_efficiencies = _heat_pump_apm_efficiencies(
main=main,
hp_record=pcdb_hp_record,
hlc_annual_avg_w_per_k=hlc_annual_avg_w_per_k,
epc=epc,
)
if apm_efficiencies is not None:
eff, water_eff = apm_efficiencies
if (
_is_heat_network_main(main)
and epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
):
# HW from main on a heat-network cert: the DHW also incurs the
# network's distribution losses. Same 1/DLF override as for
# space heating so the delivered HW kWh reflects q_useful × DLF
# = q_generated, matching the per-kWh-generated unit price.
water_eff = 1.0 / _heat_network_dlf(primary_age)
is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES
# §9a Table 11 secondary fraction — pulled forward of §4 so the
# post-§8 Equation D1 cascade can derive Q_space = (98c)m × (204)
# without recomputing it. Pure function over the cert; same value
# later when §9a `space_heating_fuel_monthly_kwh` runs.
secondary_fraction_value = _secondary_fraction(
main, epc.sap_heating.secondary_heating_type
)
# SAP10.2 §4 — compute the worksheet (45..65) values now (they only
# depend on the cert dwelling shape, not on water_efficiency). The
# (65)m heat-gains tuple feeds §5 internal gains. HW fuel kWh is
# deferred to after §8 produces (98c)m so the Appendix D §D2.1 (2)
# Equation D1 monthly cascade has both Q_space and Q_water.
wh_result, hw_heat_gains_monthly_kwh = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=water_eff,
is_instantaneous=is_instantaneous,
primary_age=primary_age,
pcdb_record=pcdb_main,
)
# SAP10.2 §5: chain (66)..(73) internal-gain components via the §5
# orchestrator. The orchestrator needs the §4 (65)m heat-gains tuple,
# which we just plumbed out of `water_heating_from_cert` above.
# Falls back to a 12-zero tuple + zero lighting when TFA is missing —
# matches the legacy `internal_gains_w` zero-floor behaviour. Overshading
# default is AVERAGE per Table 6d note 1 (existing dwellings).
# Annual lighting kWh (worksheet line ref (232)) is sourced from the
# §5 cascade so the cost-side `inputs.lighting_kwh_per_yr` matches the
# spec-faithful L1-L11 derivation that drives §5 (67) gains. Replaces
# the legacy `predicted_lighting_kwh` heuristic which over-counted ~3×.
lighting_monthly_kwh: tuple[float, ...] = (0.0,) * 12
appliances_monthly_kwh: tuple[float, ...] = (0.0,) * 12
cooking_monthly_kwh: tuple[float, ...] = (0.0,) * 12
if epc.total_floor_area_m2 is None:
internal_gains_monthly_w = (0.0,) * 12
lighting_kwh = 0.0
else:
internal_gains_result = internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=dim.volume_m3,
heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
rooflight_total_area_m2=_rooflight_total_area_m2_from_cert(epc),
)
internal_gains_monthly_w = (
internal_gains_result.total_internal_gains_monthly_w
)
lighting_kwh = internal_gains_result.lighting_kwh_per_yr
# Watts → kWh via n_days_in_month × 24 hours / 1000 W per kWh.
# Appendix M1 §3a D_PV,m needs each of these monthly so the
# PV-eligible-demand assembly downstream can sum them in kWh.
lighting_monthly_kwh = tuple(
w * d * 24.0 / 1000.0
for w, d in zip(
internal_gains_result.lighting_monthly_w, _DAYS_IN_MONTH
)
)
appliances_monthly_kwh = tuple(
w * d * 24.0 / 1000.0
for w, d in zip(
internal_gains_result.appliances_monthly_w, _DAYS_IN_MONTH
)
)
# SAP 10.2 Appendix M1 §3a needs cooking ELECTRICITY (L20-L21,
# p.91): E_cook = 138 + 28 × N annual kWh, distributed by days
# n_m / 365. Distinct from the L18 cooking HEAT GAIN (35 + 7N
# watts) which the §5 internal-gains accounting uses via
# `internal_gains_result.cooking_monthly_w` for the (98c)m
# space-heating cascade. The two differ by ~2.2× because not
# all cooking electricity stays as internal heat (extraction
# fans, heat absorbed by food, etc.). Pre-S0380.73 the cascade
# mis-used L18 × hours/1000 as the D_PV cooking electricity
# figure, over-counting D_PV by ~235 kWh/yr on a typical
# 2-occupant cert and inflating the per-month β by 0.012-0.016
# in summer — closes the cohort 0380 +25 kWh annual (233a)
# gap when corrected.
cooking_electricity_annual_kwh = (
_COOKING_ELECTRICITY_BASE_KWH_L20
+ _COOKING_ELECTRICITY_PER_OCCUPANT_KWH_L20 * wh_result.occupancy
) if wh_result is not None else 0.0
cooking_monthly_kwh = _days_in_month_proportioned(
cooking_electricity_annual_kwh, _DAYS_IN_MONTH,
)
climate: "int | PostcodeClimate" = _climate_source(postcode_climate)
solar_gains_monthly_w = solar_gains_from_cert(
epc=epc,
region=climate,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
roof_windows=_roof_windows_for_solar_gains(epc),
).total_solar_gains_monthly_w
# SAP10.2 §7 — compose (93)m + (94)m via the orchestrator. Per-month HTC
# = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0
# for the Elmhurst corpus (cert-side mapping is a future slice).
control_type_value = _control_type(main)
responsiveness_value = _responsiveness(
main, tariff=tariff_from_meter_type(epc.sap_energy_source.meter_type),
)
living_area_fraction_value = _living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
)
monthly_total_gains_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
monthly_htc_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12)
)
extended_heating_days = _heat_pump_extended_heating_days_per_month(
main=main,
hp_record=pcdb_hp_record,
hlc_annual_avg_w_per_k=hlc_annual_avg_w_per_k,
)
mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=tuple(
external_temperature_c(climate, m)
for m in range(1, 13)
),
monthly_total_gains_w=monthly_total_gains_w,
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=control_type_value,
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
extended_heating_days_per_month=extended_heating_days,
)
# SAP10.2 §8 — compose (98c)m via the orchestrator. Reuses the per-month
# HTC + total-gains tuples already computed for §7 and adds T_int + η
# from the MIT result. Includes the Table 9c step 10 summer clamp.
monthly_external_temp_c = tuple(
external_temperature_c(climate, m) for m in range(1, 13)
)
space_heating_result = space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
monthly_internal_temperature_c=mit_result.adjusted_mean_internal_temp_monthly,
monthly_external_temperature_c=monthly_external_temp_c,
monthly_utilisation_factor=mit_result.utilisation_factor_whole_monthly,
monthly_total_gains_w=monthly_total_gains_w,
total_floor_area_m2=dim.total_floor_area_m2,
)
# SAP10.2 Appendix D §D2.1 (2) Equation D1: now that (98c)m exists,
# divide §4 (64)m by the monthly cascade (PCDB-tested combis) or by
# the scalar `water_eff` (Table 4a/4b boilers, legacy fallback).
# Q_space (kWh/month) per spec = (98c)m × (204) = (98c)m × (1
# sec_frac) for single-main fixtures.
if wh_result is not None:
space_heating_monthly_useful_kwh = tuple(
q * (1.0 - secondary_fraction_value)
for q in space_heating_result.total_space_heating_monthly_kwh
)
hw_kwh = _apply_water_efficiency(
wh_output_monthly_kwh=wh_result.output_monthly_kwh,
wh_output_annual_kwh=wh_result.output_kwh_per_yr,
water_efficiency_pct=water_eff,
eq_d1_winter_summer_pct=eq_d1_winter_summer_pct,
space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh,
interlock_penalty_pp=eq_d1_interlock_penalty_pp,
)
# SAP 10.2 §12.4.4 (PDF p.36-37) — back-boiler HW kWh splits at
# boiler efficiency (Oct-May) + 100% electric immersion (Jun-Sep).
# When the rule applies, the cascade swaps the single-fuel hw_kwh
# for the two-fuel sum so the (219) line lands on the worksheet's
# mixed-fuel total. The blend struct also carries the cost / CO2
# / PE / standing overrides for the CalculatorInputs construction
# below; resolved here (not in `_apply_water_efficiency`) so the
# helper's signature stays a pure scalar→scalar mapping.
section_12_4_4_blend: Optional[
tuple[float, float, float, float, float]
] = None
if _section_12_4_4_summer_immersion_applies(epc, main):
section_12_4_4_blend = _section_12_4_4_hw_blend(
wh_output_monthly_kwh=wh_result.output_monthly_kwh,
# `water_eff` is the fraction (0.65 not 65.0); blend
# helper expects a percentage to match its naming.
boiler_efficiency_pct=water_eff * 100.0,
boiler_fuel_code=_water_heating_fuel_code(epc),
tariff=_rdsap_tariff(epc),
prices=prices,
)
hw_kwh = section_12_4_4_blend[0]
else:
section_12_4_4_blend = None
# TFA missing → legacy `predicted_hot_water_kwh` cascade. Mirrors
# the pre-§4 slice-1 behaviour exactly so we don't change the
# answer for the (rare) corpus carrying no TFA.
hw_kwh = predicted_hot_water_kwh(
total_floor_area_m2=epc.total_floor_area_m2,
seasonal_efficiency_water=water_eff,
cylinder_size=None if is_instantaneous else _int_or_none(epc.sap_heating.cylinder_size),
cylinder_insulation_thickness_mm=(
None if is_instantaneous else epc.sap_heating.cylinder_insulation_thickness_mm
),
cylinder_insulation_type=(
None if is_instantaneous
else _int_or_none(epc.sap_heating.cylinder_insulation_type)
),
age_band=None if is_instantaneous else primary_age,
has_wwhrs=False,
has_solar_water_heating=epc.solar_water_heating,
)
# SAP10.2 §8c — compose (107)m via the orchestrator. RdSAP convention:
# `cooled_area_fraction = 0` always (the cert never lodges cooled-area
# data) and `cooling_gains = (0,)*12` until a real cooling-gains-from-
# cert helper lands. Both decisions deferred per SPEC_COVERAGE §8c row;
# for `has_fixed_air_conditioning=False` certs the f_C=0 zeros (107)
# regardless of gains so the stub is harmless.
space_cooling_result = space_cooling_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
monthly_external_temperature_c=monthly_external_temp_c,
monthly_total_gains_w=(0.0,) * 12,
total_floor_area_m2=dim.total_floor_area_m2,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
cooled_area_fraction=0.0,
intermittency_factor=0.25,
)
# SAP10.2 (109) — Fabric Energy Efficiency. Spec literal: (98a) / (4) +
# (108). For corpora without Appendix H solar space heating, (98a) == (98c).
# §11 compliance would re-run with different conditions; transparency-only
# for ratings.
fee_kwh_per_m2 = fabric_energy_efficiency_kwh_per_m2_yr(
space_heating_kwh_per_yr=space_heating_result.space_heating_requirement_kwh_per_yr,
total_floor_area_m2=dim.total_floor_area_m2,
space_cooling_per_m2_kwh=space_cooling_result.space_cooling_per_m2_kwh,
)
# SAP10.2 §9a — per-system energy requirements (201)..(221). Composes
# (98c)m + Table 11 secondary fraction + per-system efficiencies into
# (211)m/(213)m/(215)m fuel-kWh tuples. Scope A: single-main only;
# (203)/(205)/(207)/(213) two-main and (209)/(221) cooling-SEER stay at
# zero placeholders until those slices land. (`secondary_fraction_value`
# pulled forward above for the §4 Equation D1 cascade.)
secondary_efficiency_value = _secondary_efficiency(
epc.sap_heating, main_code, main_fuel
)
energy_requirements_result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
secondary_heating_fraction=secondary_fraction_value,
main_heating_efficiency_pct=eff * 100.0,
secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0,
)
# SAP 10.2 Appendix M1 §3-4 (p.93-94): split monthly PV generation
# into onsite-consumed (E_PV,dw,m) and exported (E_PV,ex,m) via the
# β factor. The PE cascade in calculator.py reads
# `pv_dwelling_kwh_per_yr` + `pv_exported_kwh_per_yr` and applies
# IMPORT PEF (Table 12 = 1.501) to the onsite portion and EXPORT
# PEF (Table 12 code 60 = 0.501) to the exported portion per §8.
# Fuel-code translation: `main_fuel` / `water_heating_fuel` are
# raw API codes; the β cascade keys on Table-12 codes (e.g. API 29
# = electricity → Table 12 code 30) per the Appendix M1 §3a fuel
# inclusion list.
pv_monthly_kwh = _pv_monthly_generation_kwh(epc, climate)
# SAP 10.2 Appendix M1 footnote 32 D_PV,m uses §4 (219)m monthly
# water-heating fuel kWh — which is the (62)m output divided by the
# water-heater efficiency. Uniform days-proration over the annual
# `hw_kwh` over-counts D_PV in summer and under-counts in winter
# (the (45)m hot-water energy content is seasonal, peaking in Jan).
# Scale `wh_output_monthly_kwh` to sum to the annual fuel `hw_kwh`
# — equivalent to dividing each month by the annual-average
# efficiency, which matches the worksheet's (219)m for HP / single-
# efficiency water heaters. For PCDB combis with distinct winter /
# summer efficiencies, `_apply_water_efficiency` already accounted
# for the seasonal split in the annual total; preserving the §4
# monthly shape here keeps the per-month distribution faithful.
if wh_result is not None and sum(wh_result.output_monthly_kwh) > 0:
output_total = sum(wh_result.output_monthly_kwh)
hot_water_monthly_kwh_for_pv = tuple(
wh_result.output_monthly_kwh[m] / output_total * hw_kwh
for m in range(12)
)
else:
hot_water_monthly_kwh_for_pv = _days_in_month_proportioned(
hw_kwh, _DAYS_IN_MONTH,
)
pv_eligible_demand_monthly_kwh = _pv_eligible_demand_monthly_kwh(
lighting_monthly_kwh=lighting_monthly_kwh,
appliances_monthly_kwh=appliances_monthly_kwh,
cooking_monthly_kwh=cooking_monthly_kwh,
electric_shower_monthly_kwh=(
wh_result.electric_shower_monthly_kwh
if wh_result is not None else (0.0,) * 12
),
pumps_fans_monthly_kwh=_days_in_month_proportioned(
pumps_fans_kwh, _DAYS_IN_MONTH,
),
main_1_fuel_monthly_kwh=energy_requirements_result.main_1_fuel_monthly_kwh,
hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv,
main_fuel_code_table_12=(
API_FUEL_TO_TABLE_12.get(main_fuel, main_fuel)
if main_fuel is not None else None
),
water_heating_fuel_code_table_12=(
API_FUEL_TO_TABLE_12.get(
epc.sap_heating.water_heating_fuel,
epc.sap_heating.water_heating_fuel,
)
if epc.sap_heating.water_heating_fuel is not None else None
),
)
pv_split = pv_split_monthly(
epv_monthly_kwh=pv_monthly_kwh,
dpv_monthly_kwh=pv_eligible_demand_monthly_kwh,
battery_capacity_kwh=_pv_battery_capacity_kwh(epc),
)
# SAP 10.2 §12.4.4 overrides — when summer immersion applies (back-
# boiler combo + cylinder + WHC from main heating), the HW cost /
# CO2 / PE factors are kWh-weighted blends of the winter boiler fuel
# + summer electric immersion. The standing-charges line adds the
# off-peak electric standing because the cylinder is heated by an
# off-peak immersion Jun-Sep. When the rule does NOT apply, the
# locals fall back to the existing single-fuel HW helpers.
hw_monthly_kwh_for_factors = (
wh_result.output_monthly_kwh if wh_result is not None
else (0.0,) * 12
)
if section_12_4_4_blend is not None:
(
_hw_total_unused,
_hw_cost_rate,
_hw_co2_factor,
_hw_pe_factor,
_hw_extra_standing,
) = section_12_4_4_blend
hw_cost_rate = _hw_cost_rate
hw_co2_factor = _hw_co2_factor
hw_pe_factor = _hw_pe_factor
else:
_community_hw_inherit = _is_community_heating_hw_from_main(epc)
hw_cost_rate = _hot_water_fuel_cost_gbp_per_kwh(
_water_heating_fuel_code(epc),
_water_heating_main(epc),
_rdsap_tariff(epc),
prices,
inherit_main_for_community_heating=_community_hw_inherit,
)
hw_co2_factor = _hot_water_co2_factor_kg_per_kwh(
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
)
hw_pe_factor = _hot_water_primary_factor(
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
)
_hw_extra_standing = 0.0
standing_charges_total = additional_standing_charges_gbp(
main_fuel_code=_main_fuel_code(main),
water_heating_fuel_code=_water_heating_fuel_code(epc),
tariff=_rdsap_tariff(epc),
) + _hw_extra_standing
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
# SAP10.2 line (25)m — 12-month effective air-change rate from the
# full §2 worksheet (openings, shelter, wind adjustment, MV mode).
monthly_infiltration_ach=ventilation.effective_monthly_ach,
# SAP10.2 line (73)m — total internal gains W/month from §5
# orchestrator (composed above).
internal_gains_monthly_w=internal_gains_monthly_w,
# SAP10.2 line (83)m — total solar gains W/month via §6 orchestrator.
# Cert summaries don't lodge roof windows or rooflights distinctly
# (Elmhurst data shows them all as `window_location = External wall`);
# both pass-throughs are empty. Per-fixture §6 conformance is
# exercised separately in `test_solar_gains.py`.
solar_gains_monthly_w=solar_gains_monthly_w,
# SAP10.2 (93)m + (94)m — adjusted MIT and whole-dwelling η. From
# the §7 orchestrator above (Table 9c steps 1-9 sequential, per-zone η).
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
# SAP10.2 (98c)m — total space heating kWh/month from §8 orchestrator
# above (includes the spec Jun..Sep summer clamp).
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
# SAP10.2 (107)m — space cooling kWh/month from §8c orchestrator
# above (includes Jun-Aug inclusion mask + 1-kWh clamp).
space_cooling_monthly_kwh=space_cooling_result.space_cooling_monthly_kwh,
# SAP10.2 (109) — Fabric Energy Efficiency precomputed above.
fabric_energy_efficiency_kwh_per_m2_yr=fee_kwh_per_m2,
region=_region_index(epc.region_code),
monthly_external_temp_c_override=monthly_external_temp_c,
control_type=control_type_value,
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
main_heating_efficiency=eff,
hot_water_kwh_per_yr=hw_kwh,
pumps_fans_kwh_per_yr=pumps_fans_kwh,
lighting_kwh_per_yr=lighting_kwh,
# Unregulated annual delivered electricity for ADR-0014
# BillDerivation — output-only, NOT wired into cost / CO2 / PE.
# Appliances: SAP 10.2 Appendix L L13/L14/L16a (sum of the §5
# (68) monthly E_A). Cooking: Appendix L L20 (p.91) ELECTRICITY
# E_cook = 138 + 28×N, already summed in `cooking_monthly_kwh`.
appliances_kwh_per_yr=sum(appliances_monthly_kwh),
cooking_kwh_per_yr=sum(cooking_monthly_kwh),
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
main, _rdsap_tariff(epc), prices
),
hot_water_fuel_cost_gbp_per_kwh=hw_cost_rate,
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(
_rdsap_tariff(epc), prices
),
# SAP 10.2 Table 12a Grid 2 — MEV/MVHR fans bill at a different
# high-rate fraction (10-hour: 0.58; 7-hour: 0.71) than the
# general "all other uses" category (10-hour: 0.80; 7-hour:
# 0.90). Compute the kWh-weighted blended rate so the
# calculator's legacy pumps_fans cost line resolves correctly.
# None on standard-tariff certs (no split applies) and on certs
# without MEV (no MEV portion to split out).
pumps_fans_fuel_cost_gbp_per_kwh=_pumps_fans_fuel_cost_gbp_per_kwh(
tariff=_rdsap_tariff(epc),
mev_kwh_per_yr=mev_kwh_for_cost_split,
total_pumps_fans_kwh_per_yr=pumps_fans_kwh,
),
# Table 32 standing charges for the off-peak fallback path.
# STANDARD-tariff certs route via `fuel_cost.additional_
# standing_charges_gbp` (set inside `_fuel_cost`) and the
# calculator ignores this scalar on that path.
standing_charges_gbp=standing_charges_total,
co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
# SAP10.2 Table 12d (p.194) per-end-use effective CO2 factors. For
# electricity end-uses Σ(kWh_m × CO2_m) / Σ(kWh_m) replaces the
# annual-average Table 12 factor; gas end-uses pass through as the
# annual Table 12 value. None → calculator falls back to the global
# `co2_factor_kg_per_kwh`. Secondary heating defaults to standard
# electricity per RdSAP §A.2.2 (portable electric heater).
# Main heating routes through `_main_heating_co2_factor_kg_per_kwh`
# so electric mains on off-peak tariffs blend Table 12a Grid 1 SH
# high-rate fraction × Table 12d high-rate monthly factors with
# the matching low-rate pair (mirror of the cost-side dual-rate
# split landed in Slice S0380.61).
main_heating_co2_factor_kg_per_kwh=_main_heating_co2_factor_kg_per_kwh(
main, _rdsap_tariff(epc),
energy_requirements_result.main_1_fuel_monthly_kwh,
),
secondary_heating_co2_factor_kg_per_kwh=_secondary_heating_co2_factor_kg_per_kwh(
epc, energy_requirements_result.secondary_fuel_monthly_kwh,
),
hot_water_co2_factor_kg_per_kwh=hw_co2_factor,
# SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps,
# lighting, and the electric-shower end-use all bill via the
# "All other uses" row → on off-peak tariffs blend the high /
# low Table 12d codes per the Grid 2 fraction. STANDARD tariff
# passes through to single-code-30 monthly. Mirrors the main-
# heating Grid 1 split landed in S0380.65.
#
# MEV/MVHR-fan kWh route through `FANS_FOR_MECH_VENT` (lower
# high-rate fraction → lower CO2 factor on a high-carbon high-
# rate code) instead of `ALL_OTHER_USES`. Slice S0380.105
# weights the two streams by their lodged kWh portions —
# mirror of the cost-side S0380.103 split.
pumps_fans_co2_factor_kg_per_kwh=_pumps_fans_co2_factor_kg_per_kwh(
tariff=_rdsap_tariff(epc),
mev_kwh_per_yr=mev_kwh_for_cost_split,
total_pumps_fans_kwh_per_yr=pumps_fans_kwh,
monthly_kwh=_days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH),
),
lighting_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh(
OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), lighting_monthly_kwh,
),
electric_shower_kwh_per_yr=(
wh_result.electric_shower_kwh_per_yr if wh_result is not None else 0.0
),
electric_shower_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh(
OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc),
wh_result.electric_shower_monthly_kwh if wh_result is not None else (0.0,) * 12,
),
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate),
pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(),
pv_dwelling_import_price_gbp_per_kwh=_pv_dwelling_import_price_gbp_per_kwh(
epc.sap_energy_source.meter_type, prices
),
# SAP 10.2 Appendix M1 §3-4 PV split — the cascade applies
# IMPORT PEF (Table 12) to the onsite portion and EXPORT PEF
# (Table 12 code 60 = 0.501) to the exported portion per §8.
# The CO2 factors per §7 are the effective monthly Table 12d
# values weighted by the monthly E_PV,dw / E_PV,ex split:
# dwelling uses code 30 (Standard electricity); exported uses
# code 60 (Electricity sold to grid, PV).
pv_dwelling_kwh_per_yr=pv_split.epv_dwelling_kwh_per_yr,
pv_exported_kwh_per_yr=pv_split.epv_exported_kwh_per_yr,
pv_dwelling_co2_factor_kg_per_kwh=_effective_monthly_co2_factor(
pv_split.epv_dwelling_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
),
pv_exported_co2_factor_kg_per_kwh=_effective_monthly_co2_factor(
pv_split.epv_exported_monthly_kwh,
_PV_EXPORT_FUEL_CODE_TABLE_12,
),
# SAP 10.2 Appendix M1 §8 — per-cert effective monthly PE
# factors for the PV split. Mirrors the §7 CO2 factors above:
# dwelling factor weights Table 12e code 30 (standard
# electricity import) by monthly E_PV,dw,m; exported factor
# weights code 60 ("electricity sold to grid, PV") by monthly
# E_PV,ex,m. Worksheet for cert 0380 lodges 1.4960 / 0.4268;
# the annual Table 12 fallbacks (1.501 / 0.501) over-credit by
# the differential when the cascade uses them directly.
pv_dwelling_primary_factor=_effective_monthly_pe_factor(
pv_split.epv_dwelling_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
),
pv_exported_primary_factor=_effective_monthly_pe_factor(
pv_split.epv_exported_monthly_kwh,
_PV_EXPORT_FUEL_CODE_TABLE_12,
),
secondary_heating_fraction=secondary_fraction_value,
secondary_heating_efficiency=secondary_efficiency_value,
energy_requirements=energy_requirements_result,
secondary_heating_fuel_cost_gbp_per_kwh=_secondary_fuel_cost_gbp_per_kwh(
epc.sap_heating, main, epc.sap_energy_source.meter_type, prices
),
space_heating_primary_factor=_main_heating_primary_factor(
main, _rdsap_tariff(epc),
energy_requirements_result.main_1_fuel_monthly_kwh,
),
hot_water_primary_factor=hw_pe_factor,
other_primary_factor=primary_energy_factor(30), # standard electricity
# SAP 10.2 Table 12e (p.195) per-end-use effective PE factors. Same
# shape as the Table 12d CO2 cascade: electricity end-uses use the
# monthly factors weighted by per-month kWh; gas end-uses pass
# through the annual Table 12 / Table 32 PE factor. Secondary
# defaults to standard electricity per RdSAP §A.2.2.
secondary_heating_primary_factor=_secondary_heating_primary_factor(
epc, energy_requirements_result.secondary_fuel_monthly_kwh,
),
# PE-side mirror of the Grid 2 dual-rate CO2 blend above —
# Table 12a Grid 2 (p.191) + Table 12e (p.195).
#
# MEV/MVHR-fan kWh route through `FANS_FOR_MECH_VENT` (lower
# high-rate fraction → lower PE factor on a higher-PE high-
# rate code) instead of `ALL_OTHER_USES`. Slice S0380.106
# weights the two streams by their lodged kWh portions —
# mirror of the cost-side (.103) + CO2-side (.105) splits.
pumps_fans_primary_factor=_pumps_fans_primary_factor(
tariff=_rdsap_tariff(epc),
mev_kwh_per_yr=mev_kwh_for_cost_split,
total_pumps_fans_kwh_per_yr=pumps_fans_kwh,
monthly_kwh=_days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH),
),
lighting_primary_factor=_other_use_primary_factor(
OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), lighting_monthly_kwh,
),
electric_shower_primary_factor=_other_use_primary_factor(
OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc),
wh_result.electric_shower_monthly_kwh if wh_result is not None else (0.0,) * 12,
),
fuel_cost=_fuel_cost(
epc=epc,
main=main,
electric_shower_kwh=(
wh_result.electric_shower_kwh_per_yr if wh_result is not None else 0.0
),
energy_requirements_result=energy_requirements_result,
hot_water_kwh=hw_kwh,
pumps_fans_kwh=pumps_fans_kwh,
lighting_kwh=lighting_kwh,
cooling_kwh=energy_requirements_result.cooling_fuel_kwh_per_yr,
climate=climate,
prices=prices,
pv_dwelling_kwh_per_yr=pv_split.epv_dwelling_kwh_per_yr,
pv_exported_kwh_per_yr=pv_split.epv_exported_kwh_per_yr,
),
)
def local_climate_for_cert(epc: EpcPropertyData) -> Optional[PostcodeClimate]:
"""Per SAP 10.2 Appendix U (p.124), the demand cascade (Current Carbon,
Current Primary Energy, Fuel Bill on the EPC) uses postcode-specific
weather data from PCDB Table 172. Returns the PostcodeClimate for the
cert's lodged postcode, or None when the postcode is missing or not in
Table 172 (callers fall back to UK-average / cert_to_inputs default).
"""
return postcode_climate(epc.postcode)
def cert_to_demand_inputs(
epc: EpcPropertyData, *, prices: PriceTable = SAP_10_2_SPEC_PRICES,
) -> CalculatorInputs:
"""Demand-cascade variant of cert_to_inputs (postcode climate from PCDB
Table 172). Used for EPC-displayed Current Carbon / Current Primary
Energy / Fuel Bill. Falls back to UK-average climate when the cert's
postcode is missing or absent from Table 172.
Reference: SAP 10.2 Appendix U paragraph 1 (p.124) — "Other
calculations (such as for energy use and costs on EPCs) are done using
local weather. Weather data for each postcode district are taken from
the PCDB and are used when the postcode district is known".
"""
return cert_to_inputs(
epc, prices=prices, postcode_climate=local_climate_for_cert(epc),
)