From 5acbecc514c97f70c3616ec9682ae7b6c4de6220 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 16:44:31 +0000 Subject: [PATCH] Slice 45c: PV demand cascade uses postcode-specific climate (PCDB Table 172) per Appendix U Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 49 ++++++++++++------- .../sap/rdsap/tests/test_cert_to_inputs.py | 27 +++++++++- .../sap/rdsap/tests/test_golden_fixtures.py | 18 ++++--- 3 files changed, 66 insertions(+), 28 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 1633c05b..5d25d116 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -211,18 +211,18 @@ _PV_PITCH_DEG_DEFAULT: Final[float] = 30.0 # RdSAP10 §11.1 default _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: +def _pv_annual_s_kwh_per_m2( + orientation_code: int, + pitch_code: int, + climate: "int | PostcodeClimate", +) -> 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.""" + (kWh/m²/yr) on a surface of given orientation and tilt. Sums the + monthly Appendix U3.2 surface flux over the year. `climate` selects + Table U3/U4 region (UK average = 0 for the rating cascade) or a + `PostcodeClimate` from PCDB Table 172 for the demand cascade. + 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 @@ -232,7 +232,7 @@ def _pv_annual_s_kwh_per_m2(orientation_code: int, pitch_code: int) -> float: s_m = surface_solar_flux_w_per_m2( orientation=orientation, pitch_deg=pitch_deg, - region=_PV_RATING_REGION, + region=climate, month=month_idx + 1, ) total += days * s_m @@ -746,27 +746,36 @@ 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: +def _pv_array_generation_kwh_per_yr( + array: PhotovoltaicArray, + climate: "int | PostcodeClimate", +) -> float: """SAP 10.2 Appendix M (M1) for a single array: EPV = 0.8 × kWp × S × ZPV. 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 + orientation and tilt under `climate` (UK average region 0 for ratings, + PCDB Table 172 PostcodeClimate for demand); 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(array.orientation, array.pitch) + s = _pv_annual_s_kwh_per_m2(array.orientation, array.pitch, climate) 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: +def _pv_generation_kwh_per_yr( + epc: EpcPropertyData, + climate: "int | PostcodeClimate", +) -> float: """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." + `climate` selects UK-average (region 0) for the rating cascade or + postcode-specific (PCDB Table 172) for the demand cascade. """ arrays = epc.sap_energy_source.photovoltaic_arrays if not arrays: return 0.0 - return sum(_pv_array_generation_kwh_per_yr(a) for a in arrays) + return sum(_pv_array_generation_kwh_per_yr(a, climate) for a in arrays) def _pv_export_credit_gbp_per_kwh() -> float: @@ -1828,6 +1837,7 @@ def _fuel_cost( pumps_fans_kwh: float, lighting_kwh: float, cooling_kwh: float, + climate: "int | PostcodeClimate", electric_shower_kwh: float = 0.0, ) -> FuelCostResult: """SAP10.2 §10a fuel-cost precompute — produce a `FuelCostResult` from @@ -1913,7 +1923,7 @@ def _fuel_cost( other_uses_gbp_per_kwh=other_uses_gbp_per_kwh, instant_shower_kwh_per_yr=electric_shower_kwh, instant_shower_gbp_per_kwh=other_uses_gbp_per_kwh, - pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc), + pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate), pv_export_credit_gbp_per_kwh=pv_export_credit_gbp_per_kwh, additional_standing_charges_gbp=standing, appendix_q_saved_gbp=0.0, @@ -2252,7 +2262,7 @@ def cert_to_inputs( wh_result.electric_shower_monthly_kwh if wh_result is not None else (0.0,) * 12, _STANDARD_ELECTRICITY_FUEL_CODE, ), - pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc), + pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate), pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(), secondary_heating_fraction=secondary_fraction_value, secondary_heating_efficiency=secondary_efficiency_value, @@ -2296,6 +2306,7 @@ def cert_to_inputs( pumps_fans_kwh=pumps_fans_kwh, lighting_kwh=lighting_kwh, cooling_kwh=energy_requirements_result.cooling_fuel_kwh_per_yr, + climate=climate, ), ) 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 acf5921a..a4a3c38f 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 @@ -28,7 +28,11 @@ from domain.ml.tests._fixtures import ( make_window, ) from domain.sap.calculator import Sap10Calculator, SapResult -from domain.sap.rdsap.cert_to_inputs import pcdb_combi_loss_override, cert_to_inputs +from domain.sap.rdsap.cert_to_inputs import ( + cert_to_demand_inputs, + cert_to_inputs, + pcdb_combi_loss_override, +) from domain.sap.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record from domain.sap.worksheet.tests import _elmhurst_worksheet_000477 as _w000477 from domain.sap.worksheet.water_heating import ( @@ -462,6 +466,27 @@ def test_pv_generation_differentiates_arrays_by_pitch() -> None: assert tilted_kwh > vertical_kwh +def test_pv_generation_uses_postcode_climate_in_demand_cascade() -> None: + # Arrange — SAP 10.2 Appendix U: rating cascade uses UK-average + # climate (region 0); demand cascade uses postcode-specific climate + # from PCDB Table 172 ("Other calculations… are done using local + # weather"). PV generation depends on monthly Sh via Appendix U3.3, + # so a cert whose postcode resolves to a non-UK-avg region must + # produce different annual PV generation under the two cascades. + epc = _typical_semi_detached_epc() + epc.postcode = "DE22" # PCDB Table 172 — Midlands, region 6 + epc.sap_energy_source.photovoltaic_arrays = [ + PhotovoltaicArray(peak_power=2.0, pitch=2, orientation=5, overshading=1), + ] + + # Act + rating_gen = cert_to_inputs(epc).pv_generation_kwh_per_yr + demand_gen = cert_to_demand_inputs(epc).pv_generation_kwh_per_yr + + # Assert + assert rating_gen != demand_gen + + 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 675a008f..839b2f8d 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 @@ -139,18 +139,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2130-1033-4050-5007-8395", actual_sap=82, expected_sap_resid=+3, - expected_pe_resid_kwh_per_m2=-51.0719, + expected_pe_resid_kwh_per_m2=-51.9024, 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/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." + "(SE + NW, overshading 1 + 2). Slices 45a/b/c implement SAP10.2 " + "Appendix M per-array yield with the real Appendix U3.3 S(orient, " + "p) integral + Table M1 ZPV, and split rating (UK-avg climate) " + "from demand (DE22 PCDB Table 172 climate). Net effect: SAP " + "residual +9 → +3, PE residual −69.57 → −51.90 vs the prior " + "lump-sum 850 × total_kWp. The remaining −51.90 PE drift sits " + "outside the PV cascade — candidates include the dwelling-use " + "vs export β-factor split (Appendix M §3) and the secondary " + "heating credit, both untouched so far." ), ), _GoldenExpectation(