Slice 45b: PV pitch dimension + real Appendix U3.3 S(orient, p) integral — replaces 45a 30°-pitch stub

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

View file

@ -107,6 +107,7 @@ from domain.sap.worksheet.solar_gains import (
RoofWindowInput,
SolarGainsResult,
solar_gains_from_cert,
surface_solar_flux_w_per_m2,
)
from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
@ -193,21 +194,49 @@ _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
# 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
# RdSAP10 §11.1 pitch enum → degrees from horizontal. RdSAP fixes the
# tilt to one of five values; certs lodge the integer code while
# Appendix U3.2 takes a continuous pitch.
_PV_PITCH_DEG_BY_CODE: Final[dict[int, float]] = {
1: 0.0, # horizontal
2: 30.0,
3: 45.0,
4: 60.0,
5: 90.0, # vertical
}
_PV_PITCH_DEG_DEFAULT: Final[float] = 30.0 # RdSAP10 §11.1 default
# SAP 10.2 Appendix U3.3 equation (U4) constant: converts (W/m² × days)
# to (kWh/m²/yr) via 24 h/day ÷ 1000 W/kW = 0.024.
_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:
"""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."""
orientation = ORIENTATION_BY_SAP10_CODE.get(orientation_code)
if orientation is None:
return 0.0
pitch_deg = _PV_PITCH_DEG_BY_CODE.get(pitch_code, _PV_PITCH_DEG_DEFAULT)
total = 0.0
for month_idx, days in enumerate(_DAYS_PER_MONTH):
s_m = surface_solar_flux_w_per_m2(
orientation=orientation,
pitch_deg=pitch_deg,
region=_PV_RATING_REGION,
month=month_idx + 1,
)
total += days * s_m
return _HOURS_PER_DAY_OVER_1000 * total
# SAP 10.2 Table M1 — PV overshading factor ZPV. RdSAP10 omits SAP10.2's
# 5th "Severe" bucket; the four RdSAP codes map directly:
@ -719,11 +748,12 @@ def _secondary_fuel_cost_gbp_per_kwh(
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."""
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
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_BY_ORIENTATION_30_PITCH.get(array.orientation, 0.0)
s = _pv_annual_s_kwh_per_m2(array.orientation, array.pitch)
z = _PV_OVERSHADING_FACTOR.get(array.overshading, 1.0)
return _PV_MODULE_EFFICIENCY_FACTOR * array.peak_power * s * z

View file

@ -436,6 +436,32 @@ def test_pv_generation_differentiates_arrays_by_orientation() -> None:
assert south_kwh > north_kwh
def test_pv_generation_differentiates_arrays_by_pitch() -> None:
# Arrange — two south-facing arrays, identical kWp / orientation /
# overshading, but differing pitch codes: 2 (30° tilt) vs 5 (vertical).
# Per SAP 10.2 Appendix U3.3 the annual solar radiation S(orient, p)
# depends on the tilt via the Rh-inc polynomial in U2/U3; a 30°-pitched
# array intercepts far more annual radiation than a vertical wall-
# mounted one at the same orientation. The Slice-45a stub fixed pitch
# at 30° and so produced identical numbers for both — this test
# forces the cascade to consume `PhotovoltaicArray.pitch`.
tilted = _typical_semi_detached_epc()
tilted.sap_energy_source.photovoltaic_arrays = [
PhotovoltaicArray(peak_power=2.0, pitch=2, orientation=5, overshading=1),
]
vertical = _typical_semi_detached_epc()
vertical.sap_energy_source.photovoltaic_arrays = [
PhotovoltaicArray(peak_power=2.0, pitch=5, orientation=5, overshading=1),
]
# Act
tilted_kwh = cert_to_inputs(tilted).pv_generation_kwh_per_yr
vertical_kwh = cert_to_inputs(vertical).pv_generation_kwh_per_yr
# Assert
assert tilted_kwh > vertical_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,19 +138,19 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-48.8108,
expected_sap_resid=+3,
expected_pe_resid_kwh_per_m2=-51.0719,
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 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."
"(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."
),
),
_GoldenExpectation(