Merge pull request #1289 from Hestia-Homes/fix/solar-missing-max-array-panels

Treat solar block without array sizing as no-solar (fix KeyError maxArrayPanelsCount, 2 e2e failures)
This commit is contained in:
Jun-te Kim 2026-06-24 11:59:57 +01:00 committed by GitHub
commit 46fc8f338c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 36 additions and 4 deletions

View file

@ -19,7 +19,7 @@ Potential).
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping
from typing import Any, Mapping, Optional
# Google's `azimuthDegrees` is a compass bearing: 0°=N, 90°=E, 180°=S, 270°=W,
# increasing clockwise. The SAP octant codes (ORIENTATION_BY_SAP10_CODE in the
@ -96,10 +96,21 @@ class SolarPotential:
configurations: tuple[SolarPanelConfiguration, ...]
@classmethod
def from_building_insights(cls, insights: Mapping[str, Any]) -> "SolarPotential":
def from_building_insights(
cls, insights: Mapping[str, Any]
) -> Optional["SolarPotential"]:
"""Project a raw Google ``buildingInsights`` response (as persisted by
`SolarRepository`) into a `SolarPotential`."""
`SolarRepository`) into a `SolarPotential`, or None when the
``solarPotential`` block lacks the array-level sizing fields
(``maxArrayPanelsCount`` / ``panelCapacityWatts``) Google returns such
partial blocks for buildings with no usable solar estimate, which is a
"no solar potential" outcome, not a hard error."""
solar_potential: Mapping[str, Any] = insights["solarPotential"]
if (
"maxArrayPanelsCount" not in solar_potential
or "panelCapacityWatts" not in solar_potential
):
return None
configurations: tuple[SolarPanelConfiguration, ...] = tuple(
SolarPanelConfiguration(
panels_count=int(config["panelsCount"]),

View file

@ -46,6 +46,7 @@ def _segment(panels: int, azimuth: float, energy: float) -> SolarRoofSegment:
def test_real_example_samples_five_spanning_configs() -> None:
# Arrange
potential = SolarPotential.from_building_insights(_insights())
assert potential is not None
# Act
configs = select_conservative_configs(potential)

View file

@ -77,6 +77,7 @@ def test_segment_overshading_recovers_each_bucket() -> None:
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

View file

@ -54,6 +54,20 @@ def test_pitch_to_sap_code_snaps_to_rdsap_enum() -> None:
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()
@ -62,6 +76,7 @@ def test_projection_reads_potential_level_fields() -> None:
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
@ -73,6 +88,7 @@ def test_projection_first_config_single_segment() -> None:
# 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
@ -93,6 +109,7 @@ def test_projection_largest_config_spans_all_segments() -> None:
# 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

View file

@ -44,7 +44,9 @@ class _StubProducts(ProductRepository):
def _solar_potential() -> SolarPotential:
with _FIXTURE.open(encoding="utf-8") as handle:
data: dict[str, Any] = json.load(handle)
return SolarPotential.from_building_insights(data)
potential = SolarPotential.from_building_insights(data)
assert potential is not None
return potential
def _eligible_house() -> EpcPropertyData: