mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
ADR-0014 BillDerivation prices a per-end-use EnergyBreakdown
(HEATING / HOT_WATER / LIGHTING / PUMPS_FANS / APPLIANCES / COOKING).
SapResult already carried the first four but not appliances or cooking,
so a downstream SapResult→EnergyBreakdown adapter had to stub those two
at 0 kWh — understating the bill by the whole unregulated electricity
load. Surface them so the property_baseline side can wire the sections.
Adds two output-only fields to CalculatorInputs + SapResult, threaded
exactly like lighting_kwh_per_yr:
appliances_kwh_per_yr — SAP 10.2 Appendix L L13/L14/L16a annual E_A
(sum of the §5 (68) monthly appliances kWh)
cooking_kwh_per_yr — SAP 10.2 Appendix L L20 (p.91) ELECTRICITY
estimate E_cook = 138 + 28×N
Both values already existed in cert_to_inputs.py (appliances_monthly_kwh,
cooking_monthly_kwh) — reused, not recomputed.
Fuel attribution: cooking_kwh_per_yr is the L20 ELECTRICITY figure (the
field docstring says so), distinct from the L18 cooking heat GAIN
(35 + 7N W) the §5 internal-gains cascade uses. The bill adapter should
treat cooking as an electricity carrier; a gas-cooker split, if ever
needed, is a separate follow-up.
HARD CONSTRAINT honoured — output-only, zero rating drift. Appliances +
cooking are unregulated and are NOT fed into ECF / total_fuel_cost /
CO2 / primary energy / sap_score. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical (1165 rated pins green). The synthetic CalculatorInputs
fixtures set the new fields non-zero on purpose so the existing cost/PE
reconciliation assertions act as leak detectors.
New focused test asserts both fields are populated (non-zero) and
threaded unchanged onto SapResult, with cooking equal to the L20
electricity figure (138 + 28×occupancy) to 1e-9. pyright net-zero
111 → 111.
Note: 11 pre-existing failures in test_appendix_u.py / test_table_32.py
arrived with the recently absorbed PR and are unrelated to this change
(they fail identically on the clean branch); flagged separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6366 lines
286 KiB
Python
6366 lines
286 KiB
Python
"""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),
|
||
)
|