mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): typed SolarPotential projection over Google buildingInsights
Slice 2 of the Solar PV Recommendation Generator (ADR-0026). Adds the
strictly-typed `SolarPotential` domain projection over the raw Google Solar
`buildingInsights` JSON that Ingestion persists (SolarRepository): the
`solarPanelConfigs` ladder, each rung broken into its roof segments with
Google's continuous azimuth/tilt mapped to the SAP octant
(`azimuth_to_sap_octant`, 0°=N clockwise → 1=N..8=NW, matching the
calculator's ORIENTATION_BY_SAP10_CODE) and RdSAP §11.1 pitch code
(`pitch_to_sap_code`, snap to {0→1,30→2,45→3,60→4,90→5}).
Pinned against the real London buildingInsights example (mirrored into
fixtures from the user-provided RTF): 400 W panels, maxArrayPanelsCount 49,
46-rung ladder, per-segment SE/NW/NE/SW octants at ~32° → pitch code 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
545bb8c328
commit
f31d5bcff9
3 changed files with 2290 additions and 0 deletions
124
domain/modelling/solar_potential.py
Normal file
124
domain/modelling/solar_potential.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""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,
|
||||
)
|
||||
2064
tests/domain/modelling/fixtures/google_building_insights_001431.json
Normal file
2064
tests/domain/modelling/fixtures/google_building_insights_001431.json
Normal file
File diff suppressed because it is too large
Load diff
102
tests/domain/modelling/test_solar_potential.py
Normal file
102
tests/domain/modelling/test_solar_potential.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Slice 2 — the typed `SolarPotential` projection over a Google Solar
|
||||
`buildingInsights` response (ADR-0026).
|
||||
|
||||
Pins the orientation/pitch mappings and the projection against the real
|
||||
London `buildingInsights` example (mirrored into fixtures from the
|
||||
user-provided RTF).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from domain.modelling.solar_potential import (
|
||||
SolarPotential,
|
||||
azimuth_to_sap_octant,
|
||||
pitch_to_sap_code,
|
||||
)
|
||||
|
||||
_FIXTURE: Path = (
|
||||
Path(__file__).resolve().parent
|
||||
/ "fixtures"
|
||||
/ "google_building_insights_001431.json"
|
||||
)
|
||||
|
||||
|
||||
def _insights() -> dict[str, Any]:
|
||||
with _FIXTURE.open(encoding="utf-8") as handle:
|
||||
data: dict[str, Any] = json.load(handle)
|
||||
return data
|
||||
|
||||
|
||||
def test_azimuth_to_sap_octant_cardinals_and_diagonals() -> None:
|
||||
# Arrange / Act / Assert — Google azimuth 0=N clockwise → SAP octant code
|
||||
assert azimuth_to_sap_octant(0.0) == 1 # N
|
||||
assert azimuth_to_sap_octant(45.0) == 2 # NE
|
||||
assert azimuth_to_sap_octant(90.0) == 3 # E
|
||||
assert azimuth_to_sap_octant(135.0) == 4 # SE
|
||||
assert azimuth_to_sap_octant(180.0) == 5 # S
|
||||
assert azimuth_to_sap_octant(225.0) == 6 # SW
|
||||
assert azimuth_to_sap_octant(270.0) == 7 # W
|
||||
assert azimuth_to_sap_octant(315.0) == 8 # NW
|
||||
assert azimuth_to_sap_octant(360.0) == 1 # wraps to N
|
||||
|
||||
|
||||
def test_pitch_to_sap_code_snaps_to_rdsap_enum() -> None:
|
||||
# Arrange / Act / Assert — RdSAP 10 §11.1 fixed tilts
|
||||
assert pitch_to_sap_code(0.0) == 1
|
||||
assert pitch_to_sap_code(30.0) == 2
|
||||
assert pitch_to_sap_code(45.0) == 3
|
||||
assert pitch_to_sap_code(60.0) == 4
|
||||
assert pitch_to_sap_code(90.0) == 5
|
||||
# Real Google pitches (~32-34°) snap to the 30° code
|
||||
assert pitch_to_sap_code(33.65681) == 2
|
||||
assert pitch_to_sap_code(31.896425) == 2
|
||||
|
||||
|
||||
def test_projection_reads_potential_level_fields() -> None:
|
||||
# Arrange
|
||||
insights = _insights()
|
||||
|
||||
# Act
|
||||
potential = SolarPotential.from_building_insights(insights)
|
||||
|
||||
# Assert
|
||||
assert abs(potential.panel_capacity_watts - 400.0) <= 1e-4
|
||||
assert potential.max_array_panels_count == 49
|
||||
assert len(potential.configurations) == 46
|
||||
|
||||
|
||||
def test_projection_first_config_single_segment() -> None:
|
||||
# Arrange
|
||||
insights = _insights()
|
||||
|
||||
# Act
|
||||
potential = SolarPotential.from_building_insights(insights)
|
||||
first = potential.configurations[0]
|
||||
|
||||
# Assert — the smallest rung: 4 panels on one SE roof plane
|
||||
assert first.panels_count == 4
|
||||
assert len(first.segments) == 1
|
||||
segment = first.segments[0]
|
||||
assert segment.segment_index == 1
|
||||
assert segment.panels_count == 4
|
||||
assert abs(segment.azimuth_degrees - 136.27895) <= 1e-4
|
||||
assert abs(segment.yearly_energy_dc_kwh - 1617.0192) <= 1e-4
|
||||
assert segment.sap_orientation == 4 # SE
|
||||
assert segment.sap_pitch_code == 2 # ~32° → 30°
|
||||
|
||||
|
||||
def test_projection_largest_config_spans_all_segments() -> None:
|
||||
# Arrange
|
||||
insights = _insights()
|
||||
|
||||
# Act
|
||||
potential = SolarPotential.from_building_insights(insights)
|
||||
largest = potential.configurations[-1]
|
||||
|
||||
# Assert — the 49-panel rung spans all four roof planes
|
||||
assert largest.panels_count == 49
|
||||
assert sum(s.panels_count for s in largest.segments) == 49
|
||||
octants = {s.sap_orientation for s in largest.segments}
|
||||
assert octants == {8, 4, 2, 6} # NW, SE, NE, SW
|
||||
Loading…
Add table
Reference in a new issue