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
other_fuel_cost_gbp_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
# known monthly kWh distribution, cert_to_inputs computes the days-
# weighted average Table 12d factor: Σ(kWh_m × CO2_m) / Σ(kWh_m). Gas
@ -305,7 +310,11 @@ def _solve_month(
time_constant_h: float,
heat_loss_parameter: float,
) -> 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_sol = inputs.solar_gains_monthly_w[month - 1]

View file

@ -20,6 +20,8 @@ from __future__ import annotations
from typing import Final
from domain.sap.tables.pcdb.postcode_weather import PostcodeClimate
# Table U1 — Mean external temperature (°C), 22 regions × 12 months.
# 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)
def external_temperature_c(region: int, month: int) -> float:
"""Mean external temperature (°C) for a SAP climate region in a month."""
_validate(region, month)
return _TABLE_U1[region][month - 1]
def external_temperature_c(
region_or_climate: "int | PostcodeClimate", month: int
) -> float:
"""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:
"""Mean wind speed (m/s) for a SAP climate region in a month."""
_validate(region, month)
return _TABLE_U2[region][month - 1]
def wind_speed_m_per_s(
region_or_climate: "int | PostcodeClimate", month: int
) -> float:
"""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²),
@ -136,12 +151,18 @@ _TABLE_U3: Final[tuple[tuple[float, ...], ...]] = (
)
def horizontal_solar_irradiance_w_per_m2(region: int, month: int) -> float:
"""Mean global solar irradiance on a horizontal plane (W/m²) for a SAP
climate region in a month. The starting point for the per-orientation
surface-flux calculation in SAP 10.2 §6.1."""
_validate(region, month)
return float(_TABLE_U3[region][month - 1])
def horizontal_solar_irradiance_w_per_m2(
region_or_climate: "int | PostcodeClimate", month: int,
) -> float:
"""Mean global solar irradiance on a horizontal plane (W/m²). Accepts
either a SAP region index (0..21) or a `PostcodeClimate` record. The
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

View file

@ -55,6 +55,10 @@ from domain.ml.sap_efficiencies import (
from domain.sap.calculator import CalculatorInputs
from domain.sap.tables.pcdb import gas_oil_boiler_record
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 (
co2_monthly_factors_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:
"""SAP rating must be computed with UK-average weather per Appendix U:
"Calculations for fabric energy efficiency (FEE), regulation compliance
(TER and DER, TPER and DPER) and for ratings (SAP rating and environmental
impact rating) are done with UK average weather. Other calculations (such
as for energy use and costs on EPCs) are done using local weather."
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.
"""
"""SAP rating must be computed with UK-average weather per Appendix U
(p.124). Always returns region 0 (UK average); the demand cascade
(Current Carbon / Current Primary Energy / Fuel Bill) uses the
`postcode_climate` parameter on `cert_to_inputs` instead see
`cert_to_demand_inputs`."""
_ = region_code
return 0
def _climate_source(
postcode_climate_override: Optional[PostcodeClimate],
) -> "int | PostcodeClimate":
"""Pick the climate source for downstream lookups. None → region 0
(UK-average, ratings cascade); a `PostcodeClimate` postcode-district
PCDB Table 172 data (demand cascade)."""
return postcode_climate_override if postcode_climate_override is not None else 0
def _is_timber_or_steel_frame(parts: list[SapBuildingPart]) -> bool:
"""RdSAP 10 §5: wall_construction codes 5 (timber frame) and 6 (system
build steel frame) get the lower 0.25 structural ACH; everything else
@ -953,6 +960,8 @@ def _roof_windows_for_solar_gains(
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`.
@ -968,10 +977,10 @@ def mean_internal_temperature_section_from_cert(
if epc.total_floor_area_m2 is None:
return None
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)
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"
internal_gains_monthly_w = ig.total_internal_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)
)
main = _first_main_heating(epc)
region = _region_index(epc.region_code)
climate = _climate_source(postcode_climate)
return mean_internal_temperature_monthly(
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_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(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[SpaceHeatingResult]:
"""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
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)
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)
mit = mean_internal_temperature_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(
@ -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]
for m in range(12)
)
region = _region_index(epc.region_code)
climate = _climate_source(postcode_climate)
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(
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(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[SpaceCoolingResult]:
"""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
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)
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)
)
region = _region_index(epc.region_code)
climate = _climate_source(postcode_climate)
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(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
@ -1139,16 +1159,23 @@ class EnvironmentalSection:
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)
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)
@ -1169,7 +1196,7 @@ def environmental_section_from_cert(
)
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.
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
# Electric shower (264a) — distinct line ref when present.
@ -1248,32 +1275,39 @@ def sap_rating_section_from_cert(
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. 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.
"""
if epc.total_floor_area_m2 is 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(
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)). 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:
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"
main = _first_main_heating(epc)
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`.
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 and UK-average region (matches cert_to_inputs'
internal cascade for the SAP-rating pass).
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=_region_index(epc.region_code),
region=_climate_source(postcode_climate),
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
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`.
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
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 certMV mapping is
documented).
@ -1336,6 +1385,10 @@ def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
storeys = max(1, dim.storey_count)
vc = _ventilation_counts(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(
volume_m3=vol,
storey_count=storeys,
@ -1355,6 +1408,7 @@ def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
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,
mv_kind=MechanicalVentilationKind.NATURAL,
**wind_kwargs,
)
@ -1702,7 +1756,10 @@ def _fuel_cost(
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:
"""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
# respective `_from_cert` helpers for cert→inputs mapping rules.
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_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(
epc=epc,
region=_region_index(epc.region_code),
region=climate,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
roof_windows=_roof_windows_for_solar_gains(epc),
).total_solar_gains_monthly_w
@ -1842,7 +1900,7 @@ def cert_to_inputs(
)
mit_result = mean_internal_temperature_monthly(
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)
),
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 + η
# from the MIT result. Includes the Table 9c step 10 summer clamp.
monthly_external_temp_c = tuple(
external_temperature_c(_region_index(epc.region_code), m)
for m in range(1, 13)
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,
@ -1978,6 +2035,7 @@ def cert_to_inputs(
# 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,
@ -2074,3 +2132,31 @@ def cert_to_inputs(
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 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 (
horizontal_solar_irradiance_w_per_m2,
solar_declination_deg,
@ -101,23 +102,29 @@ _ORIENTATION_TO_K: Final[dict[Orientation, tuple[float, ...]]] = {
}
def _latitude_deg(region: int) -> float:
if not 0 <= region < len(_LATITUDE_DEG):
raise ValueError(f"region must be 0..{len(_LATITUDE_DEG) - 1}, got {region}")
return _LATITUDE_DEG[region]
def _latitude_deg(region_or_climate: "int | PostcodeClimate") -> float:
if isinstance(region_or_climate, PostcodeClimate):
return region_or_climate.latitude_deg
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(
*,
orientation: Orientation,
pitch_deg: float,
region: int,
region: "int | PostcodeClimate",
month: int,
) -> float:
"""Per-orientation per-pitch monthly solar flux on a surface (W/m²).
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)
declination = solar_declination_deg(month)
@ -292,7 +299,7 @@ def _vertical_window_gain_monthly_w(
*,
w: SapWindow,
orientation: Orientation,
region: int,
region: "int | PostcodeClimate",
z_solar: float,
) -> tuple[float, ...]:
"""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(
*,
epc: EpcPropertyData,
region: int,
region: "int | PostcodeClimate",
overshading: OvershadingCategory = OvershadingCategory.AVERAGE,
roof_windows: tuple[RoofWindowInput, ...] = (),
rooflights: tuple[RooflightInput, ...] = (),