Model/domain/modelling/solar_potential.py
Jun-te Kim 79f89d872e Treat solar block without array sizing as no-solar (fix KeyError)
2 modelling_e2e properties failed with KeyError: 'maxArrayPanelsCount'.

Google returns a `solarPotential` block with no array-level sizing fields
(`maxArrayPanelsCount` / `panelCapacityWatts`) for buildings with no usable
solar estimate. `SolarPotential.from_building_insights` hard-indexed those keys
and crashed the whole property.

Fix: the projection now returns Optional and yields None when those fields are
absent — the established "no solar potential" outcome (the orchestrator and
recommendation path already type it Optional and skip solar on None). Existing
callers (`_solar_potential_for`, harness) already assign to Optional.

Regression test + `assert is not None` narrowing on the valid-fixture tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:22:06 +00:00

135 lines
5.6 KiB
Python

"""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
@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]
) -> 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
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,
)