Slice 45a: PV generation per-array Appendix M yield — cert 2130 SAP +9 → +2, PE −69.57 → −48.81

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 16:31:29 +00:00
parent ea6d426349
commit f08252dc06
3 changed files with 87 additions and 23 deletions

View file

@ -55,6 +55,7 @@ from typing import Callable, Final, Literal, Optional
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
PhotovoltaicArray,
SapBuildingPart,
SapVentilation,
SapWindow,
@ -185,11 +186,41 @@ _INTERNAL_GAINS_DEFAULT_OVERSHADING: Final[OvershadingCategory] = (
_INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
# UK-average annual PV yield (kWh per kWp). SAP 10.2 Appendix M references
# regional yield factors; for rating purposes (Appendix U: ratings use UK
# average weather) the single national figure applies. Derived from
# `domain.ml.ecf._PV_YIELD_BY_REGION` UK-average baseline.
_PV_ANNUAL_YIELD_KWH_PER_KWP: Final[float] = 850.0
# SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed
# per array. The module efficiency constant (0.8), orientation-dependent
# annual solar radiation S (kWh/m²/yr from Appendix U3.3), and overshading
# factor ZPV (Table M1) are decoupled here so per-array generation tracks
# the cert's tilt / orientation / shading data.
_PV_MODULE_EFFICIENCY_FACTOR: Final[float] = 0.8
# Appendix U3.3 annual solar radiation S (kWh/m²/yr) on a 30°-pitch
# surface, by SAP orientation octant (1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW,
# 7=W, 8=NW), UK-average climate (rating cascade per Appendix U). The
# pitch dimension lands in a follow-on slice — for now 30° is the
# RdSAP10 §11.1 default ("if not provided … facing South, pitch 30°").
_PV_ANNUAL_S_KWH_PER_M2_BY_ORIENTATION_30_PITCH: Final[dict[int, float]] = {
1: 580.0, # N
2: 720.0, # NE
3: 880.0, # E
4: 1010.0, # SE
5: 1050.0, # S
6: 1010.0, # SW
7: 880.0, # W
8: 720.0, # NW
}
# SAP 10.2 Table M1 — PV overshading factor ZPV. RdSAP10 omits SAP10.2's
# 5th "Severe" bucket; the four RdSAP codes map directly:
# 1 = very little / none → 1.0
# 2 = modest → 0.8
# 3 = significant → 0.5
# 4 = heavy → 0.35
_PV_OVERSHADING_FACTOR: Final[dict[int, float]] = {
1: 1.0,
2: 0.8,
3: 0.5,
4: 0.35,
}
# SAP 10.2 Table 11 — fraction of space heating supplied by a secondary
@ -686,16 +717,26 @@ 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:
"""SAP 10.2 Appendix M (M1) for a single array: EPV = 0.8 × kWp × S × ZPV.
S comes from Appendix U3.3 by orientation (30°-pitch column for now);
ZPV from Table M1. Arrays with missing peak power contribute zero."""
if array.peak_power is None:
return 0.0
s = _PV_ANNUAL_S_KWH_PER_M2_BY_ORIENTATION_30_PITCH.get(array.orientation, 0.0)
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:
"""Annual PV generation (kWh/yr) summed across all photovoltaic
arrays on the cert. SAP 10.2 Appendix M: yield = peak power × annual
yield factor. We use the UK-average yield (Appendix U rule for
ratings)."""
"""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."
"""
arrays = epc.sap_energy_source.photovoltaic_arrays
if not arrays:
return 0.0
total_kwp = sum(a.peak_power for a in arrays if a.peak_power is not None)
return total_kwp * _PV_ANNUAL_YIELD_KWH_PER_KWP
return sum(_pv_array_generation_kwh_per_yr(a) for a in arrays)
def _pv_export_credit_gbp_per_kwh() -> float:

View file

@ -18,7 +18,7 @@ from typing import Final
import pytest
from datatypes.epc.domain.epc_property_data import MainHeatingDetail
from datatypes.epc.domain.epc_property_data import MainHeatingDetail, PhotovoltaicArray
from domain.ml.tests._fixtures import (
make_building_part,
@ -411,6 +411,31 @@ def test_pv_export_credit_input_reports_rdsap10_table_32_rate() -> None:
assert abs(inputs.pv_export_credit_gbp_per_kwh - 0.1319) <= 1e-6
def test_pv_generation_differentiates_arrays_by_orientation() -> None:
# Arrange — two single-array PVs at the same kWp / pitch / overshading,
# one facing South and one facing North. Per SAP10.2 Appendix M
# (EPV = 0.8 × kWp × S × ZPV) the radiation S depends on the array's
# orientation through Appendix U3.3 / Table U5, so a north-facing
# array must generate meaningfully less than a south-facing one at
# identical peak power. The legacy cascade summed kWp and applied a
# single UK-average factor, returning the same number for both.
south = _typical_semi_detached_epc()
south.sap_energy_source.photovoltaic_arrays = [
PhotovoltaicArray(peak_power=2.0, pitch=2, orientation=5, overshading=1),
]
north = _typical_semi_detached_epc()
north.sap_energy_source.photovoltaic_arrays = [
PhotovoltaicArray(peak_power=2.0, pitch=2, orientation=1, overshading=1),
]
# Act
south_kwh = cert_to_inputs(south).pv_generation_kwh_per_yr
north_kwh = cert_to_inputs(north).pv_generation_kwh_per_yr
# Assert
assert south_kwh > north_kwh
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

@ -138,21 +138,19 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+9,
expected_pe_resid_kwh_per_m2=-69.5678,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-48.8108,
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 + NE). Cert scored under SAP10.2 software (Table 12 PV "
"export = 5.59 p/kWh, £194 PV credit → SAP 82). Our calc "
"targets RdSAP10 (Table 32 PV export = 13.19 p/kWh, £457 PV "
"credit → SAP 90) per ADR-0010. The +8 SAP / 61 PE residual "
"is spec-version drift, not a code bug — both calcs are "
"internally consistent against their own price table. The PE "
"residual is amplified because PV generation also offsets PE "
"via inputs.other_primary_factor; that offset scales with the "
"PV gen kWh, not the export-credit price."
"(SE + NW, overshading 1 + 2). Slice 45a applied SAP10.2 "
"Appendix M per-array yield (orientation S-table at 30° pitch "
"+ Table M1 ZPV) — pulled SAP residual +9 → +2 and PE residual "
"69.57 → 48.81 vs the prior lump-sum 850 × total_kWp. "
"Remaining drift: pitch dimension and full Appendix U3.3 "
"monthly integral (currently approximated by the 30°-pitch "
"S-table) will land in follow-on slices."
),
),
_GoldenExpectation(