Model/tests/domain/modelling/test_solar_overshading.py
Khalim Conn-Kowlessar 82c3422788 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>
2026-06-08 09:59:48 +00:00

89 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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}