mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 17:03:02 +00:00
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>
147 lines
6.5 KiB
Python
147 lines
6.5 KiB
Python
"""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 93–98% 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.65–0.90→2,
|
||
# 0.425–0.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))
|