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