Model/domain/modelling/generators/solar_recommendation.py
Khalim Conn-Kowlessar c03f4ff123 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>
2026-06-08 10:02:15 +00:00

147 lines
6.5 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.

"""The Solar PV Recommendation Generator (ADR-0026).
Offers competing whole-array PV Options built from real Google Solar imagery
(a typed `SolarPotential`), not an estimate. Unlike the heating bundles, the
SAP scoring side is already mature — the calculator does Appendix M β-split,
G4 diverter, SEG export, batteries and monthly E_PV — so this generator fixes
the *recommendation* side: where the array config comes from, how it is
conservatively sized, the new PV Overlay surface, and the composite cost.
This slice covers the generation-calibrated overshading derivation; config
selection, the overlay and `recommend_solar` land in later slices.
"""
from __future__ import annotations
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 9398% range); distinct from
# the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback.
_DC_TO_AC_RATE = 0.955
# SAP 10.2 Appendix M PV annual output: E = 0.8 × kWp × S × ZPV. The 0.8 is the
# in-system performance factor; back-solving for ZPV isolates the effective
# overshading once orientation (S) and size (kWp) are divided out.
_SAP_PV_PERFORMANCE_FACTOR = 0.8
# ADR-0026 overshading cutpoints — the lower bound of each RdSAP bucket's ZPV
# midpoint band {1:1.0, 2:0.8, 3:0.5, 4:0.35}: ≥0.90→1, 0.650.90→2,
# 0.4250.65→3, <0.425→4. ZPV > 1 (Google beats SAP's unshaded model) clamps
# to 1 via the ≥0.90 branch. RdSAP10 has no "Severe" 5th bucket.
_OVERSHADING_LOWER_BOUNDS: tuple[tuple[float, int], ...] = (
(0.90, 1),
(0.65, 2),
(0.425, 3),
)
_OVERSHADING_HEAVY_CODE = 4
def overshading_code_from_zpv(zpv_target: float) -> int:
"""Snap a back-solved effective shading factor ZPV to the RdSAP overshading
code (1 = very little/none … 4 = heavy), per the ADR-0026 cutpoints."""
for lower_bound, code in _OVERSHADING_LOWER_BOUNDS:
if zpv_target >= lower_bound:
return code
return _OVERSHADING_HEAVY_CODE
def segment_overshading_code(
segment: SolarRoofSegment, panel_capacity_watts: float
) -> int:
"""Derive a roof segment's RdSAP overshading code from Google's expected
generation (ADR-0026). Google's `yearlyEnergyDcKwh` already encodes real
orientation, tilt and shading; dividing its AC equivalent by SAP's own
unshaded annual output (0.8 × kWp × S) cancels orientation/tilt and leaves
the effective overshading factor ZPV, which snaps to the bucket."""
kwp: float = segment.panels_count * panel_capacity_watts / 1000.0
s: float = pv_annual_solar_radiation_kwh_per_m2(
segment.sap_orientation, segment.sap_pitch_code
)
unshaded_ac_kwh: float = _SAP_PV_PERFORMANCE_FACTOR * kwp * s
if unshaded_ac_kwh <= 0.0:
# No panels, or an orientation the calculator scores as zero — nothing
# to shade; the modal "no shading" code.
return 1
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))