Slice 45c: PV demand cascade uses postcode-specific climate (PCDB Table 172) per Appendix U

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 16:44:31 +00:00
parent 24f35f8b80
commit 5acbecc514
3 changed files with 66 additions and 28 deletions

View file

@ -211,18 +211,18 @@ _PV_PITCH_DEG_DEFAULT: Final[float] = 30.0 # RdSAP10 §11.1 default
_HOURS_PER_DAY_OVER_1000: Final[float] = 0.024
_DAYS_PER_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
# SAP 10.2 Appendix U Table U4 row 0 = UK-average climate region. Rating
# cascade uses UK average per Appendix U; demand cascade will switch to
# postcode-specific climate in a follow-on slice.
_PV_RATING_REGION: Final[int] = 0
def _pv_annual_s_kwh_per_m2(orientation_code: int, pitch_code: int) -> float:
def _pv_annual_s_kwh_per_m2(
orientation_code: int,
pitch_code: int,
climate: "int | PostcodeClimate",
) -> float:
"""SAP 10.2 Appendix U3.3 equation (U4): annual solar radiation
(kWh//yr) on a surface of given orientation and tilt under UK-
average climate. Sums the monthly Appendix U3.2 surface flux over
the year. Returns 0.0 for unrecognised orientation codes (cert
octants outside 1..8) these PV arrays contribute nothing."""
(kWh//yr) on a surface of given orientation and tilt. Sums the
monthly Appendix U3.2 surface flux over the year. `climate` selects
Table U3/U4 region (UK average = 0 for the rating cascade) or a
`PostcodeClimate` from PCDB Table 172 for the demand cascade.
Returns 0.0 for unrecognised orientation codes (cert octants outside
1..8) these PV arrays contribute nothing."""
orientation = ORIENTATION_BY_SAP10_CODE.get(orientation_code)
if orientation is None:
return 0.0
@ -232,7 +232,7 @@ def _pv_annual_s_kwh_per_m2(orientation_code: int, pitch_code: int) -> float:
s_m = surface_solar_flux_w_per_m2(
orientation=orientation,
pitch_deg=pitch_deg,
region=_PV_RATING_REGION,
region=climate,
month=month_idx + 1,
)
total += days * s_m
@ -746,27 +746,36 @@ def _secondary_fuel_cost_gbp_per_kwh(
return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP
def _pv_array_generation_kwh_per_yr(array: PhotovoltaicArray) -> float:
def _pv_array_generation_kwh_per_yr(
array: PhotovoltaicArray,
climate: "int | PostcodeClimate",
) -> float:
"""SAP 10.2 Appendix M (M1) for a single array: EPV = 0.8 × kWp × S × ZPV.
S is the Appendix U3.3 annual solar radiation for the array's
orientation and tilt under UK-average climate; ZPV is the Table M1
orientation and tilt under `climate` (UK average region 0 for ratings,
PCDB Table 172 PostcodeClimate for demand); ZPV is the Table M1
overshading factor. Arrays with missing peak power contribute zero."""
if array.peak_power is None:
return 0.0
s = _pv_annual_s_kwh_per_m2(array.orientation, array.pitch)
s = _pv_annual_s_kwh_per_m2(array.orientation, array.pitch, climate)
z = _PV_OVERSHADING_FACTOR.get(array.overshading, 1.0)
return _PV_MODULE_EFFICIENCY_FACTOR * array.peak_power * s * z
def _pv_generation_kwh_per_yr(epc: EpcPropertyData) -> float:
def _pv_generation_kwh_per_yr(
epc: EpcPropertyData,
climate: "int | PostcodeClimate",
) -> float:
"""Annual PV generation (kWh/yr) summed per-array. Per SAP 10.2
Appendix M §M1: "If there is more than one PV array … apply this
process to each and sum the monthly electricity generation figures."
`climate` selects UK-average (region 0) for the rating cascade or
postcode-specific (PCDB Table 172) for the demand cascade.
"""
arrays = epc.sap_energy_source.photovoltaic_arrays
if not arrays:
return 0.0
return sum(_pv_array_generation_kwh_per_yr(a) for a in arrays)
return sum(_pv_array_generation_kwh_per_yr(a, climate) for a in arrays)
def _pv_export_credit_gbp_per_kwh() -> float:
@ -1828,6 +1837,7 @@ def _fuel_cost(
pumps_fans_kwh: float,
lighting_kwh: float,
cooling_kwh: float,
climate: "int | PostcodeClimate",
electric_shower_kwh: float = 0.0,
) -> FuelCostResult:
"""SAP10.2 §10a fuel-cost precompute — produce a `FuelCostResult` from
@ -1913,7 +1923,7 @@ def _fuel_cost(
other_uses_gbp_per_kwh=other_uses_gbp_per_kwh,
instant_shower_kwh_per_yr=electric_shower_kwh,
instant_shower_gbp_per_kwh=other_uses_gbp_per_kwh,
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc),
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate),
pv_export_credit_gbp_per_kwh=pv_export_credit_gbp_per_kwh,
additional_standing_charges_gbp=standing,
appendix_q_saved_gbp=0.0,
@ -2252,7 +2262,7 @@ def cert_to_inputs(
wh_result.electric_shower_monthly_kwh if wh_result is not None else (0.0,) * 12,
_STANDARD_ELECTRICITY_FUEL_CODE,
),
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc),
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate),
pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(),
secondary_heating_fraction=secondary_fraction_value,
secondary_heating_efficiency=secondary_efficiency_value,
@ -2296,6 +2306,7 @@ def cert_to_inputs(
pumps_fans_kwh=pumps_fans_kwh,
lighting_kwh=lighting_kwh,
cooling_kwh=energy_requirements_result.cooling_fuel_kwh_per_yr,
climate=climate,
),
)

