diff --git a/domain/modelling/solar_potential.py b/domain/modelling/solar_potential.py index 1d90e14f..d805e131 100644 --- a/domain/modelling/solar_potential.py +++ b/domain/modelling/solar_potential.py @@ -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"]), diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index d48a7aec..d8f4bcc2 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -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) diff --git a/tests/domain/modelling/test_solar_overshading.py b/tests/domain/modelling/test_solar_overshading.py index 9c97ce15..fcb43871 100644 --- a/tests/domain/modelling/test_solar_overshading.py +++ b/tests/domain/modelling/test_solar_overshading.py @@ -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 diff --git a/tests/domain/modelling/test_solar_potential.py b/tests/domain/modelling/test_solar_potential.py index 1561242f..5b1ddb54 100644 --- a/tests/domain/modelling/test_solar_potential.py +++ b/tests/domain/modelling/test_solar_potential.py @@ -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 diff --git a/tests/domain/modelling/test_solar_recommendation.py b/tests/domain/modelling/test_solar_recommendation.py index 9d51b0d8..f858a78b 100644 --- a/tests/domain/modelling/test_solar_recommendation.py +++ b/tests/domain/modelling/test_solar_recommendation.py @@ -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: