Model/tests/domain/modelling/test_solar_overshading.py
Jun-te Kim 79f89d872e Treat solar block without array sizing as no-solar (fix KeyError)
2 modelling_e2e properties failed with KeyError: 'maxArrayPanelsCount'.

Google returns a `solarPotential` block with no array-level sizing fields
(`maxArrayPanelsCount` / `panelCapacityWatts`) for buildings with no usable
solar estimate. `SolarPotential.from_building_insights` hard-indexed those keys
and crashed the whole property.

Fix: the projection now returns Optional and yields None when those fields are
absent — the established "no solar potential" outcome (the orchestrator and
recommendation path already type it Optional and skip solar on None). Existing
callers (`_solar_potential_for`, harness) already assign to Optional.

Regression test + `assert is not None` narrowing on the valid-fixture tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:22:06 +00:00

90 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())
assert potential is not None
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}