"""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 # 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 @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, ...] @classmethod def from_building_insights(cls, insights: Mapping[str, Any]) -> "SolarPotential": """Project a raw Google ``buildingInsights`` response (as persisted by `SolarRepository`) into a `SolarPotential`.""" solar_potential: Mapping[str, Any] = insights["solarPotential"] configurations: tuple[SolarPanelConfiguration, ...] = tuple( SolarPanelConfiguration( panels_count=int(config["panelsCount"]), yearly_energy_dc_kwh=float(config["yearlyEnergyDcKwh"]), segments=tuple( SolarRoofSegment( segment_index=int(summary["segmentIndex"]), panels_count=int(summary["panelsCount"]), azimuth_degrees=float(summary["azimuthDegrees"]), pitch_degrees=float(summary["pitchDegrees"]), yearly_energy_dc_kwh=float(summary["yearlyEnergyDcKwh"]), ) 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, )