diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index c52dd297..ed97f863 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -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)) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py new file mode 100644 index 00000000..d48a7aec --- /dev/null +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -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 == ()