mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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
|
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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 cert→MV mapping is
|
sides → 2 when missing, MV kind → NATURAL until cert→MV 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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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, ...] = (),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue