Model/tests/domain/modelling/test_solar_config_selection.py
Khalim Conn-Kowlessar 904c205e3a Dwelling-Roof Cap bounds the PV array to the dwelling's own roof 🟥
select_conservative_configs must accept the dwelling's roof area and cap panels
to its usable roof (ADR-0038) — bounding a 55m² dwelling to ~16 panels under
Google footprint conflation, while staying a no-op on correctly-matched homes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:15:28 +00:00

183 lines
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _south(panels: int) -> SolarRoofSegment:
return _segment(panels=panels, azimuth=180.0, energy=panels * 100.0)
def _potential_with_panel_dims(
max_panels: int, panel_counts: tuple[int, ...]
) -> SolarPotential:
# Google panel footprint 1.879 × 1.045 ≈ 1.964 m².
return SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=max_panels,
configurations=tuple(
SolarPanelConfiguration(
panels_count=n,
yearly_energy_dc_kwh=n * 100.0,
segments=(_south(n),),
)
for n in panel_counts
),
panel_height_m=1.879,
panel_width_m=1.045,
)
def test_dwelling_roof_cap_bounds_a_small_dwelling() -> None:
# ADR-0038: Google's maxArrayPanelsCount (58) reflects a conflated whole-
# building roof; a 55 m² dwelling's own usable roof (≈ 55/cos30° × 0.5 ≈
# 32 m² ≈ 16 panels) must bound the array, well below the 0.70×58 ≈ 40 cap.
potential = _potential_with_panel_dims(58, (4, 12, 20, 30, 41, 58))
# Google cap alone allows up to the 30-panel rung (41/58 exceed 0.70×58).
assert max(c.panels_count for c in select_conservative_configs(potential)) == 30
capped = select_conservative_configs(potential, dwelling_roof_area_m2=55.0)
assert capped # still offers the small rungs
assert all(c.panels_count <= 16 for c in capped)
assert max(c.panels_count for c in capped) == 12
def test_dwelling_roof_cap_is_a_no_op_on_a_matched_home() -> None:
# ADR-0038: on a correctly-matched home Google's roof ≈ the dwelling's, so
# the area budget is ≳ what Google offers and the cap does NOT bite.
potential = _potential_with_panel_dims(58, (4, 12, 20, 30, 41, 58))
baseline = [c.panels_count for c in select_conservative_configs(potential)]
matched = select_conservative_configs(potential, dwelling_roof_area_m2=300.0)
assert [c.panels_count for c in matched] == baseline
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 == ()