Merge pull request #1119 from Hestia-Homes/claude/model-p4

Solar API Client
This commit is contained in:
Daniel Roth 2026-05-21 17:09:17 +01:00 committed by GitHub
commit dc5fcc41bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 219 additions and 57 deletions

View file

@ -1,13 +1,15 @@
import time
import requests
import pandas as pd
import numpy as np
from typing import List
from typing import Any, List
from functools import lru_cache
from sklearn.preprocessing import MinMaxScaler
from tqdm import tqdm
from math import sin, cos, sqrt, atan2, radians
from infrastructure.solar.google_solar_api_client import (
BuildingInsightsNotFoundError,
GoogleSolarApiClient,
)
from utils.logger import setup_logger
from recommendations.Costs import Costs
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
@ -57,19 +59,9 @@ class GoogleSolarApi:
# that we calcualte based on the property dimensions, we will correct the roof area
ROOF_AREA_TOLERANCE = 1.25
# Error Messages
ENTITY_NOT_FOUND_ERROR = 'Requested entity was not found.'
def __init__(self, api_key, solar_materials: list, max_retries=5):
"""
Initialize the GoogleSolarApi class with the provided API key and maximum retries.
:param api_key: The API key to authenticate requests to the Google Solar API.
:param max_retries: The maximum number of retries for the API request (default is 5).
"""
def __init__(self, api_key: str, solar_materials: list) -> None:
self.api_key = api_key
self.max_retries = max_retries
self.base_url = "https://solar.googleapis.com/v1"
self._solar_client = GoogleSolarApiClient(api_key)
self.insights_data = None
self.roof_segments = []
@ -90,48 +82,11 @@ class GoogleSolarApi:
self.allowed_segment_indices = None
def get_building_insights(self, longitude, latitude, required_quality="MEDIUM", max_retries=None):
"""
Make an API request to retrieve building insights based on the given longitude and latitude, with retry
mechanism.
:param longitude: The longitude of the location.
:param latitude: The latitude of the location.
:param required_quality: The required quality of the data (default is "MEDIUM").
:param max_retries: The maximum number of retries for the API request (default is None, which uses the
instance's max_retries).
:return: The JSON response containing the building insights data.
"""
if max_retries is None:
max_retries = self.max_retries
insights_url = f"{self.base_url}/buildingInsights:findClosest"
params = {
'location.latitude': f'{latitude:.5f}',
'location.longitude': f'{longitude:.5f}',
'requiredQuality': required_quality,
'key': self.api_key
}
attempt = 0
while attempt < max_retries:
try:
response = requests.get(insights_url, params=params)
response.raise_for_status() # Raise an error for bad status codes
return response.json()
except requests.exceptions.RequestException as e:
if (
(e.response.status_code == 404) &
(e.response.json()["error"]["message"] == self.ENTITY_NOT_FOUND_ERROR)
):
logger.warning("No building insights found for the given location.")
return {"error": self.ENTITY_NOT_FOUND_ERROR}
attempt += 1
print(f"Attempt {attempt} failed: {e}")
time.sleep(2 ** attempt) # Exponential backoff
if attempt >= max_retries:
raise
def get_building_insights(self, longitude: float, latitude: float, required_quality: str = "MEDIUM") -> dict[str, Any]:
try:
return self._solar_client.get_building_insights(longitude, latitude, required_quality) # type: ignore[arg-type]
except BuildingInsightsNotFoundError:
return {"error": GoogleSolarApiClient.ENTITY_NOT_FOUND_ERROR}
@lru_cache(maxsize=128)
def get(

View file

View file

@ -0,0 +1,50 @@
import time
from typing import Any, Literal, Optional
import requests
class BuildingInsightsNotFoundError(Exception):
pass
class GoogleSolarApiClient:
base_url: str = "https://solar.googleapis.com/v1"
MAX_RETRIES: int = 5
ENTITY_NOT_FOUND_ERROR: str = "Requested entity was not found."
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def get_building_insights(
self,
longitude: float,
latitude: float,
required_quality: Literal["HIGH", "MEDIUM", "LOW"] = "MEDIUM",
) -> dict[str, Any]:
insights_url = f"{self.base_url}/buildingInsights:findClosest"
params: dict[str, str] = {
"location.latitude": f"{latitude:.5f}",
"location.longitude": f"{longitude:.5f}",
"requiredQuality": required_quality,
"key": self._api_key,
}
last_exc: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
try:
response = requests.get(insights_url, params=params)
response.raise_for_status()
result: dict[str, Any] = response.json()
return result
except requests.exceptions.RequestException as e:
if (
e.response is not None
and e.response.status_code == 404
and e.response.json()["error"]["message"] == self.ENTITY_NOT_FOUND_ERROR
):
raise BuildingInsightsNotFoundError() from e
last_exc = e
time.sleep(2 ** attempt)
assert last_exc is not None
raise last_exc

View file

View file

@ -0,0 +1,110 @@
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import requests
from infrastructure.solar.google_solar_api_client import (
BuildingInsightsNotFoundError,
GoogleSolarApiClient,
)
# ---------------------------------------------------------------------------
# Slice 1: Successful response returns parsed JSON
# ---------------------------------------------------------------------------
def test_get_building_insights_returns_parsed_json() -> None:
# Arrange
client = GoogleSolarApiClient(api_key="test-key")
payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}}
mock_response = MagicMock()
mock_response.json.return_value = payload
with patch("requests.get", return_value=mock_response) as mock_get:
mock_response.raise_for_status.return_value = None
# Act
result = client.get_building_insights(longitude=-0.1278, latitude=51.5074)
# Assert
assert result == payload
mock_get.assert_called_once()
# ---------------------------------------------------------------------------
# Slice 2: Transient HTTP errors trigger retries
# ---------------------------------------------------------------------------
def test_get_building_insights_retries_on_transient_error() -> None:
# Arrange
client = GoogleSolarApiClient(api_key="test-key")
payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}}
transient_error = requests.exceptions.ConnectionError("timeout")
transient_error.response = None # type: ignore[attr-defined]
success_response = MagicMock()
success_response.json.return_value = payload
success_response.raise_for_status.return_value = None
with patch("requests.get", side_effect=[transient_error, success_response]) as mock_get:
with patch("time.sleep"):
# Act
result = client.get_building_insights(longitude=-0.1278, latitude=51.5074)
# Assert
assert result == payload
assert mock_get.call_count == 2
# ---------------------------------------------------------------------------
# Slice 3: 404 + entity-not-found raises BuildingInsightsNotFoundError
# ---------------------------------------------------------------------------
def test_get_building_insights_raises_on_entity_not_found() -> None:
# Arrange
client = GoogleSolarApiClient(api_key="test-key")
not_found_response = MagicMock()
not_found_response.status_code = 404
not_found_response.json.return_value = {
"error": {"message": GoogleSolarApiClient.ENTITY_NOT_FOUND_ERROR}
}
http_error = requests.exceptions.HTTPError("404")
http_error.response = not_found_response
with patch("requests.get") as mock_get:
mock_get.return_value.raise_for_status.side_effect = http_error
mock_get.return_value.response = not_found_response
# Act / Assert
with pytest.raises(BuildingInsightsNotFoundError):
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
assert mock_get.call_count == 1
# ---------------------------------------------------------------------------
# Slice 4: Retry exhaustion propagates the exception to the caller
# ---------------------------------------------------------------------------
def test_get_building_insights_propagates_exception_after_retry_exhaustion() -> None:
# Arrange
client = GoogleSolarApiClient(api_key="test-key")
error = requests.exceptions.ConnectionError("persistent failure")
error.response = None # type: ignore[attr-defined]
with patch("requests.get", side_effect=error) as mock_get:
with patch("time.sleep"):
# Act / Assert
with pytest.raises(requests.exceptions.ConnectionError):
client.get_building_insights(longitude=-0.1278, latitude=51.5074)
assert mock_get.call_count == GoogleSolarApiClient.MAX_RETRIES

