"""PCDB Table 172 — postcode-district weather data. Per SAP 10.2 Appendix U (p.124): "Weather data for each postcode district are taken from the PCDB and are used when the postcode district is known; in other cases the data from Tables U1 to U4 are used." Table 172 is the PCDB delivery format. ~3138 districts × monthly (temp, wind, solar). The "rating" cascade (SAP rating, EI rating) uses UK-average climate per Appendix U; the "demand" cascade (EPC emissions, primary energy, fuel cost) uses the postcode-specific climate from this table. Reference: PCDB10 data file `domain/sap10_calculator/tables/pcdb/data/pcdb10.dat`. """ from __future__ import annotations from dataclasses import dataclass from functools import lru_cache from pathlib import Path from typing import Final, Optional _PCDB_DAT_PATH: Final[Path] = ( Path(__file__).resolve().parent / "data" / "pcdb10.dat" ) _TABLE_172_TAG: Final[str] = "$172" @dataclass(frozen=True) class PostcodeClimate: """Per-postcode-district monthly weather. Months are Jan..Dec (12-tuples). `region` is the fallback SAP climate region index (1-21) for this district — used when callers want to mix in region-only tables like U3.2 (solar transformations) that haven't been delivered per postcode. """ area: str # e.g. "BD" district: int # e.g. 3 region: int # SAP region 1-21 (for fallbacks) country: int # 1-5 country/jurisdiction code height_m: float # district elevation (m) latitude_deg: float # district centroid longitude_deg: float # district centroid monthly_external_temp_c: tuple[float, ...] # T(1..12) °C monthly_wind_speed_m_per_s: tuple[float, ...] # W(1..12) m/s monthly_horizontal_solar_w_per_m2: tuple[float, ...] # R(1..12) W/m² def _parse_table_172_rows(dat_text: str) -> dict[tuple[str, int], PostcodeClimate]: """Parse Table 172 (Postcodes) rows from the PCDB data file text into a `{(area, district): PostcodeClimate}` lookup.""" out: dict[tuple[str, int], PostcodeClimate] = {} in_table = False for line in dat_text.splitlines(): if line.startswith(_TABLE_172_TAG): in_table = True continue if not in_table: continue if line.startswith("$"): break # next table starts if line.startswith("#") or not line.strip(): continue parts = line.split(",") if len(parts) < 45: continue area = parts[0].strip().upper() try: district = int(parts[1]) except ValueError: continue temps = tuple(float(parts[9 + i]) for i in range(12)) winds = tuple(float(parts[21 + i]) for i in range(12)) solars = tuple(float(parts[33 + i]) for i in range(12)) out[(area, district)] = PostcodeClimate( area=area, district=district, region=int(parts[3]), country=int(parts[4]), height_m=float(parts[6]), latitude_deg=float(parts[7]), longitude_deg=float(parts[8]), monthly_external_temp_c=temps, monthly_wind_speed_m_per_s=winds, monthly_horizontal_solar_w_per_m2=solars, ) return out @lru_cache(maxsize=1) def _postcode_climate_table() -> dict[tuple[str, int], PostcodeClimate]: """Cached load of Table 172. Called lazily on first postcode lookup.""" # PCDB delivery uses latin-1 (degree symbols, etc.) — not UTF-8. return _parse_table_172_rows(_PCDB_DAT_PATH.read_text(encoding="latin-1")) def _split_postcode(postcode: str) -> Optional[tuple[str, int]]: """Split a UK postcode into (area, district). "BD3 7XY" → ("BD", 3), "bd19 3tf" → ("BD", 19). Returns None when the format is unrecognised. UK postcode structure: outward = 1-2 letter area + 1-2 digit district, optionally followed by a letter (e.g. "EC1A"). For Table 172 the district sub-letter is dropped — only the numeric part is used.""" if not postcode: return None outward = postcode.strip().split()[0].upper() i = 0 while i < len(outward) and outward[i].isalpha(): i += 1 area = outward[:i] rest = outward[i:] j = 0 while j < len(rest) and rest[j].isdigit(): j += 1 if not area or j == 0: return None return area, int(rest[:j]) def postcode_climate(postcode: Optional[str]) -> Optional[PostcodeClimate]: """Look up postcode-district weather from PCDB Table 172. Returns None when postcode is missing, format unrecognised, or district not in the table (callers fall back to Appendix U region tables).""" if postcode is None: return None key = _split_postcode(postcode) if key is None: return None return _postcode_climate_table().get(key)