Slice 35: Plumb postcode climate through cert_to_inputs (demand cascade)

Adds an optional `postcode_climate: Optional[PostcodeClimate]` parameter
to every cert→inputs section helper that touches climate:
- `cert_to_inputs(epc, postcode_climate=...)`
- `ventilation_from_cert` (overrides UK-avg wind tuple)
- `mean_internal_temperature_section_from_cert`
- `space_heating_section_from_cert`
- `space_cooling_section_from_cert`
- `solar_gains_section_from_cert`
- `energy_requirements_section_from_cert`
- `fuel_cost_section_from_cert`
- `environmental_section_from_cert`

`_climate_source(postcode_climate)` returns `int | PostcodeClimate`
(region 0 = UK-avg fallback). The four Appendix U lookup functions
(`external_temperature_c`, `wind_speed_m_per_s`, `horizontal_solar_
irradiance_w_per_m2`, `_latitude_deg`) now accept the union and
dispatch on isinstance — region path is unchanged, postcode path reads
directly from `PostcodeClimate`.

CalculatorInputs gains `monthly_external_temp_c_override` so the
calculator's per-month solve uses the postcode tuple computed in
cert_to_inputs instead of looking up `external_temperature_c(region, m)`
(which would always be UK-avg).

Adds two public helpers:
- `local_climate_for_cert(epc)` — postcode lookup with None fallback
- `cert_to_demand_inputs(epc)` — convenience: cert_to_inputs with
  postcode climate from the cert's postcode field

Verification (000474 with postcode "bd3 8aq" injected — fixtures
currently lodge placeholder "A1 1AA"; real postcodes land in slice 36):
  Rating  main_1_fuel = 11964.8924  (PDF Block 1: 11964.8924 ✓)
  Demand  main_1_fuel = 12288.0014  (PDF Block 2: 12288.0014 ✓ EXACT)
  Rating  ext_temp Jan = 4.3°C (UK-avg)
  Demand  ext_temp Jan = 4.2°C (BD3)

840/840 existing pins still pass — refactor is backward-compatible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 09:40:03 +00:00
parent 20b2bfa11d
commit 8cfeba8e2a
4 changed files with 186 additions and 63 deletions

View file

@ -172,6 +172,11 @@ class CalculatorInputs:
hot_water_fuel_cost_gbp_per_kwh: float hot_water_fuel_cost_gbp_per_kwh: float
other_fuel_cost_gbp_per_kwh: float other_fuel_cost_gbp_per_kwh: float
co2_factor_kg_per_kwh: float co2_factor_kg_per_kwh: float
# Pre-computed monthly external temperature (°C). When provided, the
# calculator's per-month solve uses this directly instead of looking up
# `external_temperature_c(region, month)`. Set by cert_to_inputs from
# either UK-average (rating cascade) or PCDB postcode (demand cascade).
monthly_external_temp_c_override: Optional[tuple[float, ...]] = None
# Per-end-use effective CO2 factors. For electricity end-uses with # Per-end-use effective CO2 factors. For electricity end-uses with
# known monthly kWh distribution, cert_to_inputs computes the days- # known monthly kWh distribution, cert_to_inputs computes the days-
# weighted average Table 12d factor: Σ(kWh_m × CO2_m) / Σ(kWh_m). Gas # weighted average Table 12d factor: Σ(kWh_m × CO2_m) / Σ(kWh_m). Gas
@ -305,7 +310,11 @@ def _solve_month(
time_constant_h: float, time_constant_h: float,
heat_loss_parameter: float, heat_loss_parameter: float,
) -> MonthlyEntry: ) -> MonthlyEntry:
t_ext = external_temperature_c(inputs.region, month) t_ext = (
inputs.monthly_external_temp_c_override[month - 1]
if inputs.monthly_external_temp_c_override is not None
else external_temperature_c(inputs.region, month)
)
g_int = inputs.internal_gains_monthly_w[month - 1] g_int = inputs.internal_gains_monthly_w[month - 1]
g_sol = inputs.solar_gains_monthly_w[month - 1] g_sol = inputs.solar_gains_monthly_w[month - 1]

View file

