From 82c34227883e727eb76352ff7c56ee639ee68272 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 09:59:48 +0000 Subject: [PATCH] feat(modelling): generation-calibrated PV overshading derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of the Solar PV Recommendation Generator (ADR-0026). Per roof segment, back-solve the effective overshading factor ZPV from Google's expected generation against SAP's own unshaded annual output: ZPV = (yearlyEnergyDcKwh × 0.955) / (0.8 × kWp × S) reusing the calculator's Appendix U3.3 annual solar radiation S via a new public seam `pv_annual_solar_radiation_kwh_per_m2`. Dividing Google's generation by SAP's S cancels orientation/tilt and isolates shading; the result snaps to the RdSAP bucket {1:1.0, 2:0.8, 3:0.5, 4:0.35} via the ADR-0026 midpoint cutpoints (≥0.90→1, 0.65–0.90→2, 0.425–0.65→3, <0.425→4; ZPV>1→1). The real London example's planes all back-solve to ZPV>1 → code 1. Co-Authored-By: Claude Opus 4.8 --- .../generators/solar_recommendation.py | 70 +++++++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 13 +++ .../modelling/test_solar_overshading.py | 89 +++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 domain/modelling/generators/solar_recommendation.py create mode 100644 tests/domain/modelling/test_solar_overshading.py diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py new file mode 100644 index 00000000..c52dd297 --- /dev/null +++ b/domain/modelling/generators/solar_recommendation.py @@ -0,0 +1,70 @@ +"""The Solar PV Recommendation Generator (ADR-0026). + +Offers competing whole-array PV Options built from real Google Solar imagery +(a typed `SolarPotential`), not an estimate. Unlike the heating bundles, the +SAP scoring side is already mature — the calculator does Appendix M β-split, +G4 diverter, SEG export, batteries and monthly E_PV — so this generator fixes +the *recommendation* side: where the array config comes from, how it is +conservatively sized, the new PV Overlay surface, and the composite cost. + +This slice covers the generation-calibrated overshading derivation; config +selection, the overlay and `recommend_solar` land in later slices. +""" + +from __future__ import annotations + +from domain.modelling.solar_potential import SolarRoofSegment +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + pv_annual_solar_radiation_kwh_per_m2, +) + +# Google Solar inverter DC→AC efficiency — the canonical rate the legacy +# `GoogleSolarApi.dc_to_ac_rate` uses (mid of the 93–98% range); distinct from +# the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback. +_DC_TO_AC_RATE = 0.955 +# SAP 10.2 Appendix M PV annual output: E = 0.8 × kWp × S × ZPV. The 0.8 is the +# in-system performance factor; back-solving for ZPV isolates the effective +# overshading once orientation (S) and size (kWp) are divided out. +_SAP_PV_PERFORMANCE_FACTOR = 0.8 + +# ADR-0026 overshading cutpoints — the lower bound of each RdSAP bucket's ZPV +# midpoint band {1:1.0, 2:0.8, 3:0.5, 4:0.35}: ≥0.90→1, 0.65–0.90→2, +# 0.425–0.65→3, <0.425→4. ZPV > 1 (Google beats SAP's unshaded model) clamps +# to 1 via the ≥0.90 branch. RdSAP10 has no "Severe" 5th bucket. +_OVERSHADING_LOWER_BOUNDS: tuple[tuple[float, int], ...] = ( + (0.90, 1), + (0.65, 2), + (0.425, 3), +) +_OVERSHADING_HEAVY_CODE = 4 + + +def overshading_code_from_zpv(zpv_target: float) -> int: + """Snap a back-solved effective shading factor ZPV to the RdSAP overshading + code (1 = very little/none … 4 = heavy), per the ADR-0026 cutpoints.""" + for lower_bound, code in _OVERSHADING_LOWER_BOUNDS: + if zpv_target >= lower_bound: + return code + return _OVERSHADING_HEAVY_CODE + + +def segment_overshading_code( + segment: SolarRoofSegment, panel_capacity_watts: float +) -> int: + """Derive a roof segment's RdSAP overshading code from Google's expected + generation (ADR-0026). Google's `yearlyEnergyDcKwh` already encodes real + orientation, tilt and shading; dividing its AC equivalent by SAP's own + unshaded annual output (0.8 × kWp × S) cancels orientation/tilt and leaves + the effective overshading factor ZPV, which snaps to the bucket.""" + kwp: float = segment.panels_count * panel_capacity_watts / 1000.0 + s: float = pv_annual_solar_radiation_kwh_per_m2( + segment.sap_orientation, segment.sap_pitch_code + ) + unshaded_ac_kwh: float = _SAP_PV_PERFORMANCE_FACTOR * kwp * s + if unshaded_ac_kwh <= 0.0: + # No panels, or an orientation the calculator scores as zero — nothing + # to shade; the modal "no shading" code. + return 1 + generation_ac_kwh: float = segment.yearly_energy_dc_kwh * _DC_TO_AC_RATE + zpv_target: float = generation_ac_kwh / unshaded_ac_kwh + return overshading_code_from_zpv(zpv_target) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 95fb2d75..50fc7cb8 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -738,6 +738,19 @@ def _pv_annual_s_kwh_per_m2( total += days * s_m return _HOURS_PER_DAY_OVER_1000 * total + +def pv_annual_solar_radiation_kwh_per_m2( + orientation_code: int, pitch_code: int, climate: int = 0 +) -> float: + """Public seam over the SAP 10.2 Appendix U3.3 annual PV solar radiation + `S` (kWh/m²/yr) for a plane of given SAP orientation octant + RdSAP pitch + code. `climate` defaults to 0 (UK average, the rating cascade). Reused by + the Modelling solar overshading calibration (ADR-0026), which back-solves + the overshading factor ZPV from Google's expected generation against this + unshaded `S`.""" + return _pv_annual_s_kwh_per_m2(orientation_code, pitch_code, climate) + + # 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 diff --git a/tests/domain/modelling/test_solar_overshading.py b/tests/domain/modelling/test_solar_overshading.py new file mode 100644 index 00000000..9c97ce15 --- /dev/null +++ b/tests/domain/modelling/test_solar_overshading.py @@ -0,0 +1,89 @@ +"""Slice 3 — generation-calibrated PV overshading (ADR-0026). + +Google's `yearlyEnergyDcKwh` per segment already encodes real orientation, +tilt and shading from imagery. Dividing its AC equivalent by SAP's own +unshaded annual output (0.8 × kWp × S) cancels orientation/tilt and isolates +the effective overshading factor ZPV, which snaps to the RdSAP bucket +{1:1.0, 2:0.8, 3:0.5, 4:0.35}. +""" + +import json +from pathlib import Path +from typing import Any + +from domain.modelling.generators.solar_recommendation import ( + overshading_code_from_zpv, + segment_overshading_code, +) +from domain.modelling.solar_potential import SolarPotential, SolarRoofSegment +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + pv_annual_solar_radiation_kwh_per_m2, +) + +_FIXTURE: Path = ( + Path(__file__).resolve().parent + / "fixtures" + / "google_building_insights_001431.json" +) +_DC_TO_AC_RATE = 0.955 +_SAP_PV_PERFORMANCE_FACTOR = 0.8 + + +def _insights() -> dict[str, Any]: + with _FIXTURE.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return data + + +def test_overshading_cutpoints_snap_to_rdsap_buckets() -> None: + # Arrange / Act / Assert — ADR-0026 midpoints: ≥0.90→1, 0.65–0.90→2, + # 0.425–0.65→3, <0.425→4, and ZPV>1 clamps to 1. + assert overshading_code_from_zpv(1.20) == 1 + assert overshading_code_from_zpv(0.90) == 1 + assert overshading_code_from_zpv(0.89) == 2 + assert overshading_code_from_zpv(0.65) == 2 + assert overshading_code_from_zpv(0.64) == 3 + assert overshading_code_from_zpv(0.425) == 3 + assert overshading_code_from_zpv(0.42) == 4 + assert overshading_code_from_zpv(0.10) == 4 + + +def _segment_with_zpv(target_zpv: float) -> SolarRoofSegment: + """A south-facing 30°-tilt 2 kWp segment whose Google generation is set so + its back-solved overshading factor is ``target_zpv``.""" + orientation, pitch_code, panels, capacity = 5, 2, 5, 400.0 # 5 × 400 W = 2 kWp + kwp = panels * capacity / 1000 + s = pv_annual_solar_radiation_kwh_per_m2(orientation, pitch_code) + g_ac = _SAP_PV_PERFORMANCE_FACTOR * kwp * s * target_zpv + yearly_dc = g_ac / _DC_TO_AC_RATE + return SolarRoofSegment( + segment_index=0, + panels_count=panels, + azimuth_degrees=180.0, # S → octant 5 + pitch_degrees=30.0, # → code 2 + yearly_energy_dc_kwh=yearly_dc, + ) + + +def test_segment_overshading_recovers_each_bucket() -> None: + # Arrange / Act / Assert — a segment dialled to each bucket midpoint + capacity = 400.0 + assert segment_overshading_code(_segment_with_zpv(1.0), capacity) == 1 + assert segment_overshading_code(_segment_with_zpv(0.8), capacity) == 2 + assert segment_overshading_code(_segment_with_zpv(0.5), capacity) == 3 + assert segment_overshading_code(_segment_with_zpv(0.35), capacity) == 4 + + +def test_real_example_segments_are_unshaded() -> None: + # Arrange + potential = SolarPotential.from_building_insights(_insights()) + largest = potential.configurations[-1] + + # Act + codes = { + segment_overshading_code(seg, potential.panel_capacity_watts) + for seg in largest.segments + } + + # Assert — a clear London roof: every plane back-solves to ZPV > 1 → code 1 + assert codes == {1}