From 629fc34a0f6f437351ab08a855bff2f82bb0ea83 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:46:47 +0000 Subject: [PATCH 01/10] =?UTF-8?q?GoogleSolarApiClient=20fetches=20building?= =?UTF-8?q?=20insights=20from=20the=20Solar=20API=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/solar/__init__.py | 0 .../solar/google_solar_api_client.py | 22 +++++++++++++ tests/infrastructure/solar/__init__.py | 0 .../solar/test_google_solar_api_client.py | 33 +++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 infrastructure/solar/__init__.py create mode 100644 infrastructure/solar/google_solar_api_client.py create mode 100644 tests/infrastructure/solar/__init__.py create mode 100644 tests/infrastructure/solar/test_google_solar_api_client.py diff --git a/infrastructure/solar/__init__.py b/infrastructure/solar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/infrastructure/solar/google_solar_api_client.py b/infrastructure/solar/google_solar_api_client.py new file mode 100644 index 00000000..5e4698e9 --- /dev/null +++ b/infrastructure/solar/google_solar_api_client.py @@ -0,0 +1,22 @@ +from typing import Any, Literal + + +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: + raise NotImplementedError + + def get_building_insights( + self, + longitude: float, + latitude: float, + required_quality: Literal["HIGH", "MEDIUM", "LOW"] = "MEDIUM", + ) -> dict[str, Any]: + raise NotImplementedError diff --git a/tests/infrastructure/solar/__init__.py b/tests/infrastructure/solar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/infrastructure/solar/test_google_solar_api_client.py b/tests/infrastructure/solar/test_google_solar_api_client.py new file mode 100644 index 00000000..b7a7e45d --- /dev/null +++ b/tests/infrastructure/solar/test_google_solar_api_client.py @@ -0,0 +1,33 @@ +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() From b0106fa93def83492d85a93f6cc5a00e3b539fff Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:48:45 +0000 Subject: [PATCH 02/10] =?UTF-8?q?GoogleSolarApiClient=20fetches=20building?= =?UTF-8?q?=20insights=20from=20the=20Solar=20API=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/solar/google_solar_api_client.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/infrastructure/solar/google_solar_api_client.py b/infrastructure/solar/google_solar_api_client.py index 5e4698e9..4edea335 100644 --- a/infrastructure/solar/google_solar_api_client.py +++ b/infrastructure/solar/google_solar_api_client.py @@ -1,5 +1,7 @@ from typing import Any, Literal +import requests + class BuildingInsightsNotFoundError(Exception): pass @@ -11,7 +13,7 @@ class GoogleSolarApiClient: ENTITY_NOT_FOUND_ERROR: str = "Requested entity was not found." def __init__(self, api_key: str) -> None: - raise NotImplementedError + self._api_key = api_key def get_building_insights( self, @@ -19,4 +21,14 @@ class GoogleSolarApiClient: latitude: float, required_quality: Literal["HIGH", "MEDIUM", "LOW"] = "MEDIUM", ) -> dict[str, Any]: - raise NotImplementedError + 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, + } + response = requests.get(insights_url, params=params) + response.raise_for_status() + result: dict[str, Any] = response.json() + return result From 44217bf36162096629467a7e7a921e07d8c05787 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:49:57 +0000 Subject: [PATCH 03/10] =?UTF-8?q?GoogleSolarApiClient=20retries=20on=20tra?= =?UTF-8?q?nsient=20HTTP=20errors=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solar/test_google_solar_api_client.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/infrastructure/solar/test_google_solar_api_client.py b/tests/infrastructure/solar/test_google_solar_api_client.py index b7a7e45d..9a38e836 100644 --- a/tests/infrastructure/solar/test_google_solar_api_client.py +++ b/tests/infrastructure/solar/test_google_solar_api_client.py @@ -31,3 +31,30 @@ def test_get_building_insights_returns_parsed_json() -> None: # 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 From 497ef1faed822c400dd75fc3ee7f8cc23aeb087e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:51:01 +0000 Subject: [PATCH 04/10] =?UTF-8?q?GoogleSolarApiClient=20retries=20on=20tra?= =?UTF-8?q?nsient=20HTTP=20errors=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solar/google_solar_api_client.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/infrastructure/solar/google_solar_api_client.py b/infrastructure/solar/google_solar_api_client.py index 4edea335..9112b980 100644 --- a/infrastructure/solar/google_solar_api_client.py +++ b/infrastructure/solar/google_solar_api_client.py @@ -1,4 +1,5 @@ -from typing import Any, Literal +import time +from typing import Any, Literal, Optional import requests @@ -28,7 +29,16 @@ class GoogleSolarApiClient: "requiredQuality": required_quality, "key": self._api_key, } - response = requests.get(insights_url, params=params) - response.raise_for_status() - result: dict[str, Any] = response.json() - return result + 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: + last_exc = e + time.sleep(2 ** attempt) + + assert last_exc is not None + raise last_exc From 573656be64416ae2bdae384fd6d03aacce70e466 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:52:21 +0000 Subject: [PATCH 05/10] =?UTF-8?q?GoogleSolarApiClient=20raises=20BuildingI?= =?UTF-8?q?nsightsNotFoundError=20on=20404=20entity-not-found=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solar/test_google_solar_api_client.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/infrastructure/solar/test_google_solar_api_client.py b/tests/infrastructure/solar/test_google_solar_api_client.py index 9a38e836..450113a7 100644 --- a/tests/infrastructure/solar/test_google_solar_api_client.py +++ b/tests/infrastructure/solar/test_google_solar_api_client.py @@ -58,3 +58,32 @@ def test_get_building_insights_retries_on_transient_error() -> None: # 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 From d1ca9be5801b4f42297024dc62db97f3b6de3fcc Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:52:52 +0000 Subject: [PATCH 06/10] =?UTF-8?q?GoogleSolarApiClient=20raises=20BuildingI?= =?UTF-8?q?nsightsNotFoundError=20on=20404=20entity-not-found=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/solar/google_solar_api_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/infrastructure/solar/google_solar_api_client.py b/infrastructure/solar/google_solar_api_client.py index 9112b980..28e91866 100644 --- a/infrastructure/solar/google_solar_api_client.py +++ b/infrastructure/solar/google_solar_api_client.py @@ -37,6 +37,12 @@ class GoogleSolarApiClient: 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) From b1933c07c351019a45ea337ee12173041eb4f8f7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:53:39 +0000 Subject: [PATCH 07/10] =?UTF-8?q?GoogleSolarApiClient=20propagates=20excep?= =?UTF-8?q?tion=20after=20retry=20exhaustion=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solar/test_google_solar_api_client.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/infrastructure/solar/test_google_solar_api_client.py b/tests/infrastructure/solar/test_google_solar_api_client.py index 450113a7..d4328fc0 100644 --- a/tests/infrastructure/solar/test_google_solar_api_client.py +++ b/tests/infrastructure/solar/test_google_solar_api_client.py @@ -87,3 +87,24 @@ def test_get_building_insights_raises_on_entity_not_found() -> None: 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 From 521294ad917e663f3378652d55235ab3f274b202 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 15:59:22 +0000 Subject: [PATCH 08/10] =?UTF-8?q?GoogleSolarApi=20delegates=20get=5Fbuildi?= =?UTF-8?q?ng=5Finsights=20to=20GoogleSolarApiClient=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solar/test_google_solar_api_wiring.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/infrastructure/solar/test_google_solar_api_wiring.py diff --git a/tests/infrastructure/solar/test_google_solar_api_wiring.py b/tests/infrastructure/solar/test_google_solar_api_wiring.py new file mode 100644 index 00000000..52618f9d --- /dev/null +++ b/tests/infrastructure/solar/test_google_solar_api_wiring.py @@ -0,0 +1,26 @@ +from typing import Any +from unittest.mock import MagicMock, 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 From 50e9d887527ce147164d6f8007b43cf3d6a56d15 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 16:00:59 +0000 Subject: [PATCH 09/10] =?UTF-8?q?GoogleSolarApi=20delegates=20get=5Fbuildi?= =?UTF-8?q?ng=5Finsights=20to=20GoogleSolarApiClient=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apis/GoogleSolarApi.py | 65 ++++++---------------------------- 1 file changed, 11 insertions(+), 54 deletions(-) diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index 6fc5daa6..589d3a04 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -8,6 +8,10 @@ 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 +61,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 +84,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: + 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( From 0d4462d1319eb54c3fde064806d2f473bd2aeef8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 21 May 2026 16:06:54 +0000 Subject: [PATCH 10/10] =?UTF-8?q?GoogleSolarApi=20translates=20BuildingIns?= =?UTF-8?q?ightsNotFoundError=20to=20sentinel=20dict=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apis/GoogleSolarApi.py | 6 ++--- .../solar/test_google_solar_api_wiring.py | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index 589d3a04..7c90307c 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -1,8 +1,6 @@ -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 @@ -84,7 +82,7 @@ class GoogleSolarApi: self.allowed_segment_indices = None - def get_building_insights(self, longitude: float, latitude: float, required_quality: str = "MEDIUM") -> dict: + 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: diff --git a/tests/infrastructure/solar/test_google_solar_api_wiring.py b/tests/infrastructure/solar/test_google_solar_api_wiring.py index 52618f9d..77988645 100644 --- a/tests/infrastructure/solar/test_google_solar_api_wiring.py +++ b/tests/infrastructure/solar/test_google_solar_api_wiring.py @@ -1,5 +1,5 @@ from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch from backend.apis.GoogleSolarApi import GoogleSolarApi from infrastructure.solar.google_solar_api_client import ( @@ -24,3 +24,24 @@ def test_get_building_insights_delegates_to_solar_client() -> None: # 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}