mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
20b2bfa11d
commit
8cfeba8e2a
4 changed files with 186 additions and 63 deletions
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, ...] = (),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue