Model/tests/domain/modelling/test_solar_potential.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

119 lines
3.9 KiB
Python

"""Slice 2 — the typed `SolarPotential` projection over a Google Solar
`buildingInsights` response (ADR-0026).
Pins the orientation/pitch mappings and the projection against the real
London `buildingInsights` example (mirrored into fixtures from the
user-provided RTF).
"""
import json
from pathlib import Path
from typing import Any
from domain.modelling.solar_potential import (
SolarPotential,
azimuth_to_sap_octant,
pitch_to_sap_code,
)
_FIXTURE: Path = (
Path(__file__).resolve().parent
/ "fixtures"
/ "google_building_insights_001431.json"
)
def _insights() -> dict[str, Any]:
with _FIXTURE.open(encoding="utf-8") as handle:
data: dict[str, Any] = json.load(handle)
return data
def test_azimuth_to_sap_octant_cardinals_and_diagonals() -> None:
# Arrange / Act / Assert — Google azimuth 0=N clockwise → SAP octant code
assert azimuth_to_sap_octant(0.0) == 1 # N
assert azimuth_to_sap_octant(45.0) == 2 # NE
assert azimuth_to_sap_octant(90.0) == 3 # E
assert azimuth_to_sap_octant(135.0) == 4 # SE
assert azimuth_to_sap_octant(180.0) == 5 # S
assert azimuth_to_sap_octant(225.0) == 6 # SW
assert azimuth_to_sap_octant(270.0) == 7 # W
assert azimuth_to_sap_octant(315.0) == 8 # NW
assert azimuth_to_sap_octant(360.0) == 1 # wraps to N
def test_pitch_to_sap_code_snaps_to_rdsap_enum() -> None:
# Arrange / Act / Assert — RdSAP 10 §11.1 fixed tilts
assert pitch_to_sap_code(0.0) == 1
assert pitch_to_sap_code(30.0) == 2
assert pitch_to_sap_code(45.0) == 3
assert pitch_to_sap_code(60.0) == 4
assert pitch_to_sap_code(90.0) == 5
# Real Google pitches (~32-34°) snap to the 30° code
assert pitch_to_sap_code(33.65681) == 2
assert pitch_to_sap_code(31.896425) == 2
def test_projection_returns_none_when_array_sizing_absent() -> None:
# Arrange — Google returns a solarPotential block with no array-sizing
# fields for buildings with no usable solar estimate; this previously
# KeyError'd on `maxArrayPanelsCount` and failed the whole property.
insights = _insights()
del insights["solarPotential"]["maxArrayPanelsCount"]
# Act
potential = SolarPotential.from_building_insights(insights)
# Assert — no usable solar potential, not a crash
assert potential is None
def test_projection_reads_potential_level_fields() -> None:
# Arrange
insights = _insights()
# Act
potential = SolarPotential.from_building_insights(insights)
# Assert
assert potential is not None
assert abs(potential.panel_capacity_watts - 400.0) <= 1e-4
assert potential.max_array_panels_count == 49
assert len(potential.configurations) == 46
def test_projection_first_config_single_segment() -> None:
# Arrange
insights = _insights()
# Act
potential = SolarPotential.from_building_insights(insights)
assert potential is not None
first = potential.configurations[0]
# Assert — the smallest rung: 4 panels on one SE roof plane
assert first.panels_count == 4
assert len(first.segments) == 1
segment = first.segments[0]
assert segment.segment_index == 1
assert segment.panels_count == 4
assert abs(segment.azimuth_degrees - 136.27895) <= 1e-4
assert abs(segment.yearly_energy_dc_kwh - 1617.0192) <= 1e-4
assert segment.sap_orientation == 4 # SE
assert segment.sap_pitch_code == 2 # ~32° → 30°
def test_projection_largest_config_spans_all_segments() -> None:
# Arrange
insights = _insights()
# Act
potential = SolarPotential.from_building_insights(insights)
assert potential is not None
largest = potential.configurations[-1]
# Assert — the 49-panel rung spans all four roof planes
assert largest.panels_count == 49
assert sum(s.panels_count for s in largest.segments) == 49
octants = {s.sap_orientation for s in largest.segments}
assert octants == {8, 4, 2, 6} # NW, SE, NE, SW