feat(modelling): generation-calibrated PV overshading derivation

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 09:59:48 +00:00
parent f31d5bcff9
commit 82c3422788
3 changed files with 172 additions and 0 deletions

View file

@ -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 9398% 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.650.90→2,
# 0.4250.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)

View file

@ -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//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

View file

@ -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.650.90→2,
# 0.4250.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}