From 8cfeba8e2ace48f3e031ff765d6eb0c65d27c087 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 09:40:03 +0000 Subject: [PATCH] Slice 35: Plumb postcode climate through cert_to_inputs (demand cascade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/domain/src/domain/sap/calculator.py | 11 +- .../src/domain/sap/climate/appendix_u.py | 49 ++++-- .../src/domain/sap/rdsap/cert_to_inputs.py | 166 +++++++++++++----- .../src/domain/sap/worksheet/solar_gains.py | 23 ++- 4 files changed, 186 insertions(+), 63 deletions(-) diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 3fc4c625..6c8e0cc9 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -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] diff --git a/packages/domain/src/domain/sap/climate/appendix_u.py b/packages/domain/src/domain/sap/climate/appendix_u.py index 654a7a8d..b3cc922d 100644 --- a/packages/domain/src/domain/sap/climate/appendix_u.py +++ b/packages/domain/src/domain/sap/climate/appendix_u.py @@ -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 diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 80a5d6f8..81ac5989 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 cert→MV 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), + ) diff --git a/packages/domain/src/domain/sap/worksheet/solar_gains.py b/packages/domain/src/domain/sap/worksheet/solar_gains.py index 78b4109b..a623b899 100644 --- a/packages/domain/src/domain/sap/worksheet/solar_gains.py +++ b/packages/domain/src/domain/sap/worksheet/solar_gains.py @@ -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, ...] = (),