mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): conservative PV config selection (5-config spread)
Slice 4 of the Solar PV Recommendation Generator (ADR-0026). `select_conservative_configs` turns Google's full solarPanelConfigs ladder into up to five competing array configs for the Optimiser: drop north-facing planes (within 30° of due north, wrap-aware), cap usable panels at ~70% of maxArrayPanelsCount (imagery misses obstructions; MCS edge setback), collapse rungs that trim to the same usable size keeping the higher-generation layout, then sample five spanning min→max by expected generation. Returns () when nothing usable remains. Real London example → 5 rungs at 4/12/19/26/34 panels (all ≤34.3 = 70% of 49); synthetic cases pin the north-drop and the 70% cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
82c3422788
commit
c03f4ff123
2 changed files with 209 additions and 1 deletions
|
|
@ -13,11 +13,26 @@ selection, the overlay and `recommend_solar` land in later slices.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.modelling.solar_potential import SolarRoofSegment
|
||||
from domain.modelling.solar_potential import (
|
||||
SolarPanelConfiguration,
|
||||
SolarPotential,
|
||||
SolarRoofSegment,
|
||||
)
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
pv_annual_solar_radiation_kwh_per_m2,
|
||||
)
|
||||
|
||||
# A roof plane within this many degrees of due north (0°/360°, Google compass
|
||||
# convention) is dropped: it generates little and is not worth panelling. The
|
||||
# legacy `GoogleSolarApi.NORTH_FACING_AZIMUTH_RANGE` used the same ±30° band.
|
||||
_NORTH_AZIMUTH_HALF_WIDTH = 30.0
|
||||
# Cap usable panels at ~70% of Google's maxArrayPanelsCount — imagery misses
|
||||
# obstructions (flues, dormers) and MCS wants a ~0.3 m edge setback, so the
|
||||
# theoretical maximum is optimistic.
|
||||
_USABLE_PANEL_FRACTION = 0.70
|
||||
# At most this many competing configs go to the Optimiser (× battery on/off).
|
||||
_MAX_CONFIGS = 5
|
||||
|
||||
# Google Solar inverter DC→AC efficiency — the canonical rate the legacy
|
||||
# `GoogleSolarApi.dc_to_ac_rate` uses (mid of the 93–98% range); distinct from
|
||||
# the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback.
|
||||
|
|
@ -68,3 +83,65 @@ def segment_overshading_code(
|
|||
generation_ac_kwh: float = segment.yearly_energy_dc_kwh * _DC_TO_AC_RATE
|
||||
zpv_target: float = generation_ac_kwh / unshaded_ac_kwh
|
||||
return overshading_code_from_zpv(zpv_target)
|
||||
|
||||
|
||||
def _is_north_facing(azimuth_degrees: float) -> bool:
|
||||
"""Whether a roof plane faces within 30° of due north (Google compass: 0°/
|
||||
360° = N), handling the 360° wrap."""
|
||||
return (
|
||||
azimuth_degrees <= _NORTH_AZIMUTH_HALF_WIDTH
|
||||
or azimuth_degrees >= 360.0 - _NORTH_AZIMUTH_HALF_WIDTH
|
||||
)
|
||||
|
||||
|
||||
def _drop_north_segments(config: SolarPanelConfiguration) -> SolarPanelConfiguration:
|
||||
"""Trim a configuration to its non-north planes, recomputing the array's
|
||||
panel count and expected generation to the usable subset."""
|
||||
kept: tuple[SolarRoofSegment, ...] = tuple(
|
||||
segment
|
||||
for segment in config.segments
|
||||
if not _is_north_facing(segment.azimuth_degrees)
|
||||
)
|
||||
return SolarPanelConfiguration(
|
||||
panels_count=sum(segment.panels_count for segment in kept),
|
||||
yearly_energy_dc_kwh=sum(segment.yearly_energy_dc_kwh for segment in kept),
|
||||
segments=kept,
|
||||
)
|
||||
|
||||
|
||||
def select_conservative_configs(
|
||||
potential: SolarPotential,
|
||||
) -> tuple[SolarPanelConfiguration, ...]:
|
||||
"""Choose up to five conservatively-sized array configs for the Optimiser
|
||||
(ADR-0026): drop north-facing planes, cap usable panels at ~70% of
|
||||
maxArrayPanelsCount, then sample five spanning min→max by expected
|
||||
generation (the size-suitability proxy) so the size/cost choice is genuine.
|
||||
Returns an empty tuple when nothing usable remains."""
|
||||
panel_cap: float = _USABLE_PANEL_FRACTION * potential.max_array_panels_count
|
||||
feasible: list[SolarPanelConfiguration] = [
|
||||
trimmed
|
||||
for config in potential.configurations
|
||||
for trimmed in (_drop_north_segments(config),)
|
||||
if trimmed.segments and trimmed.panels_count <= panel_cap
|
||||
]
|
||||
if not feasible:
|
||||
return ()
|
||||
# Collapse rungs that trimmed to the same usable size (north-drop can make
|
||||
# distinct original rungs coincide), keeping the higher-generation layout —
|
||||
# the Optimiser's dial is panel count (≈ kWp ≈ cost), so duplicates of the
|
||||
# same size add no choice.
|
||||
best_by_size: dict[int, SolarPanelConfiguration] = {}
|
||||
for config in feasible:
|
||||
incumbent = best_by_size.get(config.panels_count)
|
||||
if incumbent is None or config.yearly_energy_dc_kwh > incumbent.yearly_energy_dc_kwh:
|
||||
best_by_size[config.panels_count] = config
|
||||
unique: list[SolarPanelConfiguration] = sorted(
|
||||
best_by_size.values(), key=lambda c: c.yearly_energy_dc_kwh
|
||||
)
|
||||
if len(unique) > _MAX_CONFIGS:
|
||||
last: int = len(unique) - 1
|
||||
sampled_indices: list[int] = sorted(
|
||||
{round(i * last / (_MAX_CONFIGS - 1)) for i in range(_MAX_CONFIGS)}
|
||||
)
|
||||
unique = [unique[index] for index in sampled_indices]
|
||||
return tuple(sorted(unique, key=lambda c: c.panels_count))
|
||||
|
|
|
|||
131
tests/domain/modelling/test_solar_config_selection.py
Normal file
131
tests/domain/modelling/test_solar_config_selection.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""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())
|
||||
|
||||
# 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 == ()
|
||||
Loading…
Add table
Reference in a new issue