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

132 lines
4 KiB
Python

"""Slice 4 — conservative PV config selection (ADR-0026).
From Google's full `solarPanelConfigs` ladder, drop north-facing segments
(within 30° of due north), cap usable panels at ~70% of maxArrayPanelsCount
(imagery misses obstructions; MCS wants an edge setback), then sample up to
five configs spanning min→max by energy so the Optimiser gets a genuine
size/cost choice.
"""
import json
from pathlib import Path
from typing import Any
from domain.modelling.generators.solar_recommendation import (
select_conservative_configs,
)
from domain.modelling.solar_potential import (
SolarPanelConfiguration,
SolarPotential,
SolarRoofSegment,
)
_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 _segment(panels: int, azimuth: float, energy: float) -> SolarRoofSegment:
return SolarRoofSegment(
segment_index=0,
panels_count=panels,
azimuth_degrees=azimuth,
pitch_degrees=30.0,
yearly_energy_dc_kwh=energy,
)
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)
# Assert — five rungs spanning the conservative range, ascending by size,
# all ≤ 70% of maxArrayPanelsCount (49 → 34.3)
assert [c.panels_count for c in configs] == [4, 12, 19, 26, 34]
assert all(c.panels_count <= 0.70 * potential.max_array_panels_count for c in configs)
def test_north_facing_segments_are_dropped() -> None:
# Arrange — a single config with a due-north plane and a south plane
south = _segment(panels=6, azimuth=180.0, energy=2000.0)
north = _segment(panels=4, azimuth=5.0, energy=900.0)
near_north = _segment(panels=2, azimuth=345.0, energy=400.0) # within 30° of N
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=20,
configurations=(
SolarPanelConfiguration(
panels_count=12,
yearly_energy_dc_kwh=3300.0,
segments=(south, north, near_north),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert — only the south plane survives; counts/energy recomputed to it
assert len(configs) == 1
only = configs[0]
assert only.panels_count == 6
assert abs(only.yearly_energy_dc_kwh - 2000.0) <= 1e-4
assert [s.azimuth_degrees for s in only.segments] == [180.0]
def test_cap_excludes_configs_above_seventy_percent() -> None:
# Arrange — max 10 panels → cap 7; a 6-panel and an 8-panel rung
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=10,
configurations=(
SolarPanelConfiguration(
panels_count=6,
yearly_energy_dc_kwh=2000.0,
segments=(_segment(6, 180.0, 2000.0),),
),
SolarPanelConfiguration(
panels_count=8,
yearly_energy_dc_kwh=2600.0,
segments=(_segment(8, 180.0, 2600.0),),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert — only the 6-panel rung (≤7) survives
assert [c.panels_count for c in configs] == [6]
def test_all_north_or_empty_yields_no_configs() -> None:
# Arrange — every plane faces north
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=20,
configurations=(
SolarPanelConfiguration(
panels_count=4,
yearly_energy_dc_kwh=800.0,
segments=(_segment(4, 10.0, 800.0),),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert
assert configs == ()