"""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 typing import Optional from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, PhotovoltaicArray, PvBatteries, PvBattery, ) from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.products import Products, SolarCostInputs from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import EpcSimulation, SolarOverlay 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, ) from repositories.product.product_repository import ProductRepository _SOLAR_SURFACE = "Solar PV" _SOLAR_MEASURE_TYPE = MeasureType.SOLAR_PV # The fixed, representative battery capacity for the with-battery variant # (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh. _BATTERY_CAPACITY_KWH = 5.0 # Watts → kilowatts for peak-power. _WATTS_PER_KW = 1000.0 # The dwelling's PV connects to its own meter (the after-cert §19 "Connected to # the dwelling's meter: Yes"). Non-load-bearing for the SAP cascade; carried for # fidelity. 1 = connected, the modal install case. _PV_CONNECTED_TO_DWELLING = 1 # 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)) def _array_for_segment( segment: SolarRoofSegment, panel_capacity_watts: float ) -> PhotovoltaicArray: """Project a chosen roof segment into a SAP `PhotovoltaicArray`: peak power from its panels, orientation/pitch from its geometry, and the generation-calibrated overshading code (ADR-0026).""" return PhotovoltaicArray( peak_power=segment.panels_count * panel_capacity_watts / _WATTS_PER_KW, pitch=segment.sap_pitch_code, orientation=segment.sap_orientation, overshading=segment_overshading_code(segment, panel_capacity_watts), ) def _solar_overlay( config: SolarPanelConfiguration, panel_capacity_watts: float, has_cylinder: bool, with_battery: bool, ) -> SolarOverlay: """Build the `SolarOverlay` for one array config variant: one `PhotovoltaicArray` per segment, export ensured, a diverter when the dwelling has a cylinder, and a battery for the with-battery variant.""" return SolarOverlay( photovoltaic_arrays=[ _array_for_segment(segment, panel_capacity_watts) for segment in config.segments ], # App G4 routes surplus PV to the cylinder immersion; a combi has nothing # to divert to, so leave the field unset (None) when there is no cylinder. pv_diverter_present=True if has_cylinder else None, pv_connection=_PV_CONNECTED_TO_DWELLING, is_dwelling_export_capable=True, pv_batteries=( PvBatteries(pv_battery=PvBattery(battery_capacity=_BATTERY_CAPACITY_KWH)) if with_battery else None ), ) def _option( config: SolarPanelConfiguration, panel_capacity_watts: float, has_cylinder: bool, with_battery: bool, products: ProductRepository, ) -> MeasureOption: """Assemble one competing Solar PV Measure Option for a config variant.""" peak_power_kwp: float = config.panels_count * panel_capacity_watts / _WATTS_PER_KW cost: Cost = Products().solar_bundle_cost( SolarCostInputs( peak_power_kwp=peak_power_kwp, has_cylinder=has_cylinder, has_battery=with_battery, ) ) battery_suffix: str = " with a 5 kWh battery" if with_battery else "" description: str = ( f"Install a {peak_power_kwp:.1f} kWp roof-mounted solar PV array" f"{battery_suffix}, ensuring an export meter" ) return MeasureOption( measure_type=_SOLAR_MEASURE_TYPE, description=description, overlay=EpcSimulation( solar=_solar_overlay( config, panel_capacity_watts, has_cylinder, with_battery ) ), cost=cost, material_id=products.get(_SOLAR_MEASURE_TYPE).id, ) def recommend_solar( epc: EpcPropertyData, products: ProductRepository, solar_potential: Optional[SolarPotential], restrictions: PlanningRestrictions = PlanningRestrictions(), ) -> Optional[Recommendation]: """Return a "Solar PV" Recommendation of competing whole-array Options — up to five conservatively-sized configs × {no battery, battery} — for an eligible dwelling with feasible Google solar potential, else None (ADR-0026). A free Optimiser candidate; the Optimiser owns whether and at what size to install it.""" if solar_potential is None or not _solar_eligible(epc, restrictions): return None configs: tuple[SolarPanelConfiguration, ...] = select_conservative_configs( solar_potential ) if not configs: return None has_cylinder: bool = bool(epc.has_hot_water_cylinder) capacity: float = solar_potential.panel_capacity_watts options: list[MeasureOption] = [ _option(config, capacity, has_cylinder, with_battery, products) for config in configs for with_battery in (False, True) ] return Recommendation(surface=_SOLAR_SURFACE, options=tuple(options)) def _solar_eligible( epc: EpcPropertyData, restrictions: PlanningRestrictions ) -> bool: """Solar PV suits a non-flat house/bungalow that is not fabric-protected and has no existing PV (ADR-0026). Eligibility encodes only physical/legal installability — the Optimiser owns the economics. A conservation area does NOT block PV (offered, installed sympathetically); a listed/heritage protection (`blocks_internal`) does — the same gate as ASHP.""" if restrictions.blocks_internal: return False if not _is_house_or_bungalow(epc): return False return not _has_existing_pv(epc) def _has_existing_pv(epc: EpcPropertyData) -> bool: """Whether the dwelling already has PV — the *existing* arrays on the EPC (existing-PV top-up is deferred), distinct from the Google potential.""" arrays: Optional[list[PhotovoltaicArray]] = epc.sap_energy_source.photovoltaic_arrays return bool(arrays) def _is_house_or_bungalow(epc: EpcPropertyData) -> bool: """Whether the dwelling is a house or bungalow (not a flat/maisonette). The Elmhurst path lodges the name; the API path a stringified RdSAP code (`PROPERTY_TYPE_LOOKUP`: 0 House, 1 Bungalow, 2 Flat, 3 Maisonette).""" raw: str = (epc.property_type or "").strip() if raw.lower() in ("house", "bungalow"): return True if raw.isdigit(): return PROPERTY_TYPE_LOOKUP.get(int(raw)) in ("House", "Bungalow") return False