"""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, Union 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, mvhr_record, ) from domain.sap10_calculator.tables.pcdb.parser import ( GasOilBoilerRecord, HeatPumpRecord, MvhrDataPoint, MvhrRecord, ) 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, CO2_KG_PER_KWH_MONTHLY, PE_FACTOR_MONTHLY, PRIMARY_ENERGY_FACTOR, UNIT_PRICE_P_PER_KWH, _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, water_heating_high_rate_fraction, ) from domain.sap10_calculator.tables.table_13 import ( electric_dhw_high_rate_fraction, ) from domain.sap10_calculator.tables.table_32 import ( additional_standing_charges_gbp, canonical_fuel_code, 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 ( PhotovoltaicSplit, 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_keep_hot_time_clock, 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 # RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter (TMP), # keyed on the construction type of the MAIN building (not extensions): # 100 kJ/m²K — timber frame, cob, park home (the three types regardless # of internal insulation); OR masonry (stone, solid brick, # cavity, system built) WITH internal insulation. # 250 kJ/m²K — masonry WITHOUT internal insulation. # This default is the masonry-no-internal value; `_thermal_mass_parameter_ # kj_per_m2_k` lowers it to 100 for the Table 22 low-mass cases. Unknown / # unmapped / curtain-wall constructions keep the 250 default (the # pre-Table-22 behaviour, so no fixture regresses on a missing class). _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _TMP_LOW_KJ_PER_M2_K: Final[float] = 100.0 # `wall_construction` int codes (domain/sap10_ml/rdsap_uvalues.py): # 5 = timber frame, 7 = cob — always Table 22 low-mass. Park home is the # third "always low-mass" type, but its wall code (8) is OVERLOADED: the # Summary path's "PH" mapping uses 8 for park home, whereas the gov-API # enum uses 8 for SYSTEM BUILT (a masonry type, Summary system build = code # 6). Code 8 therefore takes the low-mass value only when the dwelling is # actually a park home (`property_type`); otherwise it is system built and # keeps the masonry 250 — see `_thermal_mass_parameter_kj_per_m2_k`. _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: Final[frozenset[int]] = frozenset({5, 7}) _TMP_PARK_HOME_OR_SYSTEM_BUILT_WALL_CODE: Final[int] = 8 # `wall_insulation_type` int codes that are INTERNAL insulation # (Table 22 "masonry … with internal insulation"): 3 = internal wall # insulation, 7 = filled cavity + internal. External (1), filled cavity # (2), cavity+external (6), as-built (4), none (5) keep the masonry 250. _TMP_INTERNAL_WALL_INSULATION_CODES: Final[frozenset[int]] = frozenset({3, 7}) # 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 — the same "no room thermostat" criterion as the interlock rule, i.e. `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES` (2101 / 2102 / 2107 / 2111; bypass and TRVs are not a room thermostat). """ 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) # (230a) balanced MVHR fan electricity — SFP × 1.22 × V (SAP 10.2 # §2.6.6). Costed here but kept out of the Table 5a gains. total += _mvhr_fan_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 # PCDB Table 329 system_type=10 = "default data" (PCDF Spec §A.20) — the # in-use-factor row used when the SFP is taken from SAP 10.2 Table 4g # rather than a specific PCDB product. Table 4g note 3 (PDF p.176) # requires the default SFP to be multiplied by this IUF (2.5, duct-agnostic). _MEV_DEFAULT_DATA_SYSTEM_TYPE: Final[int] = 10 # 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}) # SAP 10.2 Table 4g (PDF p.176) / §2.6.3 note 1 — default specific fan # power (W per litre/sec) for an MEV system whose fan(s) are not in the # PCDB. This is the RAW SFP: Table 4g note 3 requires it to be multiplied # by the "default data" in-use factor (Table 329 system_type 10) before # use as the SFPav in the (230a) formula (SFPav × 1.22 × V). _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S: Final[float] = 0.8 def _mev_default_data_iuf() -> float: """SAP 10.2 Table 4g note 3 (PDF p.176) in-use factor for default MEV data — PCDB Table 329 system_type 10 (IUF 2.5, identical across rigid/ flexible/no-duct columns per Table 4g note 2 "applies to both rigid and flexible ducting"). Falls back to 1.0 if the Table 329 record is unavailable (ETL bootstrap), preserving the pre-fix raw-SFP behaviour rather than zeroing fan electricity.""" record = mv_in_use_factors_record(_MEV_DEFAULT_DATA_SYSTEM_TYPE) if record is None or record.sfp_iuf_rigid_no_scheme is None: return 1.0 return record.sfp_iuf_rigid_no_scheme 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 record = decentralised_mev_record(pcdf_id) if pcdf_id is not None else None if record is None: # No PCDB Table 322 record (index absent, or lodged index not in # the table). For a mechanical-EXTRACT system, SAP 10.2 §2.6.3 / # Table 4g note 1 prescribes the default SFP (0.8 W/(l/s)) used # directly as SFPav. Natural / balanced (MVHR / MV) systems # contribute no (230a) decentralised-MEV fan electricity here — # the gate mirrors `_has_balanced_mechanical_ventilation`'s # MEV / PIV-from-outside vs balanced split. Closes the +2.2 SAP # over-rate on the index-less MEV cohort (mostly gas houses), which # previously billed zero fan electricity. sv = epc.sap_ventilation if ( sv is None or sv.mechanical_ventilation_kind != MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE.name ): return 0.0 # SAP 10.2 Table 4g note 3 (PDF p.176): the default SFP "[is] to be # multiplied by the appropriate in-use factor for default data from # the PCDB" (Table 329 system_type 10, IUF 2.5). Omitting it # under-billed the index-less MEV fan electricity by 2.5x → +1.3 SAP # over-rate on the no-PCDB MEV cohort (mostly gas houses). Distinct # from the with-index path below, which applies the tested-product # system_type-2 "no scheme" IUF (~1.45) per fan. sfp_av = _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * _mev_default_data_iuf() return mev_decentralised_kwh_per_yr( sfp_av_w_per_l_per_s=sfp_av, dwelling_volume_m3=dimensions_from_cert(epc).volume_m3, ) 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, ) # PCDB Table 329 / 323 system_type 3 = balanced whole-house MV (with or # without heat recovery). _MV_BALANCED_SYSTEM_TYPE: Final[int] = 3 # SAP 10.2 Table 4g (PDF p.176) defaults for an MVHR system NOT in the # PCDB: raw heat-recovery efficiency 66% + raw SFP 2.0 W/(l/s). Each is # then multiplied by the default-data in-use factor (Table 329 # system_type 10 — efficiency 0.70 inside the envelope, SFP 2.5). _TABLE_4G_DEFAULT_MVHR_EFFICIENCY_PCT: Final[float] = 66.0 _TABLE_4G_DEFAULT_MVHR_SFP_W_PER_L_PER_S: Final[float] = 2.0 @dataclass(frozen=True) class _MvhrSystemValues: """In-use SFP + heat-recovery efficiency for an MVHR dwelling, with the PCDB Table 329 in-use factors already folded in.""" in_use_sfp_w_per_l_per_s: float in_use_efficiency_pct: float def _select_mvhr_data_point(record: MvhrRecord, wet_rooms_count: int) -> MvhrDataPoint: """SAP 10.2 §2.6.4: select the MVHR data point by the dwelling's wet-room count (each Table 323 group is keyed by wet rooms). Exact match where lodged; otherwise clamp to the tested range. Falls back to the smallest-wet-room group when the cert lodges 0 (unlodged).""" points = sorted(record.data_points, key=lambda p: p.num_wet_rooms) target = wet_rooms_count if wet_rooms_count > 0 else points[0].num_wet_rooms for point in points: if point.num_wet_rooms == target: return point if target < points[0].num_wet_rooms: return points[0] return points[-1] def _mvhr_system_values(epc: EpcPropertyData) -> Optional[_MvhrSystemValues]: """Resolve the in-use SFP + heat-recovery efficiency for an MVHR dwelling, or None when the cert is not MVHR. PCDB path (index lodged + Table 323 hit): select the data point whose wet-room count matches the lodgement, then apply the Table 329 system_type-3 in-use factors — SFP per the lodged duct type (rigid 1.4 / flexible 1.7); heat-recovery efficiency for ducts inside the heated envelope (0.90). Worksheet-proven on simulated case 49 (000565, 2 wet rooms, Vent Axia 500140 → raw SFP 0.88 × 1.4 = 1.232; raw efficiency 91 × 0.90 = 81.9% = worksheet (23c)). Default path (no PCDB record, e.g. an MVHR lodged with no PCDF index): SAP 10.2 Table 4g raw SFP 2.0 / efficiency 66%, × the default-data in-use factors (Table 329 system_type 10 — SFP 2.5, efficiency 0.70). Duct type defaults to rigid when unlodged (the Elmhurst Summary path captures it but not all sources do); semi-rigid maps to rigid per SAP 10.2 §2.6.8. Only the ducts-inside-envelope efficiency factor is worksheet-validated (all corpus + worksheet MVHR certs lodge it). """ sv = epc.sap_ventilation if sv is None or sv.mechanical_ventilation_kind != MechanicalVentilationKind.MVHR.name: return None pcdf_id = epc.mechanical_ventilation_index_number record = mvhr_record(pcdf_id) if pcdf_id is not None else None if record is not None and record.data_points: point = _select_mvhr_data_point(record, epc.wet_rooms_count) raw_sfp = point.sfp_w_per_l_per_s raw_efficiency_pct = point.efficiency_pct iuf_record = mv_in_use_factors_record(_MV_BALANCED_SYSTEM_TYPE) else: raw_sfp = _TABLE_4G_DEFAULT_MVHR_SFP_W_PER_L_PER_S raw_efficiency_pct = _TABLE_4G_DEFAULT_MVHR_EFFICIENCY_PCT iuf_record = mv_in_use_factors_record(_MEV_DEFAULT_DATA_SYSTEM_TYPE) if iuf_record is None or raw_sfp is None or raw_efficiency_pct is None: return None if epc.mechanical_vent_duct_type == _MV_DUCT_TYPE_FLEXIBLE: sfp_iuf = iuf_record.sfp_iuf_flexible_no_scheme else: sfp_iuf = iuf_record.sfp_iuf_rigid_no_scheme efficiency_iuf = iuf_record.mvhr_efficiency_iuf_inside_no_scheme if sfp_iuf is None or efficiency_iuf is None: return None return _MvhrSystemValues( in_use_sfp_w_per_l_per_s=raw_sfp * sfp_iuf, in_use_efficiency_pct=raw_efficiency_pct * efficiency_iuf, ) def _mvhr_fan_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float: """SAP 10.2 §5 Table 4f line (230a) annual fan electricity for an MVHR dwelling: in-use SFP × 1.22 × V (the same (230a) shape as decentralised MEV). Returns 0.0 when the cert is not MVHR. Per SAP 10.2 §2.6.6 this electricity is costed but NOT added to the Table 5a gains (its effect is already in the heat-recovery efficiency).""" values = _mvhr_system_values(epc) if values is None: return 0.0 return mev_decentralised_kwh_per_yr( sfp_av_w_per_l_per_s=values.in_use_sfp_w_per_l_per_s, dwelling_volume_m3=dimensions_from_cert(epc).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 Table 4a "direct-acting electric boiler" (RdSAP 10 §12 p.62). # Named in the SAP 10.2 Table 3 (PDF p.160) primary-loss zero list, so a # 191 main feeding a cylinder incurs no primary circuit loss. _DIRECT_ACTING_ELECTRIC_BOILER_CODE: Final[int] = 191 # Water-heating codes for a dedicated "boiler/circulator for water # heating only" — SAP 10.2 Table 4a hot-water section (PDF p.166): # 911 gas, 912 liquid fuel, 913 solid fuel boiler/circulator; 921-931 # range cooker with boiler for water heating only. Each is a heat # generator feeding the cylinder through a primary loop, so SAP 10.2 # Table 3 (PDF p.160) row 1 primary circuit loss applies — independent # of the space-heating system (which for these certs is a separate main, # e.g. electric storage heaters). 941 (electric HP for water only) is # excluded: HP DHW vessels follow the Table 3 integral-vessel rules. _WATER_HEATING_BOILER_CIRCULATOR_CODES: Final[frozenset[int]] = frozenset( {911, 912, 913} | set(range(921, 932)) ) # 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: Optional[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 an unknown orientation (None when the cert lodged 'ND', or a code outside 1..8) — these PV arrays contribute nothing.""" if orientation_code is None: return 0.0 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 def pv_annual_solar_radiation_kwh_per_m2( orientation_code: int, pitch_code: int, climate: int = 0 ) -> float: """Public seam over the SAP 10.2 Appendix U3.3 annual PV solar radiation `S` (kWh/m²/yr) for a plane of given SAP orientation octant + RdSAP pitch code. `climate` defaults to 0 (UK average, the rating cascade). Reused by the Modelling solar overshading calibration (ADR-0026), which back-solves the overshading factor ZPV from Google's expected generation against this unshaded `S`.""" return _pv_annual_s_kwh_per_m2(orientation_code, pitch_code, climate) # 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, 8: 0.10, # Electric underfloor heating (direct-acting electric, e.g. # SAP code 424): SAP 10.2 Table 11 (PDF p.188) row # "Integrated storage/direct-acting electric systems" / # "Other electric systems" = 0.10. First exercised when the # description-lodged-secondary fix routed cat-8 mains (which # previously short-circuited to 0) through the Table 11 # lookup (cert 2051-9502, electric underfloor + assumed # portable-electric secondary). 9: 0.10, # Warm-air systems (NOT heat pump): a gas/oil warm-air unit # is an "All gas, liquid and solid fuel systems" row (0.10), # and electric warm air is "Other electric systems" (also # 0.10) — so 0.10 regardless of fuel (SAP 10.2 Table 11 # p.188). Cert 0380 (warm air mains gas, code 506, + # electric room-heater secondary) raised here before. 10: 0.20, 11: 0.10, # Electric ceiling heating (SAP code 701): direct-acting # electric, SAP 10.2 Table 11 "Other electric systems" row. } _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, } # SAP 10.2 Table 12 (PDF p.191) "Heat networks" standing charge row = # £120/yr (note (k)). Note (l): "Include half this value if only DHW is # provided by a heat network." §C3.2 (PDF p.58): the full charge applies # when the space heating is also on the heat network. _HEAT_NETWORK_STANDING_CHARGE_GBP: Final[float] = 120.0 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 _is_heat_network_electric_main(main: Optional[MainHeatingDetail]) -> bool: """True when the main is a heat network whose generator runs on grid electricity (Table 4a code 304 → Table 12 fuel code 41 "heat from electric heat pump"). Such networks meter electricity, so SAP 10.2 Table 12 note (s)/(t) + worksheet block 12b/13b footnote (a) require the MONTHLY Table 12d/12e factors (not the annual average), weighted by the network heat profile, before the 1/heat-source-eff (1/COP) scaling. Non-electric heat networks (gas/oil/coal boilers, codes 51/53/54) have no monthly factor set and keep the annual Table 12 value.""" if not _is_heat_network_main(main): return False return co2_monthly_factors_kg_per_kwh(_main_fuel_code(main)) is not None 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) # SAP 10.2 Table 4c(3) (PDF p.169) — "Factor for controls and charging # method" for HEAT NETWORKS, by Table 4e control code. The factor multiplies # the heat-network heat requirement on top of the Table 12c distribution loss # factor: worksheet (307) space = (98c) × (302) × (305) × (306), and # (310) DHW = (64) × (305a) × (306). "Flat rate charging" (note d: the # household pays a fixed amount regardless of heat used) carries a demand # penalty — there is no incentive to economise; "charging linked to use of # heat" does not. The SPACE column adds a further +0.05 when there is also # no thermostatic room-temperature control (2301/2302). _HEAT_NETWORK_SPACE_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = { # Flat rate charging, no thermostatic room control → 1.10 2301: 1.10, 2302: 1.10, # Flat rate charging, with some thermostatic control → 1.05 2303: 1.05, 2304: 1.05, 2305: 1.05, 2307: 1.05, 2311: 1.05, 2313: 1.05, # Charging linked to use of heat, thermostat but no TRVs → 1.05 2308: 1.05, 2309: 1.05, # Charging linked to use of heat, with TRVs → 1.00 2306: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00, } _HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = { # Flat rate charging → 1.05 (all controls) 2301: 1.05, 2302: 1.05, 2303: 1.05, 2304: 1.05, 2305: 1.05, 2307: 1.05, 2311: 1.05, 2313: 1.05, # Charging linked to use of heat → 1.00 2306: 1.00, 2308: 1.00, 2309: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00, } def _heat_network_space_charging_factor(main: Optional[MainHeatingDetail]) -> float: """SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305) — heat-network space-heating factor for controls and charging method. Returns 1.0 when the control is absent (no penalty); every Table 4e Group-3 code (2301-2314) is covered, so an unmapped key only arises off the heat-network path and is harmless at 1.0.""" code = main.main_heating_control if main is not None else None if not isinstance(code, int): return 1.0 return _HEAT_NETWORK_SPACE_CHARGING_FACTOR_BY_CODE.get(code, 1.0) def _heat_network_dhw_charging_factor(main: Optional[MainHeatingDetail]) -> float: """SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305a) — heat-network water-heating factor for charging method (flat rate → 1.05, linked to use → 1.0). Returns 1.0 when the control is absent.""" code = main.main_heating_control if main is not None else None if not isinstance(code, int): return 1.0 return _HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE.get(code, 1.0) def _dwelling_age_band(epc: EpcPropertyData) -> Optional[str]: """The dwelling's construction age band, read from the first building part that lodges one. The GOV.UK API can lodge a junk empty leading building part (all fields absent) ahead of the real Main Dwelling — reading `sap_building_parts[0]` then yields None and silently drops the age band (e.g. defaulting the heat-network DLF to the K-or-newer 1.50 instead of the dwelling's true band). A no-op for normal certs, where `[0]` is the Main part and already carries a valid band. """ return next( ( bp.construction_age_band for bp in epc.sap_building_parts if bp.construction_age_band ), None, ) # SAP 10.2 Table 12 fuel code 50 — "electricity for pumping in # distribution network". Its CO2 / PE factors vary by month per Table # 12d / 12e (= standard-electricity profile); worksheet (372)/(472) # footnote (a) applies the monthly factors weighted by the heat profile. _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE: Final[int] = 50 # SAP 10.2 Appendix C §C3.2 (PDF p.51) — pumping energy = 1% of the # energy required for space and water heating. _HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT: Final[float] = 0.01 def _heat_network_distribution_electricity( main: Optional[MainHeatingDetail], space_heating_monthly_kwh: tuple[float, ...], hot_water_output_monthly_kwh: tuple[float, ...], efficiency: float, ) -> Optional[tuple[float, float, float]]: """SAP 10.2 Appendix C §C3.2 (PDF p.51) — electricity for pumping water through a heat network's distribution system. Spec verbatim: "CO2 emissions and Primary Energy associated with the electricity used for pumping water through the distribution system are allowed for by adding electrical energy equal to 1% of the energy required for space and water heating." Worksheet line (313) = 0.01 × [(307) + (310)]; its CO2 (372) and PE (472) bill on the Table 12d / 12e monthly factors for fuel code 50 ("electricity for pumping in distribution network"), weighted by the monthly heat profile per worksheet footnote (a). (307)m = space-heating fuel and (310)m = water-heating fuel: for a heat network the cascade models the heat-generator efficiency as 1/DLF, so fuel = q_useful / efficiency = q_useful × DLF. The monthly weighting of the Table 12d/12e factor is shape-only (the DLF scalar cancels), and the energy carries the DLF. Returns (energy_kwh, co2_factor, pe_factor) for heat-network mains (Table 4a 301-304 / category 6); None otherwise so the default 0.0 / None fields leave individually-heated certs unchanged. NB Elmhurst's worksheet DISPLAYS the (372) energy column as 0.01 × (307) (space only) but computes the EMISSIONS on 0.01 × (307+310) per the §C3.2 text — verified line-by-line against the community- heating corpus worksheets. We mirror the spec text (space + water). """ if not _is_heat_network_main(main) or efficiency <= 0.0: return None distribution_monthly_kwh = tuple( _HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT * (sh + hw) / efficiency for sh, hw in zip( space_heating_monthly_kwh, hot_water_output_monthly_kwh ) ) energy_kwh = sum(distribution_monthly_kwh) if energy_kwh <= 0.0: return None co2_monthly = co2_monthly_factors_kg_per_kwh( _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE ) pe_monthly = pe_monthly_factors_kwh_per_kwh( _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE ) if co2_monthly is None or pe_monthly is None: return None co2_factor = sum( kwh * f for kwh, f in zip(distribution_monthly_kwh, co2_monthly) ) / energy_kwh pe_factor = sum( kwh * f for kwh, f in zip(distribution_monthly_kwh, pe_monthly) ) / energy_kwh return (energy_kwh, co2_factor, pe_factor) # SAP 10.2 Table 12 fuel code 302's worksheet path. Community heating # "CHP and boilers" (Table 4a code 302). _SAP_CODE_COMMUNITY_CHP_AND_BOILERS: Final[int] = 302 # SAP 10.2 Table 12f (PDF p.196) — fuel factors for the electricity # GENERATED BY CHP (the displaced-grid credit, worksheet (364)/(464)). # The BRE-approved Elmhurst rdSAP engine applies the "flexible # operation" row (0.420 kg CO2/kWh, 2.369 PE) to RdSAP-defaulted # community CHP that is not in the PCDB — verified line-by-line against # the CH2/CH4/CH6 corpus worksheets (363)..(366) / (463)..(466). Table # 12f's own notes make "standard" (0.348 / 2.149) the default and # require assessor evidence for "flexible"; we mirror the certified # engine per [[feedback-software-no-special-handling]] (documented as a # spec divergence in SAP_CALCULATOR.md §8). _TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH: Final[float] = 0.420 _TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH: Final[float] = 2.369 # RdSAP 10 §C (PDF p.58) heat-network CHP defaults when the network is # not in the PCDB: CHP overall efficiency 75% with heat-to-power ratio # 2.0 → heat efficiency 50% (worksheet (362)) + electrical efficiency # 25% (worksheet (361)); back-up boiler efficiency 80% (worksheet # (367)). The CHP heat fraction (0.35 default) is per-cert via # `community_heating_chp_fraction`. _HEAT_NETWORK_CHP_HEAT_EFFICIENCY: Final[float] = 0.50 _HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY: Final[float] = 0.25 _HEAT_NETWORK_CHP_BOILER_EFFICIENCY: Final[float] = 0.80 def _heat_network_code_302_effective_factor( main: Optional[MainHeatingDetail], *, primary_energy: bool, ) -> Optional[float]: """SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating "CHP and boilers" (SAP code 302) — the effective per-kWh factor to apply to the network heat fuel [(307) + (310)]. Per unit of network heat fuel H = (307) + (310), the worksheet sums: CHP fuel (363)/(463) = chp_frac × 100/(362) × f_fuel less credit (364)/(464) = −chp_frac × (361)/(362) × f_disp boiler fuel (368)/(468) = (1−chp_frac) × 100/(367) × f_fuel where f_fuel is the Table 12 heat-network fuel factor (the CHP unit and the back-up boilers burn the same community fuel — verified vs CH2 gas / CH4 oil / CH6 coal worksheets) and f_disp is the Table 12f credit factor for the electricity the CHP generates. RdSAP 10 §C defaults: (362) heat eff 50%, (361) electrical eff 25%, (367) boiler eff 80%. Returns the blended factor for code-302 mains with the CHP-split fields populated; None otherwise so callers fall through to the existing single-fuel / heat-source-efficiency-scaling path. NB the worksheet PDF DISPLAYS the (368)/(468) boiler emissions rounded/mis-aligned, but the (373)/(473)/(386)/(486) totals reconcile only with the boiler at the FULL Table 12 fuel factor — verified EXACT. """ if ( main is None or main.sap_main_heating_code != _SAP_CODE_COMMUNITY_CHP_AND_BOILERS ): return None chp_fraction = main.community_heating_chp_fraction boiler_fuel_code = main.community_heating_boiler_fuel_type if chp_fraction is None or boiler_fuel_code is None: return None if primary_energy: fuel_factor = primary_energy_factor(boiler_fuel_code) displaced_factor = _TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH else: fuel_factor = co2_factor_kg_per_kwh(boiler_fuel_code) displaced_factor = _TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH boiler_fraction = 1.0 - chp_fraction return ( chp_fraction * (1.0 / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY) * fuel_factor - chp_fraction * ( _HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY ) * displaced_factor + boiler_fraction * (1.0 / _HEAT_NETWORK_CHP_BOILER_EFFICIENCY) * fuel_factor ) @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 with NO # boiler interlock because they lack a room thermostat (or an equivalent # device). SAP 10.2 §9.4.11 (PDF p.66) is explicit: "A boiler system with # no room thermostat (or a device equivalent in this context, such as a # flow switch or boiler energy manager), even if there is a cylinder # thermostat, must be considered as having no interlock", and "TRVs alone # (other than some communicating TRVs) do not perform the boiler interlock # function". A *fixed bypass* likewise provides no interlock — it exists to # keep water circulating when the TRVs close. The Group-1 rows without a # room thermostat / flow switch / boiler energy manager are therefore: # 2101 "No time or thermostatic control of room temperature" # 2102 "Programmer, no room thermostat" # 2107 "Programmer, TRVs and bypass" ← bypass ≠ interlock # 2111 "TRVs and bypass" ← bypass ≠ interlock # (2108 "Programmer, TRVs and flow switch" and 2109 "… boiler energy # manager" carry an interlock-equivalent device, so they are INTERLOCKED # and excluded; 2103-2106/2113 all carry a room thermostat.) Each of these # triggers 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 at the call site. # NB this is the interlock criterion only — the separate "+0.6 °C" Table 4e # temperature adjustment applies to 2101/2102 alone (it lives in # `_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE`, where 2107/2111 stay at 0.0). _BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES: Final[frozenset[int]] = frozenset( {2101, 2102, 2107, 2111} ) # 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 _roof_insulation_location_is_determined( value: Optional[Union[int, str]] ) -> bool: """Whether a building part lodges a *determined* roof-insulation location — an RdSAP integer code (1-7: at-rafters, loft, flat-roof, …), meaning the unit has an exposed roof to insulate. The gov-EPC API lodges the string "ND" (Not Defined) when there is no exposed roof: the ceiling is a party surface (another dwelling above), so there is nowhere to put roof insulation. `None`/empty is likewise "no signal". """ if isinstance(value, int): return True if isinstance(value, str): return value.strip().upper() not in ("", "ND") return False def _cert_lodges_exposed_roof(parts: list[SapBuildingPart]) -> bool: """Whether the main building part lodges a genuine exposed (heat-loss) roof — keyed on the structured `roof_insulation_location` field, NOT a description string or `roof_construction`. The gov-EPC API lodges the *building's* `roof_construction` on every unit (incl. mid-floor ones whose ceiling is party), so it is not a per-unit exposure signal. `roof_insulation_location`, by contrast, is "ND" (Not Defined) exactly when the unit's ceiling is a party surface (no roof to insulate) and a real RdSAP location code when the roof is exposed. On the RdSAP-21.0.1 corpus this separates the two classes with zero disagreement: all 190 party-ceiling flats lodge "ND"; every mid/ground-floor flat with a determined location is genuinely top-storey. Motivating cases (gov-API certs lodge `dwelling_type` as the raw assessor label, which can contradict the fabric): property 715363 (uprn 6027561, location code 6 = flat roof) + sibling 715395 lodge "Mid-floor flat" yet have their own exposed roof over a dwelling below — top-floor flats mislabelled mid-floor; the correctly labelled top-floor sibling 715871 (same block, same roof) computes the lodged SAP 74. Dropping the roof under-read space-heating demand ~32% / over-read SAP +7. Of the corpus mid/ground-floor flats this exposes, 4/4 move toward the lodged SAP, 0 away. Reads the MAIN part (parts[0]): a flat's storey position is a whole-dwelling property, and `DwellingExposure` is a single global flag that `heat_transmission` applies to every part — so a multi-part flat whose main ceiling is party (e.g. only an extension has an exposed roof) correctly stays party rather than over-counting the main party ceiling. """ if not parts: return False return _roof_insulation_location_is_determined(parts[0].roof_insulation_location) def _dwelling_exposure( dwelling_type: Optional[str], parts: Optional[list[SapBuildingPart]] = None, ) -> 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. The lodged fabric overrides a contradictory label: when the main part lodges a determined roof-insulation location (`_cert_lodges_exposed_roof`), the roof is heat-loss even if the label suppressed it (a top-floor flat lodged as "Mid-floor"). The override is additive — it only ever *exposes* a roof the label dropped, never hides a lodged party ceiling — so a true mid-floor flat (roof_insulation_location "ND") is unaffected. """ base = _dwelling_exposure_from_type(dwelling_type) if not base.has_exposed_roof and parts and _cert_lodges_exposed_roof(parts): return replace(base, has_exposed_roof=True) return base def _dwelling_exposure_from_type(dwelling_type: Optional[str]) -> DwellingExposure: """The `dwelling_type`-label-only exposure map (RdSAP 10 §3 prefixes). `_dwelling_exposure` layers the lodged-fabric override on top.""" 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 §2 (Ventilation, "Walls" row): "Structural infiltration: 0.25 for steel or timber frame or 0.35 for masonry construction ... System build: treated as masonry." So only wall_construction code 5 (timber frame) takes the lower 0.25 structural ACH; code 6 (system build) is explicitly masonry (0.35), as is everything else. (Park homes also take the timber-frame value per the same spec row, but that is a dwelling-type flag, not a wall_construction code, and is out of scope here.)""" if not parts: return False wc = parts[0].wall_construction return isinstance(wc, int) and wc == 5 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 _water_heating_main_space_fraction( epc: EpcPropertyData, secondary_fraction: float ) -> float: """Fraction of TOTAL space heating provided by the DHW boiler — the SAP 10.2 Appendix D §D2.1(2) Equation D1 Q_space weight. Eq D1's monthly water-heater efficiency blends η_winter / η_summer by the ratio of the boiler's space-heating load to its water load. On a single-main / WHC-901 cert that load is the whole main share, (202) = 1 − (201). On a dual-main cert the DHW boiler does ONLY its own share — (204) for Main 1, (205) for Main 2 — so feeding it the dwelling total over-weights η_winter and under-states HW fuel (simulated case 6: Main 1 serves DHW + 51% of space heat; using 100% of demand gave HW −78 kWh vs the worksheet).""" details = epc.sap_heating.main_heating_details if epc.sap_heating else [] main_fraction = 1.0 - secondary_fraction # (202) if len(details) < 2: return main_fraction main_2 = details[1] main_2_of_main = ( main_2.main_heating_fraction / 100.0 if main_2.main_heating_fraction is not None else 0.0 ) if _water_heating_main(epc) is details[1]: return main_fraction * main_2_of_main # (205) — DHW from Main 2 return main_fraction * (1.0 - main_2_of_main) # (204) — DHW from Main 1 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 ) # §12 Unknown-meter exception: "water heating ... intended to run off an # off-peak tariff" via the text-box "dual electric immersion" system — # whc 903 (HW from a separate electric immersion) lodged as dual # (`_immersion_is_single` is False). This makes an Unknown-meter dwelling # "dual" even when the main is a single-rate-capable system (e.g. room # heaters), per RdSAP 10 §12 (PDF p.62). water_dual_immersion = ( _int_or_none(epc.sap_heating.water_heating_code) == _WHC_ELECTRIC_IMMERSION and _immersion_is_single(epc) is False ) 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), water_is_off_peak_dual_immersion=water_dual_immersion, ) 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: fuel = epc.sap_heating.water_heating_fuel # When DHW is on a heat network, the colliding community fuels # 30/31/32 take their Table 12 community row rather than the # same-numbered electricity code — see # `_heat_network_community_fuel_code`. community = _heat_network_community_fuel_code(fuel, _water_heating_main(epc)) if community is not None: return community # Normalise colliding gov-API solid-fuel enum codes (see # `_main_fuel_code`) before the shared price/CO2/PE lookups. return canonical_fuel_code(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 heat network the cascade can cost/emission-rate: a registered single-source heat-source efficiency (`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — SAP code 301 boilers / 304 HP) OR code 302 (CHP and boilers). 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 rate. SAP code 302 (CHP+boilers) was previously excluded because the 35%/65% split needs the displaced-electricity credit line (spec block 12b/13b (364)/(366)/(464)/(466)). S0380.182 wired that credit via `_heat_network_code_302_effective_factor`, which intercepts the HW CO2/PE helpers ABOVE this predicate's branch — so including 302 here now affects only the COST path, routing HW cost through `_fuel_cost_gbp_per_kwh(main)` = the S0380.171 CHP heat-fraction blend (the same rate as space heating, worksheet (342) = (310) × blend). Closes the CH2/CH4 HW cost residual (S0380.183). """ 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 or code == _SAP_CODE_COMMUNITY_CHP_AND_BOILERS ) def _heat_network_standing_charge_gbp( epc: EpcPropertyData, main: Optional[MainHeatingDetail] ) -> Optional[float]: """SAP 10.2 Table 12 note (l) + §C3.2 heat-network standing charge, or None when the dwelling is not on a heat network (caller then falls back to the fuel-based `additional_standing_charges_gbp`). A heat network carries the Table 12 £120/yr standing charge regardless of the network fuel — full when the SPACE heating is on the network (§C3.2 "the total standing charge is the normal heat network standing charge"), halved to £60 when ONLY DHW is provided by the heat network (note (l)). This REPLACES the fuel-based gas/off-peak standing for a heat-network main, so it must not be added on top of `additional_standing_charges_gbp` (which would double-count: a Summary-path community-gas main lodges Table-32 code 1 and already draws the £120 gas standing). Worksheet-validated: simulated case 14 (community boilers + mains gas, space + water) → (351) = £120. The API path under-counted this: an EPC community fuel (e.g. 20 = mains gas community) is not a Table-32 gas code, so `_is_gas_code` returned False and the standing came out £0 — cert 9390 lost the whole £120. """ if _is_heat_network_main(main): return _HEAT_NETWORK_STANDING_CHARGE_GBP if _is_community_heating_hw_from_main(epc): return _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0 return None 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.""" return _main_heating_detail_efficiency(_first_main_heating(epc), epc) def _main_heating_detail_efficiency( main: Optional[MainHeatingDetail], epc: EpcPropertyData ) -> float: """SAP 10.2 (206)/(207) efficiency (0..1) for a SPECIFIC main heating detail — the per-detail core of `_main_heating_efficiency`. Used for both main system 1 (206) and main system 2 (207) on dual-main certs (cert 0240 / simulated case 6).""" 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 = _dwelling_age_band(epc) # Worksheet (307): heat required = demand × (305) × (306 DLF), so the # delivered-per-fuel efficiency carries 1 / ((305) charging factor × # DLF). The Table 4c(3) flat-rate charging penalty raises demand. eff = 1.0 / ( _heat_network_dlf(primary_age) * _heat_network_space_charging_factor(main) ) 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 } # Gov-API community fuel enum codes (waste 30 / biomass 31 / biogas 32) # whose VALUE collides with a Table-32 electricity tariff code of the same # number (30 standard / 31 7-hour-low / 32 7-hour-high). Per # `epc_codes.csv` these are unambiguously "(community)" fuels, but the # bare Table-32 codes 30/31/32 are ALSO used internally as grid # electricity (e.g. `_STANDARD_ELECTRICITY_FUEL_CODE = 30` written by the # no-water-heating immersion default), so the community meaning is only # authoritative when the main is a heat network — see # `_heat_network_community_fuel_code`. _API_COMMUNITY_COLLISION_FUELS: Final[frozenset[int]] = frozenset({30, 31, 32}) def _heat_network_community_fuel_code( fuel: int, main: Optional[MainHeatingDetail] ) -> Optional[int]: """Translate a gov-API community fuel enum to its SAP Table 12 community fuel code WHEN the main is a heat network; else return None so the caller keeps `fuel` unchanged. Community fuels 30 (waste) / 31 (biomass) / 32 (biogas) collide in value with the Table-32 electricity codes 30/31/32. Without this translation `is_electric_fuel_code` flags a community-scheme main as electric and `_is_electric_main` routes its cost through the off-peak electricity branch — bypassing the heat-network rate (`_heat_network_factor_fuel_code`) entirely. Per RdSAP 10 §C / SAP 10.2 Table 12 the community waste/biomass/biogas rows are codes 42/43/44 (the same rows the backwards-compat enum codes 11/12/13 map to). Cert 8536 (biomass community, SAP code 301) closed -17.2 → -6.5. Gating on `_is_heat_network_main` keeps the bare Table-32 code 30 the cascade uses internally as grid electricity untouched on non-community certs (e.g. cert 2211 whose whc=999 default writes `water_heating_fuel=30`). Raises `UnmappedSapCode` when a heat-network main lodges a colliding community fuel the translation table doesn't cover — surfacing the gap loudly instead of silently mis-pricing it as grid electricity, per the strict-raise principle ([[reference-unmapped-sap-code]]). """ if fuel not in _API_COMMUNITY_COLLISION_FUELS or not _is_heat_network_main(main): return None translated = API_FUEL_TO_TABLE_12.get(fuel) if translated is None: raise UnmappedSapCode("heat_network_community_fuel", fuel) return translated 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): # Heat-network community fuels 30/31/32 collide with electricity # Table-32 codes — translate to the community row before anything # else so the main isn't mis-classified as electric. community = _heat_network_community_fuel_code(fuel, main) if community is not None: return community # Normalise the colliding gov-API solid-fuel enum codes (5 # anthracite / 9 dual fuel / 33 coal) to their canonical Table # 32/12 codes here — at the fuel-TYPE boundary — so the shared # price/CO2/PE table lookups (which also receive electricity # TARIFF codes 31/33 for the dual-rate split) never confuse a # coal fuel-type 33 with the electricity-10h tariff code 33. return canonical_fuel_code(fuel) raise MissingMainFuelType(fuel, main.sap_main_heating_code) # Fuel codes the Table 12 / Table 32 factor & price lookups recognise as a # DIRECT key (vs falling through to their mains-gas default). The union of # every per-fuel column the cost/CO2/PE cascade consumes, plus the values # `API_FUEL_TO_TABLE_12` translates to (all valid Table-12 codes). A code in # this set — or translatable into it via the API enum map — is priced/ # factored correctly; a code in NEITHER would silently default to mains gas. _RECOGNISED_TABLE_12_FUEL_CODES: Final[frozenset[int]] = frozenset( set(CO2_KG_PER_KWH) | set(PRIMARY_ENERGY_FACTOR) | set(UNIT_PRICE_P_PER_KWH) | set(CO2_KG_PER_KWH_MONTHLY) | set(PE_FACTOR_MONTHLY) | set(API_FUEL_TO_TABLE_12.values()) ) def _table_12_factor_fuel_code(fuel: int) -> int: """`API_FUEL_TO_TABLE_12.get(fuel, fuel)` with a STRICT tail. Returns the Table-12 factor code for the cost / CO2 / PE lookups, with behaviour identical to the prior silent passthrough for every recognised input. The one difference: when the resolved code is neither translatable via the API enum map NOR already a recognised Table-12/32 fuel code, it raises `UnmappedSapCode` instead of passing the unknown code through to the mains-gas default baked into `unit_price_p_per_kwh` / `co2_factor_kg_per_kwh` / `primary_energy_factor` (the silent fuel- collision class — cert 8536's community biomass mis-priced as grid electricity was this pattern). Mirror of the strict-raise principle ([[reference-unmapped-sap-code]]). """ code = API_FUEL_TO_TABLE_12.get(fuel, fuel) if code in _RECOGNISED_TABLE_12_FUEL_CODES: return code raise UnmappedSapCode("table_12_factor_fuel", fuel) def _heat_network_factor_fuel_code( main: Optional[MainHeatingDetail], ) -> Optional[int]: """Fuel code to feed the Table 12 / Table 32 factor lookups, with the EPC→Table-12 translation applied for heat-network (community) mains. The EPC `main_fuel_type` enum and the SAP Table 12 / Table 32 fuel-code numbering COLLIDE in the 18-25 range: `epc_codes.csv` lists 20='mains gas (community)', 21='LPG (community)', 22='oil (community)', ..., whereas Table 12/32 code 20-25 are solid biomass fuels. The factor lookups (`co2_factor_kg_per_kwh` / `primary_energy_factor` / `unit_price_p_per_kwh`) check the Table-12/32 dict FIRST, so an EPC community fuel 20 silently returns the biomass factor (CO2 0.028, PE 1.046, wood-logs price) instead of community mains gas (CO2 0.210, PE 1.130, mains-gas price + £120 standing charge). Resolution: for a heat-network main, translate the EPC community fuel to its Table-12 code via `API_FUEL_TO_TABLE_12` (20->51) so the lookups hit the heat-network row. NON-heat-network mains are returned unchanged so a genuine biomass boiler (EPC 6 wood logs / 12 biomass, etc.) keeps its raw Table-12 factor. The Summary path is unaffected — it maps "Mains gas - community" to code 1 (no collision). Worksheet-validated: simulated case 14 (community boilers + mains gas, SAP code 301) → (367) CO2 factor 0.2100, (467) PE factor 1.1300. """ fuel = _main_fuel_code(main) if fuel is None or not _is_heat_network_main(main): return fuel return _table_12_factor_fuel_code(fuel) 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(_heat_network_factor_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 # 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 } # SAP Table 4a electric room-heater codes (panel/convector/radiant 691, # fan 692, portable 693, water-/oil-filled 694, "electric heaters assumed" # 699) — the same set RdSAP 10 §12 Rule 3 (PDF p.62) routes to the 10-hour # tariff. They are direct-acting electric for the Table 12a Grid 1 SH split. _ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( {691, 692, 693, 694, 699} ) # SAP 10.2 Table 4a electric boilers (PDF p.170, codes 191-196) → their # Table 12a Grid 1 SH rows (PDF p.191). NOTE the boiler families do NOT all # share a row — read the spec exactly: # 191 Direct-acting electric boiler → "Direct-acting electric boiler # (a)" row: 7-hour 0.90, 10-hour 0.50 (NOT the "Other direct-acting # electric heating" 1.00/0.50 room-heater row). # 192 Electric CPSU → "Electric CPSU" row: Appendix F # (no flat Table 12a fraction — left as a documented gap, see below). # 193/194 Electric dry core storage boiler → "Electric dry core or water # 195/196 Electric water storage boiler storage boiler" row: 7-hour # 0.00 (charged wholly off-peak — identical to the 100%-low-rate the # None fallback already gave; mapped EXPLICITLY so the spec-correct # 0.00 is pinned and can't be "fixed" up to a wrong direct-acting 1.00). _DIRECT_ACTING_ELECTRIC_BOILER_CODES: Final[frozenset[int]] = frozenset({191}) _ELECTRIC_STORAGE_BOILER_CODES: Final[frozenset[int]] = frozenset( {193, 194, 195, 196} ) _ELECTRIC_CPSU_CODES: Final[frozenset[int]] = frozenset({192}) 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 (cat 7): 408 → INTEGRATED_STORAGE_DIRECT (0.20), all others → OTHER_STORAGE_HEATERS (0.00) — wired - Underfloor heating (421-422) — TODO - Direct-acting electric boiler (191) → 0.90/0.50; electric storage boilers (193/194/195/196) → 0.00 — wired - Electric CPSU (192) — Appendix F high-rate cascade still 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 ) # Electric room heaters are direct-acting electric → SAP 10.2 Table # 12a Grid 1 (PDF p.191) "Other systems including direct-acting # electric" row (7-hour high-rate fraction 1.00, 10-hour 0.50). # Identified EITHER by RdSAP main_heating_category 10 OR by a Table 4a # electric room-heater SAP code (691-694 panel/fan/portable/water- # filled, 699 "electric heaters assumed" — the SAME set RdSAP 10 §12 # Rule 3 (PDF p.62) routes to the 10-hour tariff). The "No system # present: electric heaters assumed" lodging (code 699) carries # main_heating_category 1, NOT 10, so the category-only gate missed it # and it fell through to None → 100% off-peak LOW rate, billing # direct-acting heaters as if they charged overnight like storage # (cert 2958 +32.2 SAP, the worst over-rate in the sample). Distinct # from electric STORAGE heaters (category 7), which DO charge off-peak # and correctly fall through to None here (→ 100% low rate). Gated on # `_is_electric_main` so a non-electric room heater (gas / solid-fuel # cat 10) is excluded; all callers already pre-gate on electric. if _is_electric_main(main) and ( main.main_heating_category == 10 or (code is not None and code in _ELECTRIC_ROOM_HEATER_SAP_CODES) ): return Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC # A PCDB Table 362 record IS a heat pump by definition (the Appendix-N # efficiency cascade keys off it), whether or not a Table-4a SAP code # (211-227 / 521-524) was ALSO lodged. API-path heat pumps resolve via # the PCDB index alone (data_source=1, sap_main_heating_code None), so # the code-range gate below misses them and they fell through to None # → the "100% off-peak low-rate" fallback, OVER-crediting the cat-4 # cluster on Dual meters (cert 9472 +15.0 SAP). Route any PCDB heat # pump to ASHP_APP_N: SAP 10.2 Table 12a Grid 1 (PDF p.191) gives the # ASHP/GSHP Appendix-N rows the same 0.80 SH high-rate fraction at # 7-hour and 10-hour, so ASHP_APP_N is the canonical Appendix-N row # for the space-heating cost split. if has_pcdb_hp: return Table12aSystem.ASHP_APP_N # ASHP — Table 4a rows 211-217 (earlier generations) + 221-227 # (2013+) cover the air-source space. Warm-air ASHPs are 521-524. # Reached only when no PCDB record is present (handled above), so the # "from database" variant never applies here → ASHP_OTHER. if code is not None and ( 211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524 ): return Table12aSystem.ASHP_OTHER # Electric STORAGE heaters (RdSAP main_heating_category 7) — SAP 10.2 # Table 12a Grid 1 (PDF p.191). Code 408 is an "Integrated (storage + # direct-acting) system" → 0.20 SH high-rate fraction at 7-hour; every # other storage code is "Other storage heaters" → 0.00 (charged wholly # off-peak, the same 100%-low-rate the None fallback already gave). # Gated on `_is_electric_main` belt-and-braces (all callers pre-gate). if main.main_heating_category == 7 and _is_electric_main(main): if code == 408: return Table12aSystem.INTEGRATED_STORAGE_DIRECT return Table12aSystem.OTHER_STORAGE_HEATERS # Electric boilers (Table 4a codes 191-196) — resolve the misleading TODO # that lumped them as one "direct-acting" family. They split across THREE # distinct Table 12a Grid 1 rows (see `_DIRECT_ACTING_ELECTRIC_BOILER_CODES` # et al). 191 alone is direct-acting (0.90/0.50); 193-196 are storage # boilers (0.00 = 100% low, the spec-correct value the None fallback gave — # so this is a forward guard, not a corpus mover); 192 CPSU needs Appendix F # and is left to fall through to None (the off-peak-low fallback) until the # Appendix-F high-rate cascade is implemented. if code is not None and _is_electric_main(main): if code in _DIRECT_ACTING_ELECTRIC_BOILER_CODES: return Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER if code in _ELECTRIC_STORAGE_BOILER_CODES: return Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE 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 _main_2_space_heating_fuel_cost_gbp_per_kwh( epc: EpcPropertyData, tariff: Tariff, prices: PriceTable, ) -> float: """Main heating system 2's space-heating fuel rate (£/kWh) for the legacy/off-peak scalar cost path. Keyed on main 2's OWN fuel via the same `_space_heating_fuel_cost_gbp_per_kwh` logic (off-peak Table 12a split when main 2 is electric, flat Table 32 rate otherwise) — so a wood-log or other non-electric second main is not billed at main 1's electric rate. Returns 0.0 when no second main is lodged (multiplied by main 2's 0 kWh). Scoped to a NON-electric second main (the unambiguous correction): a wood/ oil/coal second main billed at main 1's rate is plainly wrong. An ELECTRIC second main keeps main 1's space-heating scalar — its off-peak Table 12a high/low split is the deferred §10a off-peak slice, and applying the split per-system here regresses the off-peak electric cohort (certs 13 Parkers Hill / 34 Dunley Road).""" details = epc.sap_heating.main_heating_details if epc.sap_heating else None if not details or len(details) < 2: return 0.0 main_2 = details[1] if _is_electric_main(main_2): return _space_heating_fuel_cost_gbp_per_kwh(details[0], tariff, prices) return _space_heating_fuel_cost_gbp_per_kwh(main_2, tariff, prices) def _main_space_heating_high_rate_fraction( main: Optional[MainHeatingDetail], tariff: Tariff, ) -> float: """SAP 10.2 Appendix M1 §3a (PDF p.93) — the fraction of the main space-heating fuel that is billed at the HIGH rate in Section 10a, i.e. carries an "electricity not at the low-rate" fuel code (30, 32, 34, 35 or 38). Only this high-rate portion of E_space,m may enter the PV-eligible demand D_PV,m; the low-rate portion (code 31/33/36/37/39) is excluded. Mirrors `_space_heating_fuel_cost_gbp_per_kwh`'s rate split exactly so the D_PV inclusion and the §10a billing stay consistent: - non-electric main, or STANDARD tariff → 1.0 (no off-peak split; the eligible-code gate in `_pv_eligible_demand_monthly_kwh` already excludes non-electric fuels, and a STANDARD-tariff electric main bills 100% at code 30). - electric main on an off-peak tariff whose Table 12a Grid 1 SH row is wired → the published high-rate fraction. Electric STORAGE heaters (Table 12a `_table_12a_system_for_main` → None, charged wholly off-peak) and any system whose Grid 1 SH row is not yet wired bill 100% at the low rate → fraction 0.0, so E_space,m is excluded from D_PV entirely (worksheet (240) high-rate cost = 0). """ if not _is_electric_main(main) or tariff is Tariff.STANDARD: return 1.0 system = _table_12a_system_for_main(main) if system is None: return 0.0 try: return space_heating_high_rate_fraction(system, tariff) except NotImplementedError: return 0.0 def _hot_water_fuel_cost_gbp_per_kwh( water_heating_fuel: Optional[int], main: Optional[MainHeatingDetail], tariff: Tariff, prices: PriceTable, *, water_heating_code: Optional[int] = None, inherit_main_for_community_heating: bool = False, cylinder_volume_l: Optional[float] = None, occupancy_n: Optional[float] = None, immersion_single: Optional[bool] = None, ) -> 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. HP-DHW exception: when DHW is heated by the main system (WHC ∈ {901, 902, 914}) and that main is a PCDB Table 362 heat pump, the HW bills per SAP 10.2 Table 12a Grid 1 WH column (PDF p.191) — the ASHP/GSHP-from-database row carries a 0.70 high-rate fraction at 7-hour and 10-hour, NOT 100% off-peak low rate. Electric IMMERSION exception (WHC 903): Table 12a's "Immersion water heater" row (PDF p.191) routes the WH column to Table 13 (PDF p.197). The Table 13 high-rate fraction — a function of cylinder volume, assumed occupancy and single-/dual-immersion — gives the proportion billed at the high rate, the remainder at the low rate. Without it the immersion HW billed 100% at the off-peak low rate, under-costing the dwelling and over-rating it (median +0.98 SAP across the off-peak WHC-903 API cohort). Needs `cylinder_volume_l` + `occupancy_n`; when `immersion_single` is unlodged (None) this branch is on an off-peak / dual meter, so per RdSAP 10 §10.5 (PDF p.54) the immersion is assumed DUAL rather than falling back to the 100%-low-rate scalar (Elmhurst applies the dual blend, e.g. uprn_10022893721 high-rate fraction 0.1386). Only an unresolvable cylinder volume / occupancy now falls back to the 100%-low-rate scalar. `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: if ( water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES and main is not None and main.main_heating_index_number is not None and heat_pump_record(main.main_heating_index_number) is not None ): high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff) high_frac = water_heating_high_rate_fraction( Table12aSystem.ASHP_APP_N, tariff ) blended = high_frac * high_rate + (1.0 - high_frac) * low_rate return blended * _PENCE_TO_GBP if ( water_heating_code == _WHC_ELECTRIC_IMMERSION and cylinder_volume_l is not None and occupancy_n is not None ): # RdSAP 10 §10.5 (PDF p.54): an immersion is assumed DUAL on a dual / # off-peak meter. This branch is only reached on an off-peak tariff # (tariff is not STANDARD ⇒ the meter is dual / Economy-7), so when the # cert does not lodge `immersion_heating_type` default to dual rather # than dropping to the 100%-low-rate fallback. The fallback under-costs # the DHW and over-rates the dwelling, whereas Elmhurst applies the # Table 13 dual blend (e.g. uprn_10022893721 high-rate fraction 0.1386). effective_single = ( immersion_single if immersion_single is not None else False ) high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff) high_frac = electric_dhw_high_rate_fraction( cylinder_volume_l=cylinder_volume_l, occupancy_n=occupancy_n, single_immersion=effective_single, tariff=tariff, ) blended = high_frac * high_rate + (1.0 - high_frac) * low_rate return blended * _PENCE_TO_GBP 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 _hot_water_high_rate_fraction( water_heating_fuel: Optional[int], main: Optional[MainHeatingDetail], tariff: Tariff, *, water_heating_code: Optional[int] = None, inherit_main_for_community_heating: bool = False, cylinder_volume_l: Optional[float] = None, occupancy_n: Optional[float] = None, immersion_single: Optional[bool] = None, ) -> float: """ADR-0014 Bill Derivation — the hot-water High-Rate Fraction (the day/high- rate share) on an Off-Peak Meter, mirroring `_hot_water_fuel_cost_gbp_per_kwh` branch-for-branch so the bill's day/night HW split matches the rating's: - community-heating HW inheriting a (non-electric) main, non-electric HW, or STANDARD tariff → 1.0 (single rate, no split); - HP-DHW (the WHC inherits a PCDB Table 362 heat-pump main) → Table 12a Grid 1 WH `ASHP_APP_N` fraction (0.70 at 7-/10-hour); - electric immersion (WHC 903) with a known cylinder + occupancy → the Table 13 dual-immersion fraction (§10.5 assumes a DUAL immersion on a dual meter); - any other electric off-peak HW (e.g. heated by a storage main) → 0.0 (the timer charges it wholly at the night/low rate).""" if inherit_main_for_community_heating: return 1.0 if not _is_electric_water(water_heating_fuel) or tariff is Tariff.STANDARD: return 1.0 if ( water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES and main is not None and main.main_heating_index_number is not None and heat_pump_record(main.main_heating_index_number) is not None ): return water_heating_high_rate_fraction(Table12aSystem.ASHP_APP_N, tariff) if ( water_heating_code == _WHC_ELECTRIC_IMMERSION and cylinder_volume_l is not None and occupancy_n is not None ): effective_single = ( immersion_single if immersion_single is not None else False ) return electric_dhw_high_rate_fraction( cylinder_volume_l=cylinder_volume_l, occupancy_n=occupancy_n, single_immersion=effective_single, tariff=tariff, ) return 0.0 def _secondary_high_rate_fraction(epc: EpcPropertyData, tariff: Tariff) -> float: """ADR-0014 Bill Derivation — the secondary-heating High-Rate Fraction on an Off-Peak Meter. Non-electric or standard-tariff secondary → 1.0. Electric secondary heaters are portable/direct-acting (Table 4a room heaters), so they take the Table 12a Grid 1 `OTHER_DIRECT_ACTING_ELECTRIC` row (1.0 at 7-hour, 0.50 at 10-hour) — run on demand, mostly at the day/high rate. Tariffs Table 12a omits (18-/24-hour) fall back to 1.0 (high).""" if tariff is Tariff.STANDARD: return 1.0 if not is_electric_fuel_code(_secondary_fuel_code(epc)): return 1.0 try: return space_heating_high_rate_fraction( Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, tariff ) except NotImplementedError: return 1.0 def _secondary_fraction( main: Optional[MainHeatingDetail], secondary_heating_type: object, secondary_lodged: bool = False, unheated_habitable_rooms: bool = False, ) -> 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. `secondary_lodged` covers the gov-API path: the register publishes the secondary as a DESCRIPTION (`secondary_heating.description`, e.g. "Portable electric heaters (assumed)") even when the integer `secondary_heating_type` code is absent. The description is authoritative — a lodged secondary description means RdSAP assessed a secondary (per §A.2.2 the assumed system is portable electric heaters) and its Table 11 fraction must be costed. Without this a gas/oil boiler main with an assumed portable-electric secondary dropped the secondary entirely (sec_kWh=0), under-costing the dwelling and over-rating its SAP by a clean systematic +2.7 (median). `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 or secondary_lodged force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES # SAP 10.2 Appendix A.2.2 — when the main system does not heat every # habitable room, the unheated rooms are assumed to be served by a # portable-electric secondary heater, so the Table 11 fraction is costed # even with no lodged secondary (the secondary fuel/efficiency cascade # already defaults to portable electric, code 693, when no code lodged). if not has_lodged_secondary and not force and not unheated_habitable_rooms: 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 _has_unheated_habitable_rooms(epc: EpcPropertyData) -> bool: """SAP 10.2 Appendix A.2.2 — the main heating system does not heat every habitable room (heated rooms < habitable rooms), so the unheated rooms take an assumed portable-electric secondary heater. Prefers the lodged `any_unheated_rooms` flag (set on both the gov-API and Elmhurst paths). Falls back to the heated/habitable room-count comparison only when the heated count is a real positive value — a lodged `heated_rooms_count == 0` is the "not provided" sentinel on the gov-API path, not literally zero heated rooms, so it must not spuriously trigger the assumed secondary.""" if epc.any_unheated_rooms is not None: return epc.any_unheated_rooms return ( epc.heated_rooms_count > 0 and epc.habitable_rooms_count > 0 and epc.heated_rooms_count < epc.habitable_rooms_count ) def _has_lodged_secondary_description(epc: EpcPropertyData) -> bool: """True when the cert lodges a secondary-heating DESCRIPTION (the gov-API path surfaces the secondary as `secondary_heating.description`, e.g. "Portable electric heaters (assumed)", even when the integer `secondary_heating_type` code is None). RdSAP treats a lodged secondary as costed (§A.2.2), so this gates the Table 11 fraction.""" sec = epc.secondary_heating if sec is None: return False desc = getattr(sec, "description", None) return desc is not None and desc not in ("None", "") 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_off_peak_rate_gbp_per_kwh(meter_type: object) -> float: """SAP 10.2 Table 12a Grid 1 (PDF p.191) blended rate for an electric secondary heater on an off-peak tariff. The secondary is a direct- acting electric room heater (RdSAP 10 §A.2.2 default), so it sits on the "Other systems including direct-acting electric" row — high-rate fraction 1.00 for 7-hour, 0.50 for 10-hour. NOT the 100%-low-rate of storage-charging: a room heater runs on demand, mostly at the high rate. Worksheet evidence — simulated case 19 (242): "Space heating - secondary (1.00*15.29 + 0.00*5.50)" → all at the 7-hour HIGH rate. Mirrors `_space_heating_fuel_cost_gbp_per_kwh`: the meter resolves to a tariff (the `_is_off_peak_meter` Unknown-code-3 heuristic falls through to 7-hour, as in `_off_peak_low_rate_gbp_per_kwh_via_meter_ heuristic`); 18-/24-hour tariffs (absent from the Grid 1 direct-acting row) fall back to the tariff's Table 32 low rate.""" tariff = tariff_from_meter_type(meter_type) if tariff is Tariff.STANDARD: tariff = Tariff.SEVEN_HOUR try: high_frac = space_heating_high_rate_fraction( Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, 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 _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 _secondary_off_peak_rate_gbp_per_kwh(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 _secondary_off_peak_rate_gbp_per_kwh(meter_type) # Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose # value collides with Table-32 9 = LPG SC11F) before the price lookup, # exactly as the main-fuel boundary does — otherwise the same-value # Table lookup mis-prices the secondary at the colliding fuel's rate. return prices.unit_price_p_per_kwh(canonical_fuel_code(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 # gov-API `sap_energy_source.pv_connection` enum (RdSAP 10 §11.1 / # SAP 10.2 Appendix M — "PV is included in the dwelling's assessment only # if connected to the dwelling's electricity meter"): # 0 = no PV # 1 = PV present but NOT connected to the dwelling's own meter # 2 = PV connected to the dwelling's own meter # Validated on the RdSAP-21.0.1 corpus (57 PV certs): pv_connection=1 certs # reconcile to the lodged SAP only WITHOUT a credit (MAE 4.48→1.22, 0/5 need # it); pv_connection=2 certs need it (MAE 0.98 vs 10.29 without). Accredited # Elmhurst proof: identical dwelling = SAP 87 connected vs SAP 74 not. _PV_CONNECTION_CONNECTED_TO_DWELLING_METER: Final[int] = 2 def _pv_connected_to_dwelling_meter(epc: EpcPropertyData) -> bool: """Whether a lodged PV array may be credited to this dwelling's SAP, i.e. whether it is connected to the dwelling's own electricity meter. Keyed on the gov-API integer `pv_connection`: only value 2 ("connected") earns a credit; value 1 ("present but not connected" — a communal / separately-metered array) contributes nothing to the dwelling's energy cost, CO2 or primary energy, per RdSAP 10 §11.1 / SAP 10.2 Appendix M. A non-integer `pv_connection` (None, or the site-notes `str` form which does not yet capture the connection flag) is NOT a determinate "not connected" signal, so it preserves the existing credit-if-array behaviour — no regression on the Elmhurst/Summary path or synthetic CalculatorInputs. The Elmhurst extractor parses "Connected to the dwelling's meter" today only as a parse stop-token; capturing its value is a follow-up that would let this gate apply to that path too. """ pv_connection = epc.sap_energy_source.pv_connection if isinstance(pv_connection, int): return pv_connection == _PV_CONNECTION_CONNECTED_TO_DWELLING_METER return True 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. Returns 0 when the array is not connected to the dwelling's own meter (`_pv_connected_to_dwelling_meter` — gov-API `pv_connection=1`), per RdSAP 10 §11.1 / SAP 10.2 Appendix M. """ if not _pv_connected_to_dwelling_meter(epc): return 0.0 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 — None when the cert lodged 'ND', else a code outside 1..8).""" if array.orientation is None: return (0.0,) * 12 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. Returns all-zero when the array is not connected to the dwelling's own meter (`_pv_connected_to_dwelling_meter`), so the §10a cost split and the CO2 / PE cascades all see no PV — mirroring the annual helper's gate.""" if not _pv_connected_to_dwelling_meter(epc): return (0.0,) * 12 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, ...], secondary_fuel_monthly_kwh: tuple[float, ...], hot_water_monthly_kwh: tuple[float, ...], main_fuel_code_table_12: Optional[int], secondary_fuel_code_table_12: Optional[int], water_heating_fuel_code_table_12: Optional[int], main_space_high_rate_fraction: float = 1.0, ) -> 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 (main AND secondary space heating) only for the electric tariffs eligible for PV self-use (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. `main_space_high_rate_fraction` scales the main-heating contribution by the portion billed at the HIGH rate (code 30) in Section 10a. Per the §3a inclusion rule "(211) should be included only where the fuel code applied to it in Section 10a is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)", off-peak electric mains (e.g. storage heaters charged wholly at the low rate, fraction 0.0) must NOT add their (211) to D_PV. Defaults to 1.0 → unchanged for STANDARD-tariff electric mains and the gas-main / electric-secondary cohort. Without this, off-peak storage-heater dwellings over-counted D_PV by the full (211) in winter, inflating R_PV,m → β → the onsite PV split (case 19: β_Jan 0.894 → 0.792, matching worksheet 0.791). Secondary space heating is included on the same footing as main: Appendix M1 §3a counts E_space,m as the dwelling's total electric space-heating demand, which for a gas-main / electric-secondary dwelling is the (215)m secondary fuel. Omitting it understates D_PV,m in the heating months only — depressing the monthly β → onsite split and under-crediting PV primary energy (the calc-vs- worksheet (233a) gap localised on the cohort-2 gas+PV certs: cert 3136 onsite 726.9 → 790.3 vs worksheet 792.1). The off-peak immersion × (243) Ewater branch is deferred. The Appendix G4 PV-diverter saving is intentionally NOT reflected here: per the §3a note (PDF p.93, lines 5485-5486) "If there is a PV diverter, then for the purposes of this β factor calculation (219)m should not include the diverter savings" — so D_PV uses the pre-diverter (219), and the diverter (63b)m is applied afterwards in `_pv_diverter_monthly_kwh`.""" include_main_space = ( main_fuel_code_table_12 is not None and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES ) include_secondary_space = ( secondary_fuel_code_table_12 is not None and secondary_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_main_space: d += main_space_high_rate_fraction * main_1_fuel_monthly_kwh[m] if include_secondary_space: d += secondary_fuel_monthly_kwh[m] if include_water: d += hot_water_monthly_kwh[m] monthly.append(d) return tuple(monthly) # SAP 10.2 Appendix G4 step 4 (PDF p.73) — correction factors applied to # the surplus PV available to the diverter: 0.8 for the cylinder's # ability to accept the heat, and fPV,diverter,storageloss = 0.9 for the # increased cylinder losses from storing water at a higher temperature. _PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR: Final[float] = 0.8 _PV_DIVERTER_STORAGE_LOSS_FACTOR: Final[float] = 0.9 def _pv_diverter_monthly_kwh( *, epc: EpcPropertyData, pv_export_monthly_kwh: tuple[float, ...], water_demand_monthly_kwh: tuple[float, ...], avg_daily_hot_water_l: float, battery_capacity_kwh: float, pv_generation_kwh: float, ) -> Optional[tuple[float, ...]]: """SAP 10.2 Appendix G4 (PDF p.72-73) — monthly PV-diverter water- heating input SPV,diverter,m (positive kWh), entered as the negative worksheet (63b)m. `pv_export_monthly_kwh` is the pre-diverter surplus EPV,m × (1 − βm) — the portion of PV generation not consumed by the dwelling's instantaneous demand, which would otherwise be exported. Per G4 step 4: SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss clamped to ≤ (62)m + (63a)m (`water_demand_monthly_kwh`; (63a) the WWHRS reduction, 0 here) so the diverter never supplies more than the water-heating demand. Returns None — diverter disregarded by software (G4 step 1) — unless ALL four inclusion conditions hold: a. a PV system connected to the dwelling supply (EPV > 0); b. a cylinder whose volume exceeds (43) the average daily hot-water use; c. no solar water heating present; d. no battery storage present. `pv_diverter_present` (Summary §19 / API `pv_diverter`) gates the whole calculation: an absent diverter returns None immediately. """ if not epc.sap_energy_source.pv_diverter_present: return None # a. PV connected to the dwelling (case "a" Appendix M1 step 2). if pv_generation_kwh <= 0.0: return None # b. Cylinder volume (litres) must exceed (43) average daily HW use. cylinder_volume_l = _hot_water_cylinder_volume_l(epc) if cylinder_volume_l is None or cylinder_volume_l <= avg_daily_hot_water_l: return None # c. No solar water heating. d. No battery storage. if epc.solar_water_heating or battery_capacity_kwh > 0.0: return None correction = ( _PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR * _PV_DIVERTER_STORAGE_LOSS_FACTOR ) return tuple( min(pv_export_monthly_kwh[m] * correction, water_demand_monthly_kwh[m]) for m in range(12) ) # 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( tariff: Tariff, prices: PriceTable ) -> float: """PV dwelling-consumption price per kWh per SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "apply the normal import electricity price to PV energy used within the dwelling … In the case of the former, use a weighted average of high and low rates (Table 12a)." Onsite-consumed PV displaces the dwelling's "all other uses" electricity (lighting / appliances / pumps), so it bills at the same Table 12a Grid 2 ALL_OTHER_USES rate `_other_fuel_cost_gbp_per_kwh` derives — a STANDARD-tariff dwelling pays the flat Table 32 code 30 13.19 p/kWh (unchanged from the legacy single-rate path), while an off-peak dwelling pays the weighted high/low blend (7-hour: 0.90 × 15.29 + 0.10 × 5.50 = 14.311 p/kWh, matching worksheet (252)/(269) "PV used in dwelling" on case 19). Pre-S0380.233 the off-peak branch returned the bare low rate (5.50 p/kWh), under-crediting onsite PV on every off-peak cert.""" return _other_fuel_cost_gbp_per_kwh(tariff, prices) 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 _other_uses_high_rate_fraction(tariff: Tariff) -> float: """ADR-0014 Bill Derivation — the ALL_OTHER_USES High-Rate Fraction (the day/high-rate share) for lighting / appliances / cooking / pumps on an Off-Peak Meter, mirroring `_other_fuel_cost_gbp_per_kwh`'s tariff handling so the bill's split matches the rating's: STANDARD → 1.0 (single rate); 7-/10- hour → SAP 10.2 Table 12a Grid 2; 18-hour → 1.0 (all other uses bill at the high rate per SAP 10.2 Appendix F2); 24-hour → 1.0 (a heating-only tariff — the Fuel Rates snapshot carries no separate non-heating rate, so other uses bill at the off-peak day rate, a documented approximation for a rare tariff). Pumps/fans reuse this fraction; the Table 12a Grid 2 MEV/MVHR `FANS_FOR_MECH_ VENT` distinction the SAP cost path applies is a small second-order effect on a small load and is deferred for the bill.""" if tariff is Tariff.STANDARD: return 1.0 try: return other_use_high_rate_fraction(OtherUse.ALL_OTHER_USES, tariff) except NotImplementedError: return 1.0 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}) # Hot-water-only heat-network codes — SAP 10.2 Table 4a HW section (PDF # p.167): 950 boilers / 951 CHP / 952 heat pump. The DHW is supplied by a # heat network independent of the space-heating system, so RdSAP 10 §10 # (spec p.36 "water heating only ... for plant efficiency, distribution loss # and pumping energy — see Table 12c") requires the Table 12c distribution # loss factor applied on top of the Table 4a plant efficiency. Distinct from # the inherit-from-main codes: the DLF fires on the WHC regardless of whether # the *main* is a heat network (e.g. cert 9093, whc 950 + warm-air main 502). _WATER_HEAT_NETWORK_ONLY_CODES: Final[frozenset[int]] = frozenset({950, 951, 952}) # 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) """ # SAP 10.2 §12b — community-heating CHP+boilers (code 302): the # blended CHP-credit + boiler generation CO2 factor (S0380.182). code_302_co2 = _heat_network_code_302_effective_factor( main, primary_energy=False, ) if code_302_co2 is not None: return code_302_co2 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. scaling = _heat_network_heat_source_efficiency_scaling(main) hn_fuel = _main_fuel_code(main) if _is_heat_network_electric_main(main) and hn_fuel is not None: # Electric-HP heat network (code 304 / fuel 41): the HP runs # on grid electricity → MONTHLY Table 12d factors weighted by # the network heat profile, then × 1/COP (S0380.184). monthly = _effective_monthly_co2_factor( main_fuel_monthly_kwh, hn_fuel, ) if monthly is not None: return monthly * scaling return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling 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 codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) system = _table_12a_system_for_main(main) if system is None: # An electric main on a dual tariff with no Table 12a Grid 1 row is # an off-peak STORAGE system (storage heaters / electric storage # boiler / CPSU): it charges 100% off-peak per the Table 12a design # intent, so its monthly CO2 factor is the dual-rate LOW code # cascade — NOT the flat annual factor. case-20 storage on E7: # code 31 → (261) 0.1357, vs the 0.136 annual fallback. if codes is not None: low_only = _effective_monthly_co2_factor(main_fuel_monthly_kwh, codes[1]) if low_only is not None: return low_only 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) 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).""" # SAP 10.2 §13b — community-heating CHP+boilers (code 302): the # blended CHP-credit + boiler generation PE factor (S0380.182). code_302_pe = _heat_network_code_302_effective_factor( main, primary_energy=True, ) if code_302_pe is not None: return code_302_pe 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. scaling = _heat_network_heat_source_efficiency_scaling(main) if _is_heat_network_electric_main(main) and fuel is not None: # Electric-HP heat network (code 304 / fuel 41): MONTHLY # Table 12e factors weighted by the network heat profile, # then × 1/COP (S0380.184). monthly = _effective_monthly_pe_factor( main_fuel_monthly_kwh, fuel, ) if monthly is not None: return monthly * scaling return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling 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, *, immersion_high_rate_fraction: Optional[float] = None, ) -> 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).""" # SAP 10.2 §12b — community-heating CHP+boilers (code 302) HW from # main: the same blended CHP-credit + boiler generation CO2 factor # as SH (S0380.182). Gated on WHC ∈ {901, 902, 914} so immersion- # heated DHW on a CHP network keeps the lodged electric factor. if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: code_302_co2 = _heat_network_code_302_effective_factor( _water_heating_main(epc), primary_energy=False, ) if code_302_co2 is not None: return code_302_co2 # 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) scaling = _heat_network_heat_source_efficiency_scaling(main) hn_fuel = _main_fuel_code(main) if _is_heat_network_electric_main(main) and hn_fuel is not None: # Electric-HP heat network HW (code 304 / fuel 41): MONTHLY # Table 12d factors weighted by the HW profile, × 1/COP # (S0380.184) — mirror of the SH branch. monthly = _effective_monthly_co2_factor( hw_monthly_kwh, hn_fuel, ) if monthly is not None: return monthly * scaling return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling 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 _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: # whc-903 electric IMMERSION with a Table 13 high-rate split: the # Elmhurst worksheet bills HW CO2 as two lines — "Water heating - # high rate cost" at the Table 12d high-rate code + "low rate cost" # at the low-rate code, weighted by the SAME Table 13 fraction the # COST path uses (simulated case 50: high 0.1475 + low 0.1238, # frac 0.1009). The flat-annual S0380.163 rule below was validated # only on HW-from-main "low-rate cost" certs (100% low) where no # high-rate split exists; it does NOT hold for the immersion split. if immersion_high_rate_fraction is not None: codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) if codes is not None: high_code, low_code = codes f_high = _effective_monthly_co2_factor(hw_monthly_kwh, high_code) f_low = _effective_monthly_co2_factor(hw_monthly_kwh, low_code) if f_high is not None and f_low is not None: return ( immersion_high_rate_fraction * f_high + (1.0 - immersion_high_rate_fraction) * f_low ) 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, *, immersion_high_rate_fraction: Optional[float] = None, ) -> 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.""" # SAP 10.2 §13b — community-heating CHP+boilers (code 302) HW from # main: same blended CHP-credit + boiler generation PE factor as SH # (S0380.182). Gated on WHC ∈ {901, 902, 914}. if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: code_302_pe = _heat_network_code_302_effective_factor( _water_heating_main(epc), primary_energy=True, ) if code_302_pe is not None: return code_302_pe # 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) scaling = _heat_network_heat_source_efficiency_scaling(main) hn_fuel = _main_fuel_code(main) if _is_heat_network_electric_main(main) and hn_fuel is not None: # Electric-HP heat network HW (code 304 / fuel 41): MONTHLY # Table 12e factors weighted by the HW profile, × 1/COP # (S0380.184) — mirror of the SH branch. monthly = _effective_monthly_pe_factor( hw_monthly_kwh, hn_fuel, ) if monthly is not None: return monthly * scaling return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_PEF table_12_code = ( fuel if fuel in PRIMARY_ENERGY_FACTOR else _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: # whc-903 immersion Table 13 split — PE mirror of the CO2 helper. # Elmhurst splits HW PE into Table 12e high-/low-rate electricity # weighted by the Table 13 fraction; the flat-annual S0380.163 rule # only holds for HW-from-main "low-rate cost" certs (no split). if immersion_high_rate_fraction is not None: codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) if codes is not None: high_code, low_code = codes f_high = _effective_monthly_pe_factor(hw_monthly_kwh, high_code) f_low = _effective_monthly_pe_factor(hw_monthly_kwh, low_code) if f_high is not None and f_low is not None: return ( immersion_high_rate_fraction * f_high + (1.0 - immersion_high_rate_fraction) * f_low ) 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 _electric_immersion_hw_high_rate_fraction( epc: EpcPropertyData, tariff: Tariff, *, cylinder_volume_l: Optional[float], occupancy_n: Optional[float], immersion_single: Optional[bool], ) -> Optional[float]: """SAP 10.2 Table 13 HW high-rate fraction for a whc-903 electric immersion on a 7-/10-hour off-peak tariff — the SAME split the cost path (`_hot_water_fuel_cost_gbp_per_kwh`) applies. The CO2/PE factor helpers blend the Table 12d/12e high- and low-rate electricity factors by this fraction, mirroring the worksheet's split "Water heating - high/low rate cost" lines (simulated case 50). Returns None when not a dual-rate immersion, when the cylinder/occupancy data Table 13 needs is missing, or on 18-/24-hour tariffs (no Table 12d/12e high/low split) — callers then keep the flat-annual S0380.163 factor.""" if tariff is Tariff.STANDARD: return None if epc.sap_heating.water_heating_code != _WHC_ELECTRIC_IMMERSION: return None if ( cylinder_volume_l is None or occupancy_n is None or immersion_single is None ): return None if _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) is None: return None return electric_dhw_high_rate_fraction( cylinder_volume_l=cylinder_volume_l, occupancy_n=occupancy_n, single_immersion=immersion_single, tariff=tariff, ) 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 # Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose value # collides with the LPG Table code) so the CO2/PE factor lookups resolve # to the lodged fuel — mirrors the main-fuel boundary + the cost side. code = canonical_fuel_code(code) or code if code in CO2_KG_PER_KWH: return code return _table_12_factor_fuel_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) if code == _STANDARD_ELECTRICITY_FUEL_CODE: # Secondary electric heaters are direct-acting (used on demand, # daytime) → on-peak. On a dual-rate meter they draw HIGH-rate # electricity, so the monthly Table 12d CO2 cascade keys on the # tariff's HIGH code, not the standard all-day code 30 — mirroring # the cost side billing secondary at the high rate (e.g. 15.29 p on # E7). case-20 secondary on E7: code 32 → (263) 0.1616, vs the # 0.15405 a code-30 weighting gives. STANDARD-tariff certs have no # dual codes → code 30 unchanged. dual_codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(_rdsap_tariff(epc)) if dual_codes is not None: code = dual_codes[0] 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 def _float_or_none(value: object) -> Optional[float]: """Coerce a lodged numeric (int / float / numeric string) to float, else None. Used for measured overrides like the cylinder declared loss factor (`cylinder_heat_loss`, kWh/day).""" if isinstance(value, bool): return None if isinstance(value, (int, float)): return float(value) if isinstance(value, str): try: return float(value.strip()) except ValueError: return None return None def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float: """RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from the MAIN building's wall construction. Timber frame / cob / park home → 100 kJ/m²K regardless of insulation. Masonry (stone, solid brick, cavity, system built) → 100 with internal insulation, else 250. Unknown / unmapped / curtain-wall constructions fall back to the masonry default (250). See the Table 22 constant comments above for the `wall_construction` / `wall_insulation_type` code sets. TMP feeds the §7 time constant τ = Cm/(3.6·H); a wrong (too-high) TMP slows the cooling rate, under-cuts the §7 temperature reduction, and over-states mean internal temperature → space heating. """ parts: list[SapBuildingPart] = epc.sap_building_parts or [] if not parts: return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K main: SapBuildingPart = parts[0] wall_code: Optional[int] = _int_or_none(main.wall_construction) if wall_code in _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: return _TMP_LOW_KJ_PER_M2_K # Wall code 8 is a park home (low-mass) ONLY when the dwelling really is # one; on the gov-API path code 8 is system built (masonry). Disambiguate # by property_type so an API system build is not mis-rated as low-mass. is_park_home: bool = (epc.property_type or "").strip().lower() == "park home" if wall_code == _TMP_PARK_HOME_OR_SYSTEM_BUILT_WALL_CODE and is_park_home: return _TMP_LOW_KJ_PER_M2_K if _int_or_none(main.wall_insulation_type) in _TMP_INTERNAL_WALL_INSULATION_CODES: return _TMP_LOW_KJ_PER_M2_K return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K @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, epc.sap_building_parts) 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, corridor_door_count=_corridor_door_count(epc), ) def _has_sheltered_corridor_wall(epc: EpcPropertyData) -> bool: """Whether the dwelling is accessed via an unheated corridor/stairwell. A SHELTERED alternative wall (`is_sheltered`, the RdSAP §5.9 wall-to-unheated-corridor surface) is the evidence that the dwelling's entrance faces an unheated corridor or stairwell. False for houses and exposed-gable flats (no sheltered alt wall lodged). """ return any( (bp.sap_alternative_wall_1 is not None and bp.sap_alternative_wall_1.is_sheltered) or (bp.sap_alternative_wall_2 is not None and bp.sap_alternative_wall_2.is_sheltered) for bp in (epc.sap_building_parts or []) ) def _corridor_door_count(epc: EpcPropertyData) -> int: """RdSAP §3.7 + Table 26 — number of doors opening to an unheated corridor/stairwell (each billed at U=1.4 on the sheltered wall). A sheltered alternative wall (`_has_sheltered_corridor_wall`) is the evidence that the dwelling is accessed via an unheated corridor, so its entrance door opens to that corridor. RdSAP convention assumes one such access door when the sheltered wall is present and the cert lodges at least one door; the remainder are external. Returns 0 when no sheltered alt wall is lodged (houses, exposed-gable flats) so the door channel is unchanged for every non-corridor dwelling. """ return 1 if _has_sheltered_corridor_wall(epc) and epc.door_count > 0 else 0 def _has_draught_lobby(epc: EpcPropertyData, sv: Optional[SapVentilation]) -> bool: """RdSAP 10 §2 (13) — presence of a draught lobby. Spec (RdSAP 10 Specification 10-06-2025, p.30, "Draught lobby"): "add infiltration 0.05 if draught lobby is not present, or use 0.0 if present. ... Flat or maisonette: Assume draught lobby if entrance door is facing corridor (heated or unheated) or stairwell." A sheltered corridor wall (`_has_sheltered_corridor_wall`) is exactly that evidence: the flat's entrance faces an unheated corridor/stairwell, so a draught lobby is assumed present regardless of the lodged value. Otherwise fall back to the lodged value — which, when undetermined, is the RdSAP "assume no draught lobby if cannot be determined" default for houses. """ if _has_sheltered_corridor_wall(epc): return True return bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False 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=_thermal_mass_parameter_kj_per_m2_k(epc), 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=_thermal_mass_parameter_kj_per_m2_k(epc), 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, secondary_lodged=_has_lodged_secondary_description(epc), unheated_habitable_rooms=_has_unheated_habitable_rooms(epc), ) # 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) # SAP 10.2 §9a two-main split (203)/(207): when a second main heating # system is lodged, (203) = its `main_heating_fraction` (% of main # heating it supplies) and (207) = its own seasonal efficiency. Cert # 0240 (2× oil code 130, 51/49) + simulated case 6 (oil code 127, # rads 51% + underfloor 49%) exercise this. details = epc.sap_heating.main_heating_details if epc.sap_heating else [] main_2 = details[1] if len(details) >= 2 else None main_2_of_main_fraction = 0.0 main_2_efficiency_value = 0.0 if main_2 is not None and main_2.main_heating_fraction is not None: main_2_of_main_fraction = main_2.main_heating_fraction / 100.0 main_2_efficiency_value = _main_heating_detail_efficiency(main_2, 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, main_2_of_main_fraction=main_2_of_main_fraction, main_2_efficiency_pct=main_2_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: u_value_known = bool(getattr(main, "floor_u_value_known", False)) # (a) a SUPPLIED floor U-value < 0.5 → sealed. RdSAP 10 §5 (PDF # p.29) splits (a)/(b) on whether a U-value is supplied: (a) is # the "U-value supplied" branch, (b) the "no U-value is supplied" # branch. A computed default U (an assumed / as-built uninsulated # floor) is NOT a supplied value, so it must NOT trigger (a) — it # falls through to (b). Without this gate the cascade marked an # as-built suspended-timber floor with default U=0.43 "sealed" # (0.1) where Elmhurst uses "unsealed" (0.2) — cert 001431 sim # case 2 worksheet (12)=0.2, dropping (25) effective ACH and # understating space heating ~450 kWh. main_floor_u = _main_floor_u_value(epc) if ( u_value_known and main_floor_u is not None and main_floor_u < _FLOOR_U_SEALED_THRESHOLD ): return True, True # (b) no U-value supplied: retro-fitted insulation → sealed; # otherwise unsealed. ins_type_str = (main.floor_insulation_type_str or "").strip().lower() 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: "Number of extract # fans if known; if number is unknown: [age-band default]." The default # is an UNKNOWN-fallback, NOT a floor: a genuinely-lodged count is used # as-is even when it is below the age-band default (e.g. a band H-M # dwelling lodging 2 fans is NOT bumped to the 3-fan default). The # Elmhurst Summary / RdSAP convention renders "0" as the form for # unknown, so a lodged 0 falls back to the default; any positive count # is taken literally. (Was `max(lodged, default)`, which over-applied # the default as a minimum and over-counted ventilation.) age_band = _dwelling_age_band(epc) or "" 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 = ( vc.intermittent_fans if vc.intermittent_fans > 0 else 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 (17) — q50 Blower-Door reading routes to `(18) = AP50/20 # + (8)` (preferred over AP4); (17a) — AP4/Pulse reading routes to # `(18) = 0.263 × AP4^0.924 + (8)`. Absent values fall through to the # components-based (16) ach. ap50 = sv.air_permeability_ap50_m3_h_m2 if sv is not None else None ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None # SAP 10.2 §2 (17) — AP50 Blower Door reading routes (18) via # `AP50 / 20 + (8)`, preferred over AP4 when both are lodged. ap50 = sv.air_permeability_ap50_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 # For a whole-house mechanical EXTRACT (MEV / dMEV) OR balanced- # with-heat-recovery (MVHR) system the lodged intermittent extract- # fan count (7a) is taken AS-IS — the Table 5 age-band default must # NOT be substituted for a lodged 0. On such a dwelling the fan # count is explicit (the mechanical system IS the ventilation), so # 0 means 0, not "unknown". Worksheet-proven on three 000565 builds: # "case 48" (dMEV) lodges (7a)=0 → SAP 57 exact; "case 49" (MVHR, # Vent Axia 500140) lodges (7a)=0 → the worksheet (8) openings line # is 0.0000 (our default had added 20 m³/h = 0.0723 ach, inflating # (22b)/(25)/(38) and the demand). The original 000565 fixture # lodges (7a)=2 → unchanged. MV-without-HR (mechanical_ventilation # =1) is EXCLUDED: forcing its lodged 0 regressed 47 Howsman / 18 # Jutland and is not worksheet-validated. if mv_kind in ( MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE, MechanicalVentilationKind.MVHR, ): intermittent_fans = vc.intermittent_fans # SAP 10.2 §2.6.6 equation (2): the (24a) MVHR effective-air-change # credit needs the in-use heat-recovery efficiency (23c) = raw PCDB # efficiency × Table 329 in-use factor (or the Table 4g default when # no PCDB record). None for non-MVHR kinds → ventilation_from_inputs # leaves the heat-recovery term at zero. mvhr_efficiency_pct: Optional[float] = None if mv_kind is MechanicalVentilationKind.MVHR: mvhr_values = _mvhr_system_values(epc) if mvhr_values is not None: mvhr_efficiency_pct = mvhr_values.in_use_efficiency_pct 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=_has_draught_lobby(epc, sv), 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_ap50=ap50, air_permeability_ap4=ap4, mv_kind=mv_kind, mv_system_ach=mv_system_ach, mvhr_efficiency_pct=mvhr_efficiency_pct, **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 (keep-hot, time # clock). Returned EXPLICITLY — the cascade default is now # the "without keep-hot" row, so the keep-hot value must be # delivered here rather than leaned on the default. return combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() # 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//dr87-*.pdf # and /sap worksheets/additional with api 2//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) # code 5 → Inaccessible (context-dependent — see Table 28 below, # resolved by `_cylinder_inaccessible_volume_l`) # code 6 → Exact (the lodged measured volume in litres, # `cylinder_volume_measured_l`; 20 API certs) _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = { 2: 110.0, 3: 160.0, 4: 210.0 } # RdSAP 10 §10.5 Table 28 (PDF p.55) — the "Inaccessible" descriptor's # size-to-use depends on the heating context, and the "Exact" descriptor # lodges its measured volume separately. _CYLINDER_SIZE_INACCESSIBLE: Final[int] = 5 _CYLINDER_SIZE_EXACT: Final[int] = 6 _CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L: Final[float] = 210.0 _CYLINDER_INACCESSIBLE_SOLID_FUEL_L: Final[float] = 160.0 _CYLINDER_INACCESSIBLE_DEFAULT_L: Final[float] = 110.0 # RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the size # of a hot-water cylinder is taken as according to Table 28." For a cylinder # present but with no size descriptor lodged (size code 0 / absent), the # baseline Table 28 default is the "Normal" row (110 L) — the same value # §10.7 instantiates as the first-row default. The context-dependent # Inaccessible 210/160 values are NOT applied here: they are tied to the # explicit "Inaccessible" descriptor (code 5) the assessor lodges # deliberately, not to a merely-unpopulated size field. _CYLINDER_SIZE_NOT_DETERMINED_L: Final[float] = 110.0 def _cylinder_inaccessible_volume_l(epc: EpcPropertyData) -> float: """RdSAP 10 §10.5 Table 28 (PDF p.55) — size to use for an "Inaccessible" cylinder (code 5): 210 L for off-peak electric DUAL immersion, 160 L from a solid-fuel boiler, otherwise 110 L.""" if _immersion_is_single(epc) is False and _is_off_peak_meter( epc.sap_energy_source.meter_type, fuel_is_electric=True ): return _CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L main = _first_main_heating(epc) if main is not None and main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES: return _CYLINDER_INACCESSIBLE_SOLID_FUEL_L return _CYLINDER_INACCESSIBLE_DEFAULT_L def _cylinder_volume_l_from_code(epc: EpcPropertyData) -> Optional[float]: """RdSAP 10 §10.5 Table 28 — resolve the HW cylinder volume (litres) from the lodged `cylinder_size` descriptor code. Codes 2/3/4 → 110/160/210; code 5 (Inaccessible) → context-dependent; code 6 (Exact) → the lodged measured volume. Returns None when no size code is lodged (or code 6 lodges no measured volume). Does NOT gate on `has_hot_water_cylinder` — callers apply that guard.""" size_code = _int_or_none(epc.sap_heating.cylinder_size) if size_code is None: return None if size_code == _CYLINDER_SIZE_EXACT: measured = _int_or_none(epc.sap_heating.cylinder_volume_measured_l) return float(measured) if measured is not None else None if size_code == _CYLINDER_SIZE_INACCESSIBLE: return _cylinder_inaccessible_volume_l(epc) return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) # RdSAP `immersion_heating_type` lodgement codes. Code 1 = DUAL immersion, # code 2 = SINGLE. Confirmed against RdSAP 10 §10.5 (PDF p.54 — an # immersion is "assumed dual" on a dual/off-peak meter) cross-checked # with the API cohort: code 1 sits 3.6:1 on dual meters (40 vs 11 single) # while code 2 sits on single meters (22 single vs 16 dual). This INVERTS # the unverified "1=single, 2=dual" annotation in an earlier handover — # the dual code (1) carries Table 13's small high-rate fraction, matching # the cohort's over-rating direction; treating code 1 as single overshot. _IMMERSION_TYPE_DUAL: Final[int] = 1 _IMMERSION_TYPE_SINGLE: Final[int] = 2 # 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 field 7-11 (cylinder insulation type) — code 2 = loose jacket, # which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor # L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4). _CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2 def _cylinder_storage_loss_insulation_label( insulation_type: "int | str | None", ) -> Optional[Literal["factory_insulated", "loose_jacket"]]: """Map the lodged cylinder_insulation_type code to the SAP 10.2 Table 2 loss-factor branch. Code 1 → factory-insulated, code 2 → loose jacket. Any other value (None / 0 / unknown) → None so the caller keeps the conservative no-storage-loss default rather than guessing a loss branch. Accepts the int / digit-string / None shapes `cylinder_insulation_type` arrives in across the two front-ends.""" code = _int_or_none(insulation_type) if code == _CYLINDER_INSULATION_TYPE_FACTORY: return "factory_insulated" if code == _CYLINDER_INSULATION_TYPE_LOOSE_JACKET: return "loose_jacket" return None # 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: # "Age band of main property A to F: 12 mm loose jacket", G/H → 25 mm # foam, I-M → 38 mm foam. Each entry is (cylinder_insulation_type, # thickness_mm); the loose-jacket branch is now plumbed (S0380.224) so # A-F resolves instead of raising. _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE: Final[dict[str, tuple[int, int]]] = { "A": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), "B": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), "C": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), "D": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), "E": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), "F": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), "G": (_CYLINDER_INSULATION_TYPE_FACTORY, 25), "H": (_CYLINDER_INSULATION_TYPE_FACTORY, 25), "I": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), "J": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), "K": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), "L": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), "M": (_CYLINDER_INSULATION_TYPE_FACTORY, 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` only when the main dwelling's age band is absent / outside A-M (no Table 29 row to apply). """ if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM: return epc age_band = _dwelling_age_band(epc) band = (age_band or "")[:1].upper() default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band) if default is None: raise UnmappedSapCode( "rdsap_10_7_default_cylinder_insulation_age_band", age_band ) insulation_type_code, thickness_mm = default 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=insulation_type_code, cylinder_insulation_thickness_mm=thickness_mm, cylinder_thermostat="Y", ) return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating) # SAP 10.2 p.24 "Heat networks" (c): the default storage loss for heat- # network DHW with no PCDB HIU and no lodged cylinder is "equivalent to a # cylinder of 110 litres and a factory insulation thickness of 50 mm". _HEAT_NETWORK_HIU_DEFAULT_INSULATION_MM: Final[int] = 50 def _apply_heat_network_hiu_default_store( epc: EpcPropertyData, ) -> EpcPropertyData: """SAP 10.2 p.24 "Heat networks" (c) — when domestic hot water is provided by a heat network and neither a PCDB Heat Interface Unit nor a lodged hot-water cylinder applies: "a measured loss of 1.72 kWh/day should be used, corrected using Table 2b. This is equivalent to a cylinder of 110 litres and a factory insulation thickness of 50 mm." RdSAP 10 Table 29 (PDF p.56) assumes a cylinder thermostat is present when DHW is from a heat network → Table 2b base temperature factor 0.60 (no ×1.3 absent-thermostat penalty). Mirrors `_apply_rdsap_no_water_heating_system_default`: rebinds the 110 L / 50 mm-factory store onto `epc` (keeping the community water- heating code + fuel) so every downstream §4 gate — storage loss (56), primary loss (59) and combi-loss suppression — sees the spec default. A heat network is NOT a combi boiler, so the injected cylinder zeroes the Table 3a keep-hot (61) loss via the `has_hot_water_cylinder` gate in `_water_heating_worksheet_and_gains`; without this the cascade wrongly billed a 600 kWh/yr keep-hot loss on heat-network DHW. No-op (returns `epc` unchanged) unless the DHW main is a heat network, no cylinder is lodged, and the network is not in the PCDB (an indexed network uses the PCDB HIU loss per branch (a)).""" if epc.has_hot_water_cylinder: return epc dhw_main = _water_heating_main(epc) if not _is_heat_network_main(dhw_main): return epc if dhw_main is not None and dhw_main.main_heating_index_number is not None: return epc sap_heating = replace( epc.sap_heating, cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L, cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY, cylinder_insulation_thickness_mm=_HEAT_NETWORK_HIU_DEFAULT_INSULATION_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} ) # SAP 10.2 Table 4c(2) boiler controls (21xx) that carry NO programmer / # time switch: 2101 "No time or thermostatic control", 2103 "Room # thermostat only", 2111 "TRVs and bypass", 2113 "Room thermostat and # TRVs". Every other 21xx control includes a programmer (2102/2104/2105/ # 2106 …) or time-and-temperature zone control (2110/2112). Used by the # RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed" rule below. _BOILER_CONTROLS_WITHOUT_PROGRAMMER: Final[frozenset[int]] = frozenset( {2101, 2103, 2111, 2113} ) # SAP 10.2 Table 4b (PDF p.168) gas/LPG/biogas boilers lodged pre-1998 # (fan-assisted flue 110-114 + balanced/open flue 115-119) plus the # pre-1998 liquid-fuel boilers (124 pre-1985, 125 1985-1997, 128 combi # pre-1998). Gas/LPG 101-109 and oil 126/127/129/130 are 1998-or-later. # Used by the RdSAP 10 §10.5 separate-timing rule: a 1998-or-later boiler # is always separately timed; a pre-1998 boiler only when a programmer # is present. _PRE_1998_BOILER_SAP_CODES: Final[frozenset[int]] = frozenset( set(range(110, 120)) | {124, 125, 128} ) 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 # SAP 10.2 Table 2b note b + RdSAP 10 §10.5.1 (PDF p.55): the ×0.9 # reduction reflects DHW timed separately from space heating on a # SHARED heat generator. When DHW is from a separate dedicated # water-heating-only system (water-heating code not "from main / # 2nd-main system" — e.g. 911 "Gas boiler/circulator for water # heating only") there is no shared timer to apply the ×0.9 against, # so the multiplier must not fire — the same principle as the WHC # 903 electric-immersion carve-out above. Simulated case 19 (electric # storage main + WHS 911 + 210 L loose-jacket cylinder) is the # worksheet case: (53) Temperature factor 0.6000 (not 0.54) and # (59)m primary loss h=5 (Jan 64.5792, not 43.31) both confirm the # DHW is not separately timed. if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: return False if not epc.has_hot_water_cylinder: return False # RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed": # No programmer, pre-1998 boiler → No # Programmer, pre-1998 boiler → Yes # Post-1998 boiler → Yes # i.e. DHW is NOT separately timed only when a pre-1998 boiler is # paired with a no-programmer control (Table 4c(2): room-thermostat- # only / TRV-only). Every other boiler+cylinder cert keeps the # separately-timed default — so the change is confined to old, low- # control stock (this lpg-boiler "before" worksheet: code 115 + 2113 # → (53) temperature factor 0.78, not 0.702). if ( main.main_heating_control in _BOILER_CONTROLS_WITHOUT_PROGRAMMER and main.sap_main_heating_code in _PRE_1998_BOILER_SAP_CODES ): return False return True 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 # SAP 10.2 Appendix N3.7 (PDF p.109): the heat-pump water-heating efficiency # (in-use factor × η_water) is "subject to a minimum efficiency of 100%" — # below that the direct-electric backup governs. _HP_WATER_HEATING_MIN_EFFICIENCY: Final[float] = 1.0 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 cert_volume_l = _cylinder_volume_l_from_code(epc) 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 ) # N3.7: in-use factor × η_water, subject to a minimum efficiency of 100% # (the direct-electric backup floors the heat pump's water heating). water_efficiency_pct = max( in_use_water * eta_water_3_pct / 100.0, _HP_WATER_HEATING_MIN_EFFICIENCY, ) 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 # SAP 10.2 Table 3 (PDF p.160) row 1 — a dedicated "boiler/circulator # for water heating only" (WHC 911 gas / 912 liquid / 913 solid / # 921-931 range cooker with boiler) is a heat generator feeding the # cylinder through a primary loop, so the loss applies regardless of # the space-heating main. Checked off `water_heating_code` (not # `main`) because for these certs the resolved DHW `main` is the # SPACE main (e.g. an electric storage heater, SAP code 402) — the # gas/oil water boiler isn't a `main_heating_detail`. Simulated case # 19 (storage main + WHS 911 + 210 L cylinder): worksheet (59) = 676.68 # kWh/yr — zero before this branch. if water_heating_code in _WATER_HEATING_BOILER_CIRCULATOR_CODES: return True # SAP 10.2 Table 3 (PDF p.160) zero-loss list names "Direct-acting # electric boiler" verbatim. RdSAP 10 §12 (p.62) classifies SAP code # 191 as the direct-acting electric boiler: its cylinder is immersion- # heated with no primary pipework, so no primary loss — even though it # lodges as main_heating_category 2 ("Boiler and radiators, electric") # and would otherwise hit the cat-{1,2} boiler branch below. Checked # before that branch so the electric-flat segment (cert 2474: WHC 901 # + code 191 + cylinder) no longer accrues ~1177 kWh/yr phantom loss. if main.sap_main_heating_code == _DIRECT_ACTING_ELECTRIC_BOILER_CODE: 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 — Normal/Medium/Large (codes 2/3/4), Inaccessible (5, context-dependent) and Exact (6, lodged measured volume). Returns None only when no cylinder is lodged. RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the size of a hot-water cylinder is taken as according to Table 28." When a cylinder IS present but no size descriptor resolves (size code 0 / absent, or Exact with no measured volume), fall back to the Table 28 baseline "Normal" default (110 L). Without this the cylinder resolved to None, silently dropping BOTH its storage loss and the Table 13 high-rate fraction, over-rating unsized-cylinder electric dwellings.""" if not epc.has_hot_water_cylinder: return None volume_l = _cylinder_volume_l_from_code(epc) if volume_l is not None: return volume_l return _CYLINDER_SIZE_NOT_DETERMINED_L def _immersion_is_single(epc: EpcPropertyData) -> Optional[bool]: """True for a single immersion, False for a dual immersion, None when the cert lodges no recognised `immersion_heating_type`. Maps the RdSAP code (1 = dual, 2 = single — see `_IMMERSION_TYPE_DUAL`). None makes the Table 13 high-rate-fraction caller fall back to the 100%-low-rate scalar rather than guess the immersion configuration. """ code = _int_or_none(epc.sap_heating.immersion_heating_type) if code == _IMMERSION_TYPE_DUAL: return False if code == _IMMERSION_TYPE_SINGLE: return True return None 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 the Table 3a # "without keep-hot" default on every cert lacking a PCDB Table # 105 boiler record — including all heat pump certs. combi_loss_override = zero_monthly # A non-PCDB combi (override still None here) has no PCDB-declared # keep-hot facility → `water_heating_from_cert` applies the Table 3a # "without keep-hot" row (600 × fu × n/365). Keep-hot rides only on the # PCDB boiler record, resolved above by `pcdb_combi_loss_override`. # 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, is_instantaneous_at_point_of_use=is_instantaneous, ) 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, is_instantaneous_at_point_of_use=is_instantaneous, ) 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 Table 3 (PDF p.160): "For heat networks apply the # formula above with p = 1.0 and h = 3 for all months." The h=3 # row applies regardless of the thermostat / separate-timing # lodgement (and so is robust to the community-fuel-as-electric # collision that would otherwise route DHW to the h=5 row). heat_network=_is_heat_network_main(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_thermostat_present( epc: EpcPropertyData, main: Optional[MainHeatingDetail], ) -> bool: """Whether a cylinder thermostat is present for the Table 2b temperature factor (absent → ×1.3 penalty on the storage loss). A lodged "Y" wins. Otherwise SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A cylinder thermostat should be assumed to be present when the domestic hot water is obtained from a heat network, an immersion heater, a thermal store, a combi boiler or a CPSU." RdSAP 10 Table 29 (PDF p.56) points the no-access default at this rule. So a cylinder heated by an immersion (WHC 903), a direct-acting electric boiler (SAP code 191 — electric-resistance, immersion-equivalent), or a heat network gets the base Table 2b factor (no absent-thermostat ×1.3). Cert 2474 (Summary path: WHC 901, main SAP 191, electric, no lodged cylinder thermostat) is the case: the dr87 worksheet lodges (53) temperature factor 0.6000 (thermostat present), and the "add cylinder thermostat" recommendation reads "SAP increase too small" because it is already assumed present. Without this the cascade applied ×1.3 and over-stated storage loss by ~378 kWh/yr (SAP −1.86).""" if epc.sap_heating.cylinder_thermostat == "Y": return True dhw_main = _water_heating_main(epc) if _is_heat_network_main(dhw_main): return True if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION: return True if ( dhw_main is not None and dhw_main.sap_main_heating_code == _DIRECT_ACTING_ELECTRIC_BOILER_CODE ): return True return False 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 # SAP 10.2 §4 branch a) (PDF p.136) — a lodged manufacturer's declared # cylinder loss factor (kWh/day, gov-API `cylinder_heat_loss`) replaces # the Table 2 V×L×VF computation. It does NOT need the insulation # type / thickness / volume (which the gov leaves None precisely # because the declared loss is lodged instead), so resolve it BEFORE # those guards — otherwise the storage loss is dropped entirely and the # dwelling over-rates (the declared-loss is typically ~1.5 kWh/day ≈ # 550 kWh/yr). The Table-2b temperature factor still applies (49)→(50). declared_loss = _float_or_none(getattr(sh, "cylinder_heat_loss", None)) volume_l = _cylinder_volume_l_from_code(epc) if declared_loss is not None: storage_56m = cylinder_storage_loss_monthly_kwh( volume_l=volume_l or 0.0, insulation_type="factory_insulated", # unused in the declared branch thickness_mm=0.0, # unused in the declared branch has_cylinder_thermostat=_cylinder_thermostat_present(epc, main), separately_timed_dhw=_table_2b_note_b_multiplier_applies(epc, main), declared_loss_kwh_per_day=declared_loss, ) # (57)m solar adjustment only when solar HW + a resolvable volume. if not epc.solar_water_heating or volume_l is None: 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) if volume_l is None: return None insulation_label = _cylinder_storage_loss_insulation_label( sh.cylinder_insulation_type ) if insulation_label is None: 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=insulation_label, thickness_mm=float(thickness_mm), has_cylinder_thermostat=_cylinder_thermostat_present(epc, main), # 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).""" # Use the §12-Rules-aware tariff (not the raw meter→tariff): it routes # an "Unknown" (code 3) meter with an electric storage / heat-pump / # room-heater main to its off-peak tariff (storage heaters can't run on # a single rate), so the off-peak branch below fires and the legacy # scalar fields bill the overnight charge at the low rate instead of # the standard 13.19 p/kWh. A non-electric Unknown-meter dwelling still # resolves STANDARD here, keeping the full §10a precompute. tariff = _rdsap_tariff(epc) 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 ) # Main heating system 2 is costed at ITS OWN fuel price (SAP 10.2 §10a # worksheet line (213) bills main system 2's fuel separately from main 1's # (211)) — Table 32 unit price keyed on main 2's fuel code. Pre-fix this # column reused `main_1_high_rate_gbp_per_kwh`, charging a dual-fuel second # main (e.g. wood logs SAP code 633, fuel 6 @ 4.23 p/kWh) at the main-1 # electric rate (13.19 p/kWh), grossly over-costing electric+wood # room-heater dwellings (cert 10032957680 "Copse Cottage" +£850/yr -> # SAP -23). Falls back to main 1's price only when no second main is lodged. main_2_detail = ( epc.sap_heating.main_heating_details[1] if epc.sap_heating and len(epc.sap_heating.main_heating_details or []) >= 2 else None ) main_2_fuel_code = _main_fuel_code(main_2_detail) main_2_high_rate_gbp_per_kwh = ( table_32_unit_price_p_per_kwh(main_2_fuel_code) * _PENCE_TO_GBP if main_2_fuel_code is not None else main_1_high_rate_gbp_per_kwh ) 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 ) heat_network_standing = _heat_network_standing_charge_gbp(epc, main) standing = ( heat_network_standing if heat_network_standing is not None else 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_2_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(_rdsap_tariff(epc), 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) # SAP 10.2 p.24 "Heat networks" (c) — heat-network DHW with no PCDB # HIU and no lodged cylinder uses a default 110 L / 50 mm-factory # store (storage + primary loss, no combi keep-hot). Rebinds before # the §4 cascade so every loss gate sees the spec default. epc = _apply_heat_network_hiu_default_store(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), ) ) # SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main # heating systems include two figures from this table." A genuine # second SPACE-heating main therefore contributes its own circulation # pump alongside Main 1's. The "second main heating system" test is the # same one §9a uses to split space-heating demand: a lodged # `main_heating_fraction > 0`. This excludes DHW-only second mains # (e.g. cert 000565 Main 2 = gas combi via WHC 914, fraction 0 — water # heating only, no space-heating circulation pump). Simulated case 6 # (dual oil boiler, 51% rads + 49% underfloor) lodges Main 1 "2013 or # later" (41 kWh) + Main 2 unknown-date (115 kWh) → worksheet (230c) # central-heating pump = 41 + 115 = 156. The Main 2 oil-boiler aux # (230d) is already summed in `_table_4f_additive_components`; this # adds only the circulation pump. _pumps_main_details = ( epc.sap_heating.main_heating_details if epc.sap_heating else [] ) if len(_pumps_main_details) >= 2: _pumps_main_2 = _pumps_main_details[1] _pumps_main_2_fraction = _pumps_main_2.main_heating_fraction if _pumps_main_2_fraction is not None and _pumps_main_2_fraction > 0: pumps_fans_kwh += _table_4f_circulation_pump_kwh(_pumps_main_2) 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.71 high-frac on # 7-hour, 0.58 on 10-hour) instead of `ALL_OTHER_USES` (0.90 / 0.80) # — see `_pumps_fans_fuel_cost_gbp_per_kwh`. Must mirror the # mechanical-ventilation fan terms summed into the total pumps/fans # at `_table_4f_additive_components` (230b decentralised MEV/extract) # + the (230a) MVHR fan: both are "Fans for mechanical ventilation # systems" in Grid 2, while flue fans / circulation pumps / solar HW # pump are "All other uses". The MVHR term was omitted when MVHR # landed, so an MVHR dwelling on off-peak billed its fan electricity # at 0.90 instead of 0.71 (case 50: +£5.87/yr, -0.23 SAP). Zero when # no mechanical-ventilation fan is lodged. mev_kwh_for_cost_split = ( _mev_decentralised_kwh_per_yr_from_cert(epc) + _mvhr_fan_kwh_per_yr_from_cert(epc) ) primary_age = _dwelling_age_band(epc) # 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: # η_space (N3.6) always replaces the Table 4a default — the heat # pump is the space main. η_water (N3.7a) applies ONLY when the DHW # is actually heated by that main (WHC "from main": 901/902/914). A # separate electric immersion (WHC 903) or other independent DHW # source keeps its own water efficiency (immersion = 100%), not the # HP's water SCOP — otherwise a HP-space + immersion-DHW dwelling # under-counts its hot-water fuel (case 45: water 2130 -> 1894 kWh, # +1.5 SAP, because 187.5% × 0.6 in-use = 112.5% was applied where # the worksheet (216) uses 100%). eff, apm_water_eff = apm_efficiencies if ( epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES ): water_eff = apm_water_eff 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. Worksheet # (310): heat required = (64) × (305a) × (306 DLF), so the DHW # efficiency also carries 1 / Table 4c(3) (305a) charging factor. water_eff = 1.0 / ( _heat_network_dlf(primary_age) * _heat_network_dhw_charging_factor(main) ) elif epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES: # HW-only heat network (whc 950/951/952): the Table 4a plant # efficiency is already in `water_eff`; apply the Table 12c # distribution loss on top per RdSAP 10 §10 (spec p.36 "water # heating only ... distribution loss"). q_generated = q_useful × # DLF, so delivered-per-fuel efficiency = plant_eff / DLF. Fires # on the WHC alone — the HW network is independent of the main. water_eff = water_eff / _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, secondary_lodged=_has_lodged_secondary_description(epc), unheated_habitable_rooms=_has_unheated_habitable_rooms(epc), ) # 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 # SAP 10.2 Appendix M1 §3a (p.93): D_PV,m sums E_L,m — the lighting # ELECTRICITY (Appendix L eq L10, = line (232)) — NOT the L12 # internal heat gain G_L,m = E_L,m × 0.85 (spec: "assuming 15%" of # lighting energy does not become internal heat). `lighting_ # monthly_w` is the L12 gain, so converting it W→kWh yields # 0.85 × E_L,m and understates D_PV by 15% of lighting — depressing # the monthly β onsite split and under-crediting PV primary energy # uniformly across the year (the residual left after S0380.187 on # the gas+PV certs: cert 3136 onsite 790.3 → 792.1 vs worksheet # 792.1). Recover E_L,m by scaling the (shape-identical) gain # profile to the annual E_L `lighting_kwh_per_yr` — mirroring the # (219)m hot-water scale-to-annual below. Same mismatch the cooking # term hit in S0380.73 (L18 gain vs L20 electricity); appliances # need no scaling (G_A = E_A, no 0.85 factor). Magnitude-only: the # shape-weighted lighting CO2/PE factor (Σkwh×f/Σkwh) is unchanged. lighting_gain_monthly_kwh = tuple( w * d * 24.0 / 1000.0 for w, d in zip( internal_gains_result.lighting_monthly_w, _DAYS_IN_MONTH ) ) _lighting_gain_total = sum(lighting_gain_monthly_kwh) lighting_monthly_kwh = ( tuple( g / _lighting_gain_total * lighting_kwh for g in lighting_gain_monthly_kwh ) if _lighting_gain_total > 0.0 else lighting_gain_monthly_kwh ) 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) _mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type) responsiveness_value = _responsiveness(main, tariff=_mit_tariff) living_area_fraction_value = _living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ) # SAP 10.2 Table 9b weighted R + p.186 two-systems-different-parts MIT. # A genuine second main (main_heating_fraction > 0 = (203)) contributes # its own responsiveness (Table 9b weighted average) and, when it # carries a different control type, its own rest-of-dwelling control # schedule. `_first_main_heating` is system 1 (living area); the second # detail is system 2. Single-main / DHW-only second mains (frac 0) pass # the None/0 defaults → unchanged single-system MIT. _mit_details = epc.sap_heating.main_heating_details if epc.sap_heating else [] _mit_main_2 = _mit_details[1] if len(_mit_details) >= 2 else None main_2_control_type_value: Optional[int] = None main_2_fraction_value = 0.0 main_2_responsiveness_value = 1.0 if ( _mit_main_2 is not None and _mit_main_2.main_heating_fraction is not None and _mit_main_2.main_heating_fraction > 0 ): main_2_control_type_value = _control_type(_mit_main_2) main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0 main_2_responsiveness_value = _responsiveness( _mit_main_2, tariff=_mit_tariff ) 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=_thermal_mass_parameter_kj_per_m2_k(epc), 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), main_2_control_type=main_2_control_type_value, main_2_fraction=main_2_fraction_value, main_2_responsiveness=main_2_responsiveness_value, 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. space_heating_monthly_useful_kwh: tuple[float, ...] = (0.0,) * 12 if wh_result is not None: # Eq D1 Q_space is the DHW boiler's OWN space-heating load — its # (204)/(205) share of total — not the dwelling total (202). See # `_water_heating_main_space_fraction`. water_main_space_fraction = _water_heating_main_space_fraction( epc, secondary_fraction_value ) space_heating_monthly_useful_kwh = tuple( q * water_main_space_fraction 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=_thermal_mass_parameter_kj_per_m2_k(epc), 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 ) # SAP 10.2 §9a two-main split (203)/(207) — see the section helper # `energy_requirements_section_from_cert` for the rationale. _main_details = epc.sap_heating.main_heating_details if epc.sap_heating else [] _main_2 = _main_details[1] if len(_main_details) >= 2 else None main_2_of_main_fraction = 0.0 main_2_efficiency_value = 0.0 if _main_2 is not None and _main_2.main_heating_fraction is not None: main_2_of_main_fraction = _main_2.main_heating_fraction / 100.0 main_2_efficiency_value = _main_heating_detail_efficiency(_main_2, epc) 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, main_2_of_main_fraction=main_2_of_main_fraction, main_2_efficiency_pct=main_2_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, secondary_fuel_monthly_kwh=energy_requirements_result.secondary_fuel_monthly_kwh, hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv, main_fuel_code_table_12=( _table_12_factor_fuel_code(main_fuel) if main_fuel is not None else None ), secondary_fuel_code_table_12=_secondary_fuel_code(epc), water_heating_fuel_code_table_12=( _table_12_factor_fuel_code(epc.sap_heating.water_heating_fuel) if epc.sap_heating.water_heating_fuel is not None else None ), # SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an # off-peak electric main from D_PV (the §10a high/low split that # `_space_heating_fuel_cost_gbp_per_kwh` already bills). main_space_high_rate_fraction=_main_space_heating_high_rate_fraction( main, _rdsap_tariff(epc), ), ) 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 Appendix G4 (PDF p.72-73) — PV diverter. The β factor above # is computed on the PRE-diverter (219) per the §3a note; now apply # the diverter saving. SPV,diverter,m diverts the surplus PV (the # would-be export EPV,m × (1 − βm)) into the cylinder immersion: # - (63b)m = −SPV,diverter,m reduces the §4 output (64)m → less main- # system water-heating fuel (219); # - the export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (the # diverted energy is no longer exported); the onsite dwelling # portion EPV,dw,m = EPV,m × βm is unchanged (the β is fixed). hw_output_monthly_for_factors = ( wh_result.output_monthly_kwh if wh_result is not None else (0.0,) * 12 ) pv_diverter_monthly_kwh = _pv_diverter_monthly_kwh( epc=epc, pv_export_monthly_kwh=pv_split.epv_exported_monthly_kwh, water_demand_monthly_kwh=( wh_result.total_demand_monthly_kwh if wh_result is not None else (0.0,) * 12 ), avg_daily_hot_water_l=( wh_result.annual_avg_hot_water_l_per_day if wh_result is not None else 0.0 ), battery_capacity_kwh=_pv_battery_capacity_kwh(epc), pv_generation_kwh=sum(pv_monthly_kwh), ) if pv_diverter_monthly_kwh is not None and wh_result is not None: pv63b_monthly_kwh = tuple(-s for s in pv_diverter_monthly_kwh) # (64)m = (62)m + (63a)m + (63b)m — reduce the §4 output by the # diverter input, then recompute (219) from the reduced output. hw_output_monthly_for_factors = tuple( max(0.0, wh_result.output_monthly_kwh[m] + pv63b_monthly_kwh[m]) for m in range(12) ) if section_12_4_4_blend is None: hw_kwh = _apply_water_efficiency( wh_output_monthly_kwh=hw_output_monthly_for_factors, wh_output_annual_kwh=sum(hw_output_monthly_for_factors), 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, ) # EPV,ex,m = EPV,m(1 − βm) + (63b)m / fPV,diverter,storageloss. adjusted_export_monthly_kwh = tuple( pv_split.epv_exported_monthly_kwh[m] + pv63b_monthly_kwh[m] / _PV_DIVERTER_STORAGE_LOSS_FACTOR for m in range(12) ) pv_split = PhotovoltaicSplit( beta_monthly=pv_split.beta_monthly, epv_dwelling_monthly_kwh=pv_split.epv_dwelling_monthly_kwh, epv_exported_monthly_kwh=adjusted_export_monthly_kwh, ) # SAP 10.2 Appendix M1 (PDF p.94): "EPV,ex,m = 0 if the PV system is not # connected to an export-capable meter." A non-export-capable dwelling # earns no export payment — only the onsite β consumption (EPV,dw) # offsets demand. Zero the exported stream so the §10a cost, CO2 and PE # export credits all drop out; the dwelling (onsite) portion and any # diverter HW reduction above are unchanged. (Without this the cascade # credited the full export — e.g. cert at 7 Wybourn Terrace S2 5BJ # over-rated +19 SAP: PE/CO2 matched the lodged figures but the export # cost credit alone pulled the rating from ~73 to 92.) if not epc.sap_energy_source.is_dwelling_export_capable: pv_split = PhotovoltaicSplit( beta_monthly=pv_split.beta_monthly, epv_dwelling_monthly_kwh=pv_split.epv_dwelling_monthly_kwh, epv_exported_monthly_kwh=(0.0,) * 12, ) # 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. The HW # factors weight by the diverter-adjusted (64)m output. hw_monthly_kwh_for_factors = hw_output_monthly_for_factors 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, water_heating_code=epc.sap_heating.water_heating_code, inherit_main_for_community_heating=_community_hw_inherit, cylinder_volume_l=_hot_water_cylinder_volume_l(epc), occupancy_n=wh_result.occupancy if wh_result is not None else None, immersion_single=_immersion_is_single(epc), ) # whc-903 immersion Table 13 high-rate fraction — same split the # cost path applies above; threaded into the CO2/PE factor helpers # so the worksheet's high/low HW lines reconcile (simulated case 50). _hw_immersion_high_frac = _electric_immersion_hw_high_rate_fraction( epc, _rdsap_tariff(epc), cylinder_volume_l=_hot_water_cylinder_volume_l(epc), occupancy_n=wh_result.occupancy if wh_result is not None else None, immersion_single=_immersion_is_single(epc), ) hw_co2_factor = _hot_water_co2_factor_kg_per_kwh( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), immersion_high_rate_fraction=_hw_immersion_high_frac, ) hw_pe_factor = _hot_water_primary_factor( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), immersion_high_rate_fraction=_hw_immersion_high_frac, ) _hw_extra_standing = 0.0 _heat_network_standing = _heat_network_standing_charge_gbp(epc, main) standing_charges_total = ( _heat_network_standing if _heat_network_standing is not None else 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 # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution # pumping electricity (worksheet (313)/(372)/(472)). None for # individually-heated certs. heat_network_distribution = _heat_network_distribution_electricity( main, space_heating_result.total_space_heating_monthly_kwh, hw_monthly_kwh_for_factors, eff, ) # ADR-0014 Bill Derivation — Off-Peak Meter day/night billing metadata. # The off-peak `_fuel_cost` path returns the zero `FuelCostResult` (deferring # to the legacy scalar rates), so the bill's per-end-use High-Rate Fractions # are sourced here from the SAME Table 12a helpers the scalar cost path uses, # keeping the bill's day/night split identical to the rating's. _billing_tariff = _rdsap_tariff(epc) _is_off_peak_meter = _billing_tariff is not Tariff.STANDARD _main_high_rate_fraction = _main_space_heating_high_rate_fraction( main, _billing_tariff ) _main_2_detail = ( epc.sap_heating.main_heating_details[1] if epc.sap_heating and len(epc.sap_heating.main_heating_details) > 1 else None ) _main_2_high_rate_fraction = _main_space_heating_high_rate_fraction( _main_2_detail, _billing_tariff ) _secondary_high_rate_frac = _secondary_high_rate_fraction(epc, _billing_tariff) _hw_high_rate_fraction = _hot_water_high_rate_fraction( _water_heating_fuel_code(epc), _water_heating_main(epc), _billing_tariff, water_heating_code=( epc.sap_heating.water_heating_code if epc.sap_heating else None ), inherit_main_for_community_heating=_is_community_heating_hw_from_main(epc), cylinder_volume_l=_hot_water_cylinder_volume_l(epc), occupancy_n=wh_result.occupancy if wh_result is not None else None, immersion_single=_immersion_is_single(epc), ) _other_uses_fraction = _other_uses_high_rate_fraction(_billing_tariff) 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=_thermal_mass_parameter_kj_per_m2_k(epc), 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), # Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel # code column) for ADR-0014 BillDerivation fuel attribution. # Output-only — they tell the bill adapter WHICH carrier each end- # use burns and do NOT feed cost / CO2 / PE / sap_score (those are # already priced via the per-end-use factor fields below). Resolved # via the same helpers the cost/CO2 cascade uses: `_main_fuel_code` # (None when no main system), `_secondary_fuel_code`, and # `_water_heating_fuel_code` (None when the WHC fuel is not # resolvable). Main 2 is the second `main_heating_details` entry, # if any (None when the cert has a single main system). main_heating_fuel_code=_main_fuel_code(main), main_2_heating_fuel_code=_main_fuel_code( epc.sap_heating.main_heating_details[1] if epc.sap_heating and len(epc.sap_heating.main_heating_details) > 1 else None ), secondary_heating_fuel_code=_secondary_fuel_code(epc), hot_water_fuel_code=_water_heating_fuel_code(epc), is_off_peak_meter=_is_off_peak_meter, main_heating_high_rate_fraction=_main_high_rate_fraction, main_2_heating_high_rate_fraction=_main_2_high_rate_fraction, secondary_heating_high_rate_fraction=_secondary_high_rate_frac, hot_water_high_rate_fraction=_hw_high_rate_fraction, pumps_fans_high_rate_fraction=_other_uses_fraction, other_electricity_high_rate_fraction=_other_uses_fraction, space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh( main, _rdsap_tariff(epc), prices ), main_2_heating_fuel_cost_gbp_per_kwh=( _main_2_space_heating_fuel_cost_gbp_per_kwh( epc, _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 Appendix C §C3.2 (PDF p.51) — heat-network distribution # pumping electricity (worksheet (313)/(372)/(472)). 0.0 / None # on individually-heated certs. heat_network_distribution_kwh_per_yr=( heat_network_distribution[0] if heat_network_distribution is not None else 0.0 ), heat_network_distribution_co2_factor_kg_per_kwh=( heat_network_distribution[1] if heat_network_distribution is not None else None ), heat_network_distribution_primary_factor=( heat_network_distribution[2] if heat_network_distribution is not None else None ), # 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( _rdsap_tariff(epc), 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), )