@ -20,6 +20,8 @@ from __future__ import annotations
from typing import Final from typing import Final
from domain.sap.tables.pcdb.postcode_weather import PostcodeClimate
# Table U1 — Mean external temperature (°C), 22 regions × 12 months. # Table U1 — Mean external temperature (°C), 22 regions × 12 months.
# Row order: region 0 (UK average) first, then regions 1-21 in spec order. # Row order: region 0 (UK average) first, then regions 1-21 in spec order.
@ -94,16 +96,29 @@ def _validate(region: int, month: int) -> None:
_validate_month(month) _validate_month(month)
def external_temperature_c(region: int, month: int) -> float: def external_temperature_c(
"""Mean external temperature (°C) for a SAP climate region in a month.""" region_or_climate: "int | PostcodeClimate", month: int
_validate(region, month) ) -> float:
return _TABLE_U1[region][month - 1] """Mean external temperature (°C) per month. Accepts either a SAP region
index (0..21) for the Appendix U fallback tables, or a `PostcodeClimate`
record for postcode-specific demand-cascade values from PCDB Table 172."""
if isinstance(region_or_climate, PostcodeClimate):
_validate_month(month)
return region_or_climate.monthly_external_temp_c[month - 1]
_validate(region_or_climate, month)
return _TABLE_U1[region_or_climate][month - 1]
def wind_speed_m_per_s(region: int, month: int) -> float: def wind_speed_m_per_s(
"""Mean wind speed (m/s) for a SAP climate region in a month.""" region_or_climate: "int | PostcodeClimate", month: int
_validate(region, month) ) -> float:
return _TABLE_U2[region][month - 1] """Mean wind speed (m/s) per month. Accepts either a SAP region index
(0..21) or a `PostcodeClimate` record."""
if isinstance(region_or_climate, PostcodeClimate):
_validate_month(month)
return region_or_climate.monthly_wind_speed_m_per_s[month - 1]
_validate(region_or_climate, month)
return _TABLE_U2[region_or_climate][month - 1]
# Table U3 — Mean global solar irradiance on a horizontal plane (W/m²), # Table U3 — Mean global solar irradiance on a horizontal plane (W/m²),
@ -136,12 +151,18 @@ _TABLE_U3: Final[tuple[tuple[float, ...], ...]] = (
) )
def horizontal_solar_irradiance_w_per_m2(region: int, month: int) -> float: def horizontal_solar_irradiance_w_per_m2(
"""Mean global solar irradiance on a horizontal plane (W/m²) for a SAP region_or_climate: "int | PostcodeClimate", month: int,
climate region in a month. The starting point for the per-orientation ) -> float:
surface-flux calculation in SAP 10.2 §6.1.""" """Mean global solar irradiance on a horizontal plane (W/m²). Accepts
_validate(region, month) either a SAP region index (0..21) or a `PostcodeClimate` record. The
return float(_TABLE_U3[region][month - 1]) starting point for the per-orientation surface-flux calculation in
SAP 10.2 §6.1."""
if isinstance(region_or_climate, PostcodeClimate):
_validate_month(month)
return region_or_climate.monthly_horizontal_solar_w_per_m2[month - 1]
_validate(region_or_climate, month)
return float(_TABLE_U3[region_or_climate][month - 1])
# Table U3 footer — Solar declination (°), region-independent (function of # Table U3 footer — Solar declination (°), region-independent (function of

View file

@ -55,6 +55,10 @@ from domain.ml.sap_efficiencies import (
from domain.sap.calculator import CalculatorInputs from domain.sap.calculator import CalculatorInputs
from domain.sap.tables.pcdb import gas_oil_boiler_record from domain.sap.tables.pcdb import gas_oil_boiler_record
from domain.sap.tables.pcdb.parser import GasOilBoilerRecord from domain.sap.tables.pcdb.parser import GasOilBoilerRecord
from domain.sap.tables.pcdb.postcode_weather import (
PostcodeClimate,
postcode_climate,
)
from domain.sap.tables.table_12 import ( from domain.sap.tables.table_12 import (
co2_monthly_factors_kg_per_kwh, co2_monthly_factors_kg_per_kwh,
co2_factor_kg_per_kwh, co2_factor_kg_per_kwh,
@ -340,21 +344,24 @@ def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
def _region_index(region_code: Optional[str]) -> int: def _region_index(region_code: Optional[str]) -> int:
"""SAP rating must be computed with UK-average weather per Appendix U: """SAP rating must be computed with UK-average weather per Appendix U
"Calculations for fabric energy efficiency (FEE), regulation compliance (p.124). Always returns region 0 (UK average); the demand cascade
(TER and DER, TPER and DPER) and for ratings (SAP rating and environmental (Current Carbon / Current Primary Energy / Fuel Bill) uses the
impact rating) are done with UK average weather. Other calculations (such `postcode_climate` parameter on `cert_to_inputs` instead see
as for energy use and costs on EPCs) are done using local weather." `cert_to_demand_inputs`."""
Since our calculator's primary output is the SAP rating, we always return
region 0 (UK average) regardless of the cert's actual region_code. A
future slice may add a `compute_local_weather` flag to also produce the
energy-use kWh totals at local weather.
"""
_ = region_code _ = region_code
return 0 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: def _is_timber_or_steel_frame(parts: list[SapBuildingPart]) -> bool:
"""RdSAP 10 §5: wall_construction codes 5 (timber frame) and 6 (system """RdSAP 10 §5: wall_construction codes 5 (timber frame) and 6 (system
build steel frame) get the lower 0.25 structural ACH; everything else build steel frame) get the lower 0.25 structural ACH; everything else
@ -953,6 +960,8 @@ def _roof_windows_for_solar_gains(
def mean_internal_temperature_section_from_cert( def mean_internal_temperature_section_from_cert(
epc: EpcPropertyData, epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[MeanInternalTemperatureResult]: ) -> Optional[MeanInternalTemperatureResult]:
"""SAP 10.2 §7 cert→inputs cascade for `mean_internal_temperature_monthly`. """SAP 10.2 §7 cert→inputs cascade for `mean_internal_temperature_monthly`.
@ -968,10 +977,10 @@ def mean_internal_temperature_section_from_cert(
if epc.total_floor_area_m2 is None: if epc.total_floor_area_m2 is None:
return None return None
dim = dimensions_from_cert(epc) dim = dimensions_from_cert(epc)
ventilation = ventilation_from_cert(epc) ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
ht = heat_transmission_section_from_cert(epc) ht = heat_transmission_section_from_cert(epc)
ig = internal_gains_section_from_cert(epc) ig = internal_gains_section_from_cert(epc)
sg = solar_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" assert ig is not None, "internal_gains None despite TFA present"
internal_gains_monthly_w = ig.total_internal_gains_monthly_w internal_gains_monthly_w = ig.total_internal_gains_monthly_w
solar_gains_monthly_w = sg.total_solar_gains_monthly_w solar_gains_monthly_w = sg.total_solar_gains_monthly_w
@ -983,10 +992,10 @@ def mean_internal_temperature_section_from_cert(
for m in range(12) for m in range(12)
) )
main = _first_main_heating(epc) main = _first_main_heating(epc)
region = _region_index(epc.region_code) climate = _climate_source(postcode_climate)
return mean_internal_temperature_monthly( return mean_internal_temperature_monthly(
monthly_external_temp_c=tuple( monthly_external_temp_c=tuple(
external_temperature_c(region, m) for m in range(1, 13) external_temperature_c(climate, m) for m in range(1, 13)
), ),
monthly_total_gains_w=monthly_total_gains_w, monthly_total_gains_w=monthly_total_gains_w,
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
@ -1003,6 +1012,8 @@ def mean_internal_temperature_section_from_cert(
def space_heating_section_from_cert( def space_heating_section_from_cert(
epc: EpcPropertyData, epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[SpaceHeatingResult]: ) -> Optional[SpaceHeatingResult]:
"""SAP 10.2 §8 cert→inputs cascade for `space_heating_monthly_kwh`. """SAP 10.2 §8 cert→inputs cascade for `space_heating_monthly_kwh`.
@ -1012,16 +1023,21 @@ def space_heating_section_from_cert(
(95)..(99) line ref) so cascade pin tests can assert each §8 line (95)..(99) line ref) so cascade pin tests can assert each §8 line
ref against the U985 PDF. 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). Returns `None` when TFA is missing (matches other section helpers).
""" """
if epc.total_floor_area_m2 is None: if epc.total_floor_area_m2 is None:
return None return None
dim = dimensions_from_cert(epc) dim = dimensions_from_cert(epc)
ventilation = ventilation_from_cert(epc) ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
ht = heat_transmission_section_from_cert(epc) ht = heat_transmission_section_from_cert(epc)
ig = internal_gains_section_from_cert(epc) ig = internal_gains_section_from_cert(epc)
sg = solar_gains_section_from_cert(epc) sg = solar_gains_section_from_cert(epc, postcode_climate=postcode_climate)
mit = mean_internal_temperature_section_from_cert(epc) mit = mean_internal_temperature_section_from_cert(
epc, postcode_climate=postcode_climate
)
assert ig is not None, "internal_gains None despite TFA present" assert ig is not None, "internal_gains None despite TFA present"
assert mit is not None, "mit None despite TFA present" assert mit is not None, "mit None despite TFA present"
monthly_total_gains_w = tuple( monthly_total_gains_w = tuple(
@ -1032,9 +1048,9 @@ def space_heating_section_from_cert(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m] ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12) for m in range(12)
) )
region = _region_index(epc.region_code) climate = _climate_source(postcode_climate)
monthly_external_temp_c = tuple( monthly_external_temp_c = tuple(
external_temperature_c(region, m) for m in range(1, 13) external_temperature_c(climate, m) for m in range(1, 13)
) )
return space_heating_monthly_kwh( return space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
@ -1048,6 +1064,8 @@ def space_heating_section_from_cert(
def space_cooling_section_from_cert( def space_cooling_section_from_cert(
epc: EpcPropertyData, epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[SpaceCoolingResult]: ) -> Optional[SpaceCoolingResult]:
"""SAP 10.2 §8c cert→inputs cascade for `space_cooling_monthly_kwh`. """SAP 10.2 §8c cert→inputs cascade for `space_cooling_monthly_kwh`.
@ -1058,20 +1076,22 @@ def space_cooling_section_from_cert(
full `SpaceCoolingResult` (every (100)..(108) line ref) so cascade pin full `SpaceCoolingResult` (every (100)..(108) line ref) so cascade pin
tests can assert each §8c line ref against the U985 PDF. 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). Returns `None` when TFA is missing (matches other section helpers).
""" """
if epc.total_floor_area_m2 is None: if epc.total_floor_area_m2 is None:
return None return None
dim = dimensions_from_cert(epc) dim = dimensions_from_cert(epc)
ventilation = ventilation_from_cert(epc) ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
ht = heat_transmission_section_from_cert(epc) ht = heat_transmission_section_from_cert(epc)
monthly_htc_w_per_k = tuple( monthly_htc_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m] ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12) for m in range(12)
) )
region = _region_index(epc.region_code) climate = _climate_source(postcode_climate)
monthly_external_temp_c = tuple( monthly_external_temp_c = tuple(
external_temperature_c(region, m) for m in range(1, 13) external_temperature_c(climate, m) for m in range(1, 13)
) )
return space_cooling_monthly_kwh( return space_cooling_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
@ -1139,16 +1159,23 @@ class EnvironmentalSection:
def environmental_section_from_cert( def environmental_section_from_cert(
epc: EpcPropertyData, epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[EnvironmentalSection]: ) -> Optional[EnvironmentalSection]:
"""SAP 10.2 §12 cert→inputs cascade. Composes §9a per-system fuel kWh + """SAP 10.2 §12 cert→inputs cascade. Composes §9a per-system fuel kWh +
§4 water heating + §5 lighting + Table 12d monthly electricity CO2 + §4 water heating + §5 lighting + Table 12d monthly electricity CO2 +
Table 12 annual fuel CO2 into per-end-use CO2 line refs. 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.""" Returns None when TFA missing."""
if epc.total_floor_area_m2 is None: if epc.total_floor_area_m2 is None:
return None return None
dim = dimensions_from_cert(epc) dim = dimensions_from_cert(epc)
er = energy_requirements_section_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" assert er is not None, "energy_requirements None despite TFA present"
main = _first_main_heating(epc) main = _first_main_heating(epc)
@ -1169,7 +1196,7 @@ def environmental_section_from_cert(
) )
water_co2 = er.main_1_fuel_kwh_per_yr # placeholder, replaced below water_co2 = er.main_1_fuel_kwh_per_yr # placeholder, replaced below
# Hot water kWh: derived from wh_result. Recompute via cert_to_inputs path. # Hot water kWh: derived from wh_result. Recompute via cert_to_inputs path.
full_inputs = cert_to_inputs(epc) full_inputs = cert_to_inputs(epc, postcode_climate=postcode_climate)
water_co2 = full_inputs.hot_water_kwh_per_yr * water_factor water_co2 = full_inputs.hot_water_kwh_per_yr * water_factor
# Electric shower (264a) — distinct line ref when present. # Electric shower (264a) — distinct line ref when present.
@ -1248,32 +1275,39 @@ def sap_rating_section_from_cert(
def fuel_cost_section_from_cert( def fuel_cost_section_from_cert(
epc: EpcPropertyData, epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[FuelCostResult]: ) -> Optional[FuelCostResult]:
"""SAP 10.2 §10a cert→inputs cascade for `fuel_cost`. Off-peak certs """SAP 10.2 §10a cert→inputs cascade for `fuel_cost`. Off-peak certs
return the zero sentinel (Table 12a high-rate-fraction split deferred). return the zero sentinel (Table 12a high-rate-fraction split deferred).
For STANDARD-tariff certs returns the full (240)..(255) FuelCostResult. For STANDARD-tariff certs returns the full (240)..(255) FuelCostResult.
Composes via `cert_to_inputs(epc)` `_fuel_cost` is invoked there with Composes via `cert_to_inputs(epc)` `_fuel_cost` is invoked there with
all upstream §4/§5/§6/§7/§8/§9a values plumbed in. Returns None when 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. TFA missing.
""" """
if epc.total_floor_area_m2 is None: if epc.total_floor_area_m2 is None:
return None return None
return cert_to_inputs(epc).fuel_cost return cert_to_inputs(epc, postcode_climate=postcode_climate).fuel_cost
def energy_requirements_section_from_cert( def energy_requirements_section_from_cert(
epc: EpcPropertyData, epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[EnergyRequirementsResult]: ) -> Optional[EnergyRequirementsResult]:
"""SAP 10.2 §9a cert→inputs cascade for `space_heating_fuel_monthly_kwh`. """SAP 10.2 §9a cert→inputs cascade for `space_heating_fuel_monthly_kwh`.
Composes §8 (98c)m + Table 11 secondary fraction + per-system Composes §8 (98c)m + Table 11 secondary fraction + per-system
efficiencies into the (201)..(221) line refs. Single-main scope A efficiencies into the (201)..(221) line refs. Single-main scope A
(no (203)/(207)/(213)/(209)/(221)). Returns None when TFA missing. (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: if epc.total_floor_area_m2 is None:
return None return None
sh = space_heating_section_from_cert(epc) sh = space_heating_section_from_cert(epc, postcode_climate=postcode_climate)
assert sh is not None, "space_heating None despite TFA present" assert sh is not None, "space_heating None despite TFA present"
main = _first_main_heating(epc) main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None main_code = main.sap_main_heating_code if main is not None else None
@ -1298,28 +1332,39 @@ def energy_requirements_section_from_cert(
) )
def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult: 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`. """SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`.
Returns the full `SolarGainsResult` (every (74)..(83) per-orientation Returns the full `SolarGainsResult` (every (74)..(83) per-orientation
line ref + (82)/(82a) roof-window/rooflight monthly tuples) computed line ref + (82)/(82a) roof-window/rooflight monthly tuples) computed
from the cert's `sap_windows` (vertical wall windows) and from the cert's `sap_windows` (vertical wall windows) and
`sap_roof_windows` (pitched roof windows for line (82)) at default `sap_roof_windows` (pitched roof windows for line (82)) at default
AVERAGE overshading and UK-average region (matches cert_to_inputs' AVERAGE overshading.
internal cascade for the SAP-rating pass).
`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 Rooflights (horizontal Z=1.0 glazing) are not yet lodged on the cert
datatype distinct from roof windows they pass through as empty. datatype distinct from roof windows they pass through as empty.
""" """
return solar_gains_from_cert( return solar_gains_from_cert(
epc=epc, epc=epc,
region=_region_index(epc.region_code), region=_climate_source(postcode_climate),
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
roof_windows=_roof_windows_for_solar_gains(epc), roof_windows=_roof_windows_for_solar_gains(epc),
) )
def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult: def ventilation_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> VentilationResult:
"""SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`. """SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`.
Reads dimensions + sap_ventilation lodgement from `epc` and produces Reads dimensions + sap_ventilation lodgement from `epc` and produces
@ -1327,6 +1372,10 @@ def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
exact same call cert_to_inputs makes internally. Exposed so cascade exact same call cert_to_inputs makes internally. Exposed so cascade
pin tests can assert every §2 line ref against the U985 PDF. 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 Defaults track the same conventions as cert_to_inputs (sheltered
sides 2 when missing, MV kind NATURAL until certMV mapping is sides 2 when missing, MV kind NATURAL until certMV mapping is
documented). documented).
@ -1336,6 +1385,10 @@ def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
storeys = max(1, dim.storey_count) storeys = max(1, dim.storey_count)
vc = _ventilation_counts(epc.sap_ventilation) vc = _ventilation_counts(epc.sap_ventilation)
sv = epc.sap_ventilation sv = epc.sap_ventilation
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 {}
)
return ventilation_from_inputs( return ventilation_from_inputs(
volume_m3=vol, volume_m3=vol,
storey_count=storeys, storey_count=storeys,
@ -1355,6 +1408,7 @@ def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0), 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, sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2,
mv_kind=MechanicalVentilationKind.NATURAL, mv_kind=MechanicalVentilationKind.NATURAL,
**wind_kwargs,
) )
@ -1702,7 +1756,10 @@ def _fuel_cost(
def cert_to_inputs( def cert_to_inputs(
epc: EpcPropertyData, *, prices: PriceTable = SAP_10_2_SPEC_PRICES epc: EpcPropertyData,
*,
prices: PriceTable = SAP_10_2_SPEC_PRICES,
postcode_climate: Optional[PostcodeClimate] = None,
) -> CalculatorInputs: ) -> CalculatorInputs:
"""Build a typed `CalculatorInputs` aggregate from an `EpcPropertyData`. """Build a typed `CalculatorInputs` aggregate from an `EpcPropertyData`.
@ -1717,7 +1774,7 @@ def cert_to_inputs(
# SAP §3 heat transmission + §2 ventilation cascades — see the # SAP §3 heat transmission + §2 ventilation cascades — see the
# respective `_from_cert` helpers for cert→inputs mapping rules. # respective `_from_cert` helpers for cert→inputs mapping rules.
ht = heat_transmission_section_from_cert(epc) ht = heat_transmission_section_from_cert(epc)
ventilation = ventilation_from_cert(epc) ventilation = ventilation_from_cert(epc, postcode_climate=postcode_climate)
main = _first_main_heating(epc) main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None main_code = main.sap_main_heating_code if main is not None else None
@ -1818,9 +1875,10 @@ def cert_to_inputs(
) )
) )
climate: "int | PostcodeClimate" = _climate_source(postcode_climate)
solar_gains_monthly_w = solar_gains_from_cert( solar_gains_monthly_w = solar_gains_from_cert(
epc=epc, epc=epc,
region=_region_index(epc.region_code), region=climate,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
roof_windows=_roof_windows_for_solar_gains(epc), roof_windows=_roof_windows_for_solar_gains(epc),
).total_solar_gains_monthly_w ).total_solar_gains_monthly_w
@ -1842,7 +1900,7 @@ def cert_to_inputs(
) )
mit_result = mean_internal_temperature_monthly( mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=tuple( monthly_external_temp_c=tuple(
external_temperature_c(_region_index(epc.region_code), m) external_temperature_c(climate, m)
for m in range(1, 13) for m in range(1, 13)
), ),
monthly_total_gains_w=monthly_total_gains_w, monthly_total_gains_w=monthly_total_gains_w,
@ -1859,8 +1917,7 @@ def cert_to_inputs(
# HTC + total-gains tuples already computed for §7 and adds T_int + η # HTC + total-gains tuples already computed for §7 and adds T_int + η
# from the MIT result. Includes the Table 9c step 10 summer clamp. # from the MIT result. Includes the Table 9c step 10 summer clamp.
monthly_external_temp_c = tuple( monthly_external_temp_c = tuple(
external_temperature_c(_region_index(epc.region_code), m) external_temperature_c(climate, m) for m in range(1, 13)
for m in range(1, 13)
) )
space_heating_result = space_heating_monthly_kwh( space_heating_result = space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
@ -1978,6 +2035,7 @@ def cert_to_inputs(
# SAP10.2 (109) — Fabric Energy Efficiency precomputed above. # SAP10.2 (109) — Fabric Energy Efficiency precomputed above.
fabric_energy_efficiency_kwh_per_m2_yr=fee_kwh_per_m2, fabric_energy_efficiency_kwh_per_m2_yr=fee_kwh_per_m2,
region=_region_index(epc.region_code), region=_region_index(epc.region_code),
monthly_external_temp_c_override=monthly_external_temp_c,
control_type=control_type_value, control_type=control_type_value,
responsiveness=responsiveness_value, responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value, living_area_fraction=living_area_fraction_value,
@ -2074,3 +2132,31 @@ def cert_to_inputs(
cooling_kwh=energy_requirements_result.cooling_fuel_kwh_per_yr, cooling_kwh=energy_requirements_result.cooling_fuel_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),
)

View file

@ -35,6 +35,7 @@ from math import cos, radians, sin
from typing import Final from typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from domain.sap.tables.pcdb.postcode_weather import PostcodeClimate
from domain.sap.climate.appendix_u import ( from domain.sap.climate.appendix_u import (
horizontal_solar_irradiance_w_per_m2, horizontal_solar_irradiance_w_per_m2,
solar_declination_deg, solar_declination_deg,
@ -101,23 +102,29 @@ _ORIENTATION_TO_K: Final[dict[Orientation, tuple[float, ...]]] = {
} }
def _latitude_deg(region: int) -> float: def _latitude_deg(region_or_climate: "int | PostcodeClimate") -> float:
if not 0 <= region < len(_LATITUDE_DEG): if isinstance(region_or_climate, PostcodeClimate):
raise ValueError(f"region must be 0..{len(_LATITUDE_DEG) - 1}, got {region}") return region_or_climate.latitude_deg
return _LATITUDE_DEG[region] if not 0 <= region_or_climate < len(_LATITUDE_DEG):
raise ValueError(
f"region must be 0..{len(_LATITUDE_DEG) - 1}, got {region_or_climate}"
)
return _LATITUDE_DEG[region_or_climate]
def surface_solar_flux_w_per_m2( def surface_solar_flux_w_per_m2(
*, *,
orientation: Orientation, orientation: Orientation,
pitch_deg: float, pitch_deg: float,
region: int, region: "int | PostcodeClimate",
month: int, month: int,
) -> float: ) -> float:
"""Per-orientation per-pitch monthly solar flux on a surface (W/m²). """Per-orientation per-pitch monthly solar flux on a surface (W/m²).
SAP 10.2 Appendix U §U3.2 polynomial conversion from the horizontal SAP 10.2 Appendix U §U3.2 polynomial conversion from the horizontal
irradiance in Table U3 to any orientation/tilt combination. irradiance in Table U3 to any orientation/tilt combination. Accepts
either a SAP region index (0..21) or a `PostcodeClimate` record from
PCDB Table 172 (demand cascade).
""" """
s_h = horizontal_solar_irradiance_w_per_m2(region, month) s_h = horizontal_solar_irradiance_w_per_m2(region, month)
declination = solar_declination_deg(month) declination = solar_declination_deg(month)
@ -292,7 +299,7 @@ def _vertical_window_gain_monthly_w(
*, *,
w: SapWindow, w: SapWindow,
orientation: Orientation, orientation: Orientation,
region: int, region: "int | PostcodeClimate",
z_solar: float, z_solar: float,
) -> tuple[float, ...]: ) -> tuple[float, ...]:
"""Compute the 12-tuple of monthly solar gain (W) for one vertical wall """Compute the 12-tuple of monthly solar gain (W) for one vertical wall
@ -324,7 +331,7 @@ def _sum_tuples(*tuples: tuple[float, ...]) -> tuple[float, ...]:
def solar_gains_from_cert( def solar_gains_from_cert(
*, *,
epc: EpcPropertyData, epc: EpcPropertyData,
region: int, region: "int | PostcodeClimate",
overshading: OvershadingCategory = OvershadingCategory.AVERAGE, overshading: OvershadingCategory = OvershadingCategory.AVERAGE,
roof_windows: tuple[RoofWindowInput, ...] = (), roof_windows: tuple[RoofWindowInput, ...] = (),
rooflights: tuple[RooflightInput, ...] = (), rooflights: tuple[RooflightInput, ...] = (),