From f08252dc069a54764cda1990ee8aaa883da83afe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 16:31:29 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2045a:=20PV=20generation=20per-array=20Ap?= =?UTF-8?q?pendix=20M=20yield=20=E2=80=94=20cert=202130=20SAP=20+9=20?= =?UTF-8?q?=E2=86=92=20+2,=20PE=20=E2=88=9269.57=20=E2=86=92=20=E2=88=9248?= =?UTF-8?q?.81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 63 +++++++++++++++---- .../sap/rdsap/tests/test_cert_to_inputs.py | 27 +++++++- .../sap/rdsap/tests/test_golden_fixtures.py | 20 +++--- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 6280ccc8..24c7cba1 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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: diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index bc61fa86..d3ef5fc1 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -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 diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py index 7c7ccb17..c51c0ec5 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py @@ -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(