View file

@ -28,7 +28,11 @@ from domain.ml.tests._fixtures import (
make_window,
)
from domain.sap.calculator import Sap10Calculator, SapResult
from domain.sap.rdsap.cert_to_inputs import pcdb_combi_loss_override, cert_to_inputs
from domain.sap.rdsap.cert_to_inputs import (
cert_to_demand_inputs,
cert_to_inputs,
pcdb_combi_loss_override,
)
from domain.sap.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record
from domain.sap.worksheet.tests import _elmhurst_worksheet_000477 as _w000477
from domain.sap.worksheet.water_heating import (
@ -462,6 +466,27 @@ def test_pv_generation_differentiates_arrays_by_pitch() -> None:
assert tilted_kwh > vertical_kwh
def test_pv_generation_uses_postcode_climate_in_demand_cascade() -> None:
# Arrange — SAP 10.2 Appendix U: rating cascade uses UK-average
# climate (region 0); demand cascade uses postcode-specific climate
# from PCDB Table 172 ("Other calculations… are done using local
# weather"). PV generation depends on monthly Sh via Appendix U3.3,
# so a cert whose postcode resolves to a non-UK-avg region must
# produce different annual PV generation under the two cascades.
epc = _typical_semi_detached_epc()
epc.postcode = "DE22" # PCDB Table 172 — Midlands, region 6
epc.sap_energy_source.photovoltaic_arrays = [
PhotovoltaicArray(peak_power=2.0, pitch=2, orientation=5, overshading=1),
]
# Act
rating_gen = cert_to_inputs(epc).pv_generation_kwh_per_yr
demand_gen = cert_to_demand_inputs(epc).pv_generation_kwh_per_yr
# Assert
assert rating_gen != demand_gen
def test_open_chimneys_raise_infiltration_ach() -> None:
# Arrange — Direction check: chimneys add Table 2.1 volume to the
# infiltration calc, so an otherwise identical dwelling with 2 open

View file

@ -139,18 +139,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+3,
expected_pe_resid_kwh_per_m2=-51.0719,
expected_pe_resid_kwh_per_m2=-51.9024,
expected_co2_resid_tonnes_per_yr=+0.1422,
notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
"(SE + NW, overshading 1 + 2). Slice 45a/b applied SAP10.2 "
"Appendix M per-array yield with the real Appendix U3.3 "
"S(orient, p) integral + Table M1 ZPV — pulled SAP residual "
"+9 → +3 and PE residual 69.57 → 51.07 vs the prior lump-"
"sum 850 × total_kWp. Remaining drift: demand cascade still "
"uses UK-average climate for S; switching to postcode-specific "
"climate (PCDB Table 172) lands in a follow-on slice."
"(SE + NW, overshading 1 + 2). Slices 45a/b/c implement SAP10.2 "
"Appendix M per-array yield with the real Appendix U3.3 S(orient, "
"p) integral + Table M1 ZPV, and split rating (UK-avg climate) "
"from demand (DE22 PCDB Table 172 climate). Net effect: SAP "
"residual +9 → +3, PE residual 69.57 → 51.90 vs the prior "
"lump-sum 850 × total_kWp. The remaining 51.90 PE drift sits "
"outside the PV cascade — candidates include the dwelling-use "
"vs export β-factor split (Appendix M §3) and the secondary "
"heating credit, both untouched so far."
),
),
_GoldenExpectation(