"""Solar Potential — the installable PV potential of a dwelling, projected from a Google Solar ``buildingInsights`` response (ADR-0026). The production source of PV array configuration is the Google Solar API: the raw ``buildingInsights`` JSON is fetched once by Ingestion and persisted as JSONB (`SolarRepository`), never re-fetched. This module is the strictly-typed projection Modelling reads over that JSON — the panel-count ladder (``solarPanelConfigs``), each rung broken into the roof segments the SAP calculator scores, with Google's continuous azimuth/tilt mapped to the SAP octant / RdSAP pitch enums. `SolarPotential` is *not* the dwelling's existing PV (that lives on the EPC's ``photovoltaic_arrays`` and is empty for a non-PV dwelling); it is the *potential* the solar Recommendation Generator installs. The Google JSON → `SolarPotential` mapping is its own validated boundary (CONTEXT: Solar Potential). """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Mapping, Optional # Google's `azimuthDegrees` is a compass bearing: 0°=N, 90°=E, 180°=S, 270°=W, # increasing clockwise. The SAP octant codes (ORIENTATION_BY_SAP10_CODE in the # calculator) are 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW — exactly the # eight 45° compass points in code order, so snapping to the nearest octant and # adding one yields the SAP code. _OCTANT_COUNT = 8 _DEGREES_PER_OCTANT = 45.0 # RdSAP 10 §11.1 fixes PV tilt to one of five values; the calculator's # `_PV_PITCH_DEG_BY_CODE` is the inverse of this. Google reports a continuous # `pitchDegrees`, so we snap to the nearest fixed tilt and return its code. _PITCH_CODE_BY_DEGREES: dict[float, int] = {0.0: 1, 30.0: 2, 45.0: 3, 60.0: 4, 90.0: 5} def azimuth_to_sap_octant(azimuth_degrees: float) -> int: """Bucket a Google compass azimuth (0°=N, clockwise) to the SAP octant code {1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW}.""" index: int = round(azimuth_degrees / _DEGREES_PER_OCTANT) % _OCTANT_COUNT return index + 1 def pitch_to_sap_code(pitch_degrees: float) -> int: """Snap a Google continuous tilt to the nearest RdSAP 10 §11.1 fixed tilt and return its code {0°→1, 30°→2, 45°→3, 60°→4, 90°→5}.""" nearest: float = min( _PITCH_CODE_BY_DEGREES, key=lambda deg: abs(deg - pitch_degrees) ) return _PITCH_CODE_BY_DEGREES[nearest] @dataclass(frozen=True) class SolarRoofSegment: """One roof plane within a panel configuration — the panels Google places on it and the orientation, tilt and expected DC generation that drive the SAP Appendix M output.""" segment_index: int panels_count: int azimuth_degrees: float pitch_degrees: float yearly_energy_dc_kwh: float # Per-segment centre + roof-plane area, enriched from the top-level # `roofSegmentStats` (keyed by `segmentIndex`) — the per-config # `roofSegmentSummaries` omit them. Used by the Dwelling-Roof Cap # (ADR-0038) to rank segments by distance from the dwelling and bound the # array by usable roof area. None when the stats block lacks the segment. center_latitude: Optional[float] = None center_longitude: Optional[float] = None area_m2: Optional[float] = None @property def sap_orientation(self) -> int: """The SAP octant code for this plane's azimuth.""" return azimuth_to_sap_octant(self.azimuth_degrees) @property def sap_pitch_code(self) -> int: """The RdSAP §11.1 pitch code for this plane's tilt.""" return pitch_to_sap_code(self.pitch_degrees) @dataclass(frozen=True) class SolarPanelConfiguration: """One rung of Google's ``solarPanelConfigs`` ladder: a whole-array layout of ``panels_count`` panels spread across the roof segments, with the array's total expected yearly DC generation.""" panels_count: int yearly_energy_dc_kwh: float segments: tuple[SolarRoofSegment, ...] @dataclass(frozen=True) class SolarPotential: """Strictly-typed projection of a Google Solar ``buildingInsights`` response — the panel ladder and the per-segment geometry Modelling needs to size, score and cost a PV array (ADR-0026).""" panel_capacity_watts: float max_array_panels_count: int configurations: tuple[SolarPanelConfiguration, ...] # Physical panel footprint (Google `panelHeightMeters` / `panelWidthMeters`) # — the Dwelling-Roof Cap (ADR-0038) converts a usable roof-area budget into # a panel count via this. None for partial blocks lacking the fields. panel_height_m: Optional[float] = None panel_width_m: Optional[float] = None @classmethod def from_building_insights( cls, insights: Mapping[str, Any] ) -> Optional["SolarPotential"]: """Project a raw Google ``buildingInsights`` response (as persisted by `SolarRepository`) into a `SolarPotential`, or None when the ``solarPotential`` block lacks the array-level sizing fields (``maxArrayPanelsCount`` / ``panelCapacityWatts``) — Google returns such partial blocks for buildings with no usable solar estimate, which is a "no solar potential" outcome, not a hard error.""" solar_potential: Mapping[str, Any] = insights["solarPotential"] if ( "maxArrayPanelsCount" not in solar_potential or "panelCapacityWatts" not in solar_potential ): return None # Per-segment centre + area live on the top-level `roofSegmentStats`; # the per-config `roofSegmentSummaries` carry only the panel/orientation # fields. `roofSegmentSummaries[].segmentIndex` refers to the POSITION in # `roofSegmentStats` (the entries are positional — Google omits an # explicit `segmentIndex` field on them), so key the lookup by position. stats_by_index: dict[int, Mapping[str, Any]] = { index: stats for index, stats in enumerate( solar_potential.get("roofSegmentStats", []) ) } def _segment(summary: Mapping[str, Any]) -> SolarRoofSegment: index: int = int(summary["segmentIndex"]) stats: Optional[Mapping[str, Any]] = stats_by_index.get(index) center: Mapping[str, Any] = ( stats.get("center", {}) if stats is not None else {} ) area: Optional[float] = ( float(stats["stats"]["areaMeters2"]) if stats is not None and "stats" in stats else None ) return SolarRoofSegment( segment_index=index, panels_count=int(summary["panelsCount"]), azimuth_degrees=float(summary["azimuthDegrees"]), pitch_degrees=float(summary["pitchDegrees"]), yearly_energy_dc_kwh=float(summary["yearlyEnergyDcKwh"]), center_latitude=( float(center["latitude"]) if "latitude" in center else None ), center_longitude=( float(center["longitude"]) if "longitude" in center else None ), area_m2=area, ) configurations: tuple[SolarPanelConfiguration, ...] = tuple( SolarPanelConfiguration( panels_count=int(config["panelsCount"]), yearly_energy_dc_kwh=float(config["yearlyEnergyDcKwh"]), segments=tuple( _segment(summary) for summary in config.get("roofSegmentSummaries", []) ), ) for config in solar_potential.get("solarPanelConfigs", []) ) return cls( panel_capacity_watts=float(solar_potential["panelCapacityWatts"]), max_array_panels_count=int(solar_potential["maxArrayPanelsCount"]), configurations=configurations, panel_height_m=( float(solar_potential["panelHeightMeters"]) if "panelHeightMeters" in solar_potential else None ), panel_width_m=( float(solar_potential["panelWidthMeters"]) if "panelWidthMeters" in solar_potential else None ), )