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