mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
24f35f8b80
commit
5acbecc514
3 changed files with 66 additions and 28 deletions
|
|
@ -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/m²/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/m²/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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue