"""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))