diff --git a/domain/modelling/solar_potential.py b/domain/modelling/solar_potential.py index d805e131..cf9c13ad 100644 --- a/domain/modelling/solar_potential.py +++ b/domain/modelling/solar_potential.py @@ -62,6 +62,14 @@ class SolarRoofSegment: 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: @@ -94,6 +102,11 @@ class SolarPotential: 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( @@ -111,18 +124,46 @@ class SolarPotential: or "panelCapacityWatts" not in solar_potential ): return None + # Per-segment centre + area live on the top-level `roofSegmentStats`, + # keyed by `segmentIndex`; the per-config `roofSegmentSummaries` carry + # only the panel/orientation fields. Build the lookup once. + stats_by_index: dict[int, Mapping[str, Any]] = { + int(stats["segmentIndex"]): stats + for stats in 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( - 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"]), - ) + _segment(summary) for summary in config.get("roofSegmentSummaries", []) ), ) @@ -132,4 +173,14 @@ class SolarPotential: 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 + ), )