mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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>
This commit is contained in:
parent
22cb47a280
commit
79f89d872e
5 changed files with 36 additions and 4 deletions
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue