mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Three corrections found by re-running property 742003 end-to-end: - roofSegmentStats are POSITIONAL — real responses omit the segmentIndex field the fixture happened to carry; key the centre/area lookup by array position. - Base the cap on ground_floor_area (the footprint the roof covers), not the greatest per-storey area; roof_area is the fallback. - Clamp the basis by total_floor_area: predicted EPCs borrow the structural template's geometry (742003: a 118.62 m² MAIN ground floor) decoupled from the predicted 55 m² (ADR-0029), so without the clamp the cap reads the template's larger footprint. Result: 742003 plan A/92.4 (16 kWp) -> C/74.4 (6.4 kWp). 29 solar tests + orchestration threading + products green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
8.1 KiB
Python
190 lines
8.1 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
|
|
# 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
|
|
),
|
|
)
|