View file

@ -0,0 +1,47 @@
from typing import Any
from unittest.mock import patch
from backend.apis.GoogleSolarApi import GoogleSolarApi
from infrastructure.solar.google_solar_api_client import (
BuildingInsightsNotFoundError,
GoogleSolarApiClient,
)
# ---------------------------------------------------------------------------
# Slice 1: get_building_insights delegates to _solar_client
# ---------------------------------------------------------------------------
def test_get_building_insights_delegates_to_solar_client() -> None:
# Arrange
payload: dict[str, Any] = {"solarPotential": {"maxArrayPanelsCount": 20}}
with patch.object(GoogleSolarApiClient, "get_building_insights", return_value=payload):
api = GoogleSolarApi(api_key="test-key", solar_materials=[])
# Act
result = api.get_building_insights(longitude=-0.1278, latitude=51.5074)
# Assert
assert result == payload
# ---------------------------------------------------------------------------
# Slice 2: BuildingInsightsNotFoundError translates to sentinel dict
# ---------------------------------------------------------------------------
def test_get_building_insights_returns_sentinel_on_not_found() -> None:
# Arrange
with patch.object(
GoogleSolarApiClient,
"get_building_insights",
side_effect=BuildingInsightsNotFoundError,
):
api = GoogleSolarApi(api_key="test-key", solar_materials=[])
# Act
result = api.get_building_insights(longitude=-0.1278, latitude=51.5074)
# Assert
assert result == {"error": GoogleSolarApiClient.ENTITY_NOT_FOUND_ERROR}