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 24c7cba1..1633c05b 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 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 d3ef5fc1..acf5921a 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 @@ -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 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 c51c0ec5..675a008f 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,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(