mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge pull request #1119 from Hestia-Homes/claude/model-p4
Solar API Client
This commit is contained in:
commit
dc5fcc41bf
6 changed files with 219 additions and 57 deletions
|
|
@ -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(
|
||||
|
|
|
|||
0
infrastructure/solar/__init__.py
Normal file
0
infrastructure/solar/__init__.py
Normal file
50
infrastructure/solar/google_solar_api_client.py
Normal file
50
infrastructure/solar/google_solar_api_client.py
Normal 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
|
||||
0
tests/infrastructure/solar/__init__.py
Normal file
0
tests/infrastructure/solar/__init__.py
Normal file
110
tests/infrastructure/solar/test_google_solar_api_client.py
Normal file
110
tests/infrastructure/solar/test_google_solar_api_client.py
Normal 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
|
||||
47
tests/infrastructure/solar/test_google_solar_api_wiring.py
Normal file
47
tests/infrastructure/solar/test_google_solar_api_wiring.py
Normal 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}
|
||||
Loading…
Add table
Reference in a new issue