diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff0431d0..b2700d9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,8 @@ jobs: test-e2e: runs-on: ubuntu-latest + env: + BOAVIZTA_ELECTRICITY_MAPS_API_KEY: ${{ secrets.SIMON_ELECTRICITYMAPS_SANDBOX_KEY }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/boaviztapi/service/electricitymaps.py b/boaviztapi/service/electricitymaps.py new file mode 100644 index 00000000..c0060bc9 --- /dev/null +++ b/boaviztapi/service/electricitymaps.py @@ -0,0 +1,51 @@ +import time + +from electricitymaps import create_client +from openapi.exceptions import ApiException + +from boaviztapi.utils.config import config + +_cache: dict[str, tuple[float, dict]] = {} + + +def fetch_carbon_intensity(api_key: str, zone: str) -> dict: + """Fetch real-time carbon intensity from the Electricity Maps API. + + Returns a dict matching the factor format used by factors.yml:: + + { + "unit": "kg CO2eq/kWh", + "source": "Electricity Maps API (lifecycle)", + "value": , + "min": , + "max": , + } + + Raises ConnectionError on request failure. + """ + cached = _cache.get(zone) + if cached is not None: + ts, result = cached + if time.monotonic() - ts < config.electricity_maps_cache_expiry_seconds: + return result + + client = create_client(api_key=api_key) + + try: + response = client.carbon_intensity.latest(zone_key=zone) + except ApiException as exc: + raise ConnectionError(f"Electricity Maps API request failed: {exc}") from exc + + value = response.carbon_intensity / 1000 + + result = { + "unit": "kg CO2eq/kWh", + "source": "Electricity Maps API (lifecycle)", + "value": value, + "min": value, + "max": value, + } + + _cache[zone] = (time.monotonic(), result) + + return result diff --git a/boaviztapi/service/factor_provider.py b/boaviztapi/service/factor_provider.py index 7bf0b127..3e9b0216 100644 --- a/boaviztapi/service/factor_provider.py +++ b/boaviztapi/service/factor_provider.py @@ -3,6 +3,9 @@ import yaml from boaviztapi import data_dir +from boaviztapi.service.electricitymaps import fetch_carbon_intensity +from boaviztapi.utils.config import config +from boaviztapi.utils.country import iso3_to_iso2, is_iso3 config_file = os.path.join(data_dir, "factors.yml") impact_factors = yaml.load(Path(config_file).read_text(), Loader=yaml.CSafeLoader) @@ -26,6 +29,16 @@ def get_gpu_impact_factor(component, phase, impact_type) -> dict: def get_electrical_impact_factor(usage_location, impact_type) -> dict: + # Use Electricity Maps if we have an API key, are looking up GWP, and the location is a valid in ISO 3166-1 alpha-3 country. + # Note that calling code may pass non-country locations like WOR or EEE, which cannot be used with Electricity Maps. + if ( + config.electricity_maps_api_key + and impact_type == "gwp" + and is_iso3(usage_location) + ): + zone = iso3_to_iso2(usage_location) + return fetch_carbon_intensity(config.electricity_maps_api_key, zone) + if impact_factors["electricity"].get(usage_location): if impact_factors["electricity"].get(usage_location).get(impact_type): return impact_factors["electricity"].get(usage_location).get(impact_type) @@ -88,48 +101,3 @@ def get_iot_impact_factor(functional_block, hsl, impact_type): .get(hsl)["eol"][impact_type] ) raise NotImplementedError - - -""" -_electricity_emission_factors_df = pd.read_csv( - os.path.join(data_dir, 'electricity/electricity_impact_factors.csv')) -class ElecFactorProvider: - def get(self, criteria, location, date): - pass - def get_range(self, criteria, location, date1, date2): - pass -class BoaviztaFactors(ElecFactorProvider): - def get(self, criteria, usage_location, date=None): - sub = _electricity_emission_factors_df - sub = sub[sub['code'] == usage_location] - return float(sub[f"{criteria}_emission_factor"]), sub[f"{criteria}_emission_source"].iloc[0], 0, ["The impact factor is averaged over the year"] - def get_range(self, criteria, usage_location, date1=None, date2=None): - return self.get(criteria,usage_location) - -class ElectricityMap(ElecFactorProvider): - auth_token = "6QGqlsF7ZcdUN6TMB7jX9DMsYKeGHbVl" - url = "https://api-access.electricitymaps.com/2w97h07rvxvuaa1g" - now = datetime.now() - def get(self, criteria, location, date): - zone = self._location_to_em_zone(location) - if self.now - timedelta(hours=1) < date < self.now + timedelta(hours=1): - return self._get_current(zone) - elif date < self.now: - return self._get_history(zone, date) - else: - return NotImplementedError - def get_range(self, criteria, zone, date1, date2): - pass - def _get_current(self, zone): - reponse = requests.get(f"{self.url}/carbon-intensity/latest?zone={zone}", - headers={"X-BLOBR-KEY": self.auth_token}).json() - - return reponse["carbonIntensity"]/1000, f"electricity map response : {reponse}", 0, [] - def _get_history(self, zone, date): - reponse = requests.get(f"{self.url}/carbon-intensity/history?zone={zone}&datetime={date}", - headers={"X-BLOBR-KEY": self.auth_token }).json() - - return reponse - def _location_to_em_zone(self, location): - return location -""" diff --git a/boaviztapi/utils/config.py b/boaviztapi/utils/config.py index 1e6df1c2..935d3b47 100644 --- a/boaviztapi/utils/config.py +++ b/boaviztapi/utils/config.py @@ -13,6 +13,10 @@ class Settings(BaseSettings): extra="ignore", ) + # Electricity Maps API key (if set, real-time GWP factors are fetched) + electricity_maps_api_key: Optional[str] = None + electricity_maps_cache_expiry_seconds: int = 600 + # Location and usage defaults default_location: str = "EEE" default_usage: str = "DEFAULT" diff --git a/boaviztapi/utils/country.py b/boaviztapi/utils/country.py new file mode 100644 index 00000000..a222f9dc --- /dev/null +++ b/boaviztapi/utils/country.py @@ -0,0 +1,15 @@ +import pycountry + + +def is_iso3(iso3_code: str) -> bool: + """Checks whether country code is present in ISO 3166-1 alpha-3 country list.""" + return pycountry.countries.get(alpha_3=iso3_code) is not None + + +def iso3_to_iso2(iso3_code: str) -> str: + """Convert an ISO 3166-1 alpha-3 country code to alpha-2.""" + country = pycountry.countries.get(alpha_3=iso3_code) + if country is None: + raise ValueError(f"Unknown ISO3 country code: '{iso3_code}'") + + return country.alpha_2 diff --git a/docs/docs/Explanations/usage/elec_factors.md b/docs/docs/Explanations/usage/elec_factors.md index 27c9327d..08466fcd 100644 --- a/docs/docs/Explanations/usage/elec_factors.md +++ b/docs/docs/Explanations/usage/elec_factors.md @@ -13,41 +13,52 @@ Users can give their own impact factors. ## Boavizta's impact factors -Users can use the average impact factors per country available in BoaviztAPI. +Users can use the average impact factors per country available in BoaviztAPI. !!!info Impact factors will depend on the `usage_location` defined by the user in usage object. By default, the average european mix is used. -`usage_location` are given in a trigram format, according to the [list of the available countries](countries.md). +`usage_location` are given in a trigram format, according to the [list of the available countries](countries.md). !!!info Available countries can be retrieve using the API endpoint `/v1/utils/country_code`. You can find bellow the data source and methodology used for each impact criteria. +## Electricity Maps Integration + +You can use [Electricity Maps](https://app.electricitymaps.com/) to load live electricity impact factors by setting the `BOAVIZTA_ELECTRICITY_MAPS_API_KEY` environment variable to a valid API key for the Electricity Maps API. + +Response data for each zone is cached in memory for 10 minutes, but this duration can be configured via the `BOAVIZTA_ELECTRICITY_MAPS_CACHE_EXPIRY_SECONDS` environment variable. + +!!!info + Electricity Maps currently only supports GWP as an impact factor. As a result, only the `gwp` factor of the _usage_ part of the impact will be based on Electricity Maps data. + +!!!info + Electricity Maps data is only available for given zones, and it does not provide any kind of global/regional averages. Therefore, the Boavizta location codes of `WOR` and `EEE` will also default to the default hard-coded factors. + ### GWP - Global warming potential factor -_Source_ : +_Source_ : * For Europe (2019): [Quantification of the carbon intensity of electricity produced and used in Europe](https://www.sciencedirect.com/science/article/pii/S0306261921012149) -* For the rest of the world: [Ember Climate](https://ember-climate.org/data/data-explorer) - +* For the rest of the world: [Ember Climate](https://ember-climate.org/data/data-explorer) ### PE - Primary energy factor -_Source_ : +_Source_ : -PE impact factor are not available in open access. +PE impact factor are not available in open access. We use the consumption of fossil resources per kwh (APDf/kwh) per country and extrapolate this consumption to renewable energy : ```PE/kwh = ADPf/kwh / (1-%RenewableEnergyInMix)``` * `%RenewableEnergyInMix` (2016): [List of countries by renewable electricity production](https://en.wikipedia.org/wiki/List_of_countries_by_renewable_electricity_production) from IRENA -* `ADPf` (2011): [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) +* `ADPf` (2011): [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) ### Other impact factors -| Criteria | Implemented | Source | +| Criteria | Implemented | Source | |----------|-------------|----------------------------------------------------------| | adp | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) | | gwppb | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) | @@ -69,7 +80,3 @@ We use the consumption of fossil resources per kwh (APDf/kwh) per country and ex | epf | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) | | epm | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) | | ept | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) | - -## Electricity map integration - -Coming soon... diff --git a/docs/docs/config.md b/docs/docs/config.md index 0a232ee0..551bb574 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -102,3 +102,7 @@ cpu_name_fuzzymatch_threshold: 62 ``` This can be overridden with the `BOAVIZTA_CPU_NAME_FUZZYMATCH_THRESHOLD` environment variable. + +## Electricity Maps integration + +The Electricity Maps integration can be activated by setting the `electricity_maps_api_key` parameter. This can be overridden with the `BOAVIZTA_ELECTRICITY_MAPS_API_KEY` environment variable. diff --git a/poetry.lock b/poetry.lock index a8515ae7..a5ac11f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -317,7 +317,7 @@ version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, @@ -341,7 +341,7 @@ version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -509,6 +509,24 @@ files = [ {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] +[[package]] +name = "electricitymaps" +version = "0.0.1" +description = "Official Electricity Maps Python SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "electricitymaps-0.0.1-py3-none-any.whl", hash = "sha256:67664642b6059c4cbcaf22770d6d87df44679e2fffca68fc4222f2ec097cba29"}, + {file = "electricitymaps-0.0.1.tar.gz", hash = "sha256:c396871c922049c87e5b0923bdb7b7d95141a9604507b7b96ea0274891040c42"}, +] + +[package.dependencies] +pydantic = ">=2" +python-dateutil = ">=2.8.2" +typing-extensions = ">=4.7.1" +urllib3 = ">=2.1.0,<3.0.0" + [[package]] name = "fastapi" version = "0.128.0" @@ -1732,6 +1750,18 @@ files = [ {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, ] +[[package]] +name = "pycountry" +version = "24.6.1" +description = "ISO country, subdivision, language, currency and script definitions and their translations" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f"}, + {file = "pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221"}, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -2230,7 +2260,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -2739,4 +2769,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "51f263e51b4d3e8e01e776246639f6a1056f2c55c08314ffe93de11ba7bca2a5" +content-hash = "995306e0252e78d772bb87f3e75e18cddc5d7a574f22e2669874ceedf3fa6e07" diff --git a/pyproject.toml b/pyproject.toml index 10682703..1764ea0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,9 @@ mangum = "^0.20.0" importlib-metadata = "^8.7.1" pyyaml = "^6.0.3" toml = "^0.10.2" +requests = "^2.32.3" +pycountry = "^24.6.1" +electricitymaps = "^0.0.1" # Security updates aiohttp = "^3.13.3" diff --git a/tests/api/test_cloud_e2e.py b/tests/api/test_cloud_e2e.py index 9a5dec76..36fef50a 100644 --- a/tests/api/test_cloud_e2e.py +++ b/tests/api/test_cloud_e2e.py @@ -29,11 +29,14 @@ def _generate_cloud_provider_urls(): for instances_row in instances_reader: instance_id = instances_row.get("id") request = CloudInstanceRequest( - provider_name, instance_id, use_url_params=True + provider_name, + instance_id, + use_url_params=True, + usage={ + "usage_location": "FRA", + }, ) - print(f"{provider_name} - {instance_id}") - urls.append((request.to_url())) except FileNotFoundError: diff --git a/tests/api/test_electricity_maps_e2e.py b/tests/api/test_electricity_maps_e2e.py new file mode 100644 index 00000000..6fba9ac9 --- /dev/null +++ b/tests/api/test_electricity_maps_e2e.py @@ -0,0 +1,40 @@ +import pytest + +from boaviztapi import config +from boaviztapi.service.factor_provider import get_electrical_impact_factor + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif( + not config.electricity_maps_api_key, + reason="Electricity Maps API key not set", + ), +] + + +class TestGetElectricalImpactFactor: + def test_returns_gwp_factor_for_known_country(self): + result = get_electrical_impact_factor("FRA", "gwp") + + assert result["unit"] == "kg CO2eq/kWh" + assert result["source"] == "Electricity Maps API (lifecycle)" + assert isinstance(result["value"], float) + assert result["value"] > 0 + assert result["min"] == result["value"] + assert result["max"] == result["value"] + + def test_non_gwp_falls_back_to_hardcoded(self): + result = get_electrical_impact_factor("FRA", "pe") + + assert result["value"] == 11.289 + assert result["source"] == "ADPf / (1-%renewable_energy)" + + def test_world_location_falls_back_to_hardcoded(self): + result = get_electrical_impact_factor("WOR", "gwp") + + assert result["value"] == 0.39 + assert result["source"] == "Average of all country in the csv" + + def test_unknown_country_falls_back_to_not_implemented(self): + with pytest.raises(NotImplementedError): + get_electrical_impact_factor("ZZZ", "gwp") diff --git a/tests/conftest.py b/tests/conftest.py index cff8b63f..dc657dbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,9 @@ bv_config.cpu_name_fuzzymatch_threshold = 60 bv_config.default_case = "DEFAULT" bv_config.default_server = "DEFAULT" + bv_config.electricity_maps_api_key = ( + "" # Deactivate Electricity Maps integration for unit tests + ) def pytest_configure(config): diff --git a/tests/unit/test_country.py b/tests/unit/test_country.py new file mode 100644 index 00000000..5a2b01af --- /dev/null +++ b/tests/unit/test_country.py @@ -0,0 +1,33 @@ +import pytest + +from boaviztapi.utils.country import iso3_to_iso2, is_iso3 + + +class TestIsIso3: + def test_fra_is_iso3(self): + assert is_iso3("FRA") + + def test_wor_is_not_iso3(self): + assert not is_iso3("WOR") + + def test_unknown_code_is_not_iso3(self): + assert not is_iso3("XYZ") + + +class TestIso3ToIso2: + def test_fra_to_fr(self): + assert iso3_to_iso2("FRA") == "FR" + + def test_deu_to_de(self): + assert iso3_to_iso2("DEU") == "DE" + + def test_usa_to_us(self): + assert iso3_to_iso2("USA") == "US" + + def test_wor_raises_value_error(self): + with pytest.raises(ValueError, match="Unknown ISO3 country code: 'WOR'"): + iso3_to_iso2("WOR") + + def test_unknown_code_raises_value_error(self): + with pytest.raises(ValueError, match="Unknown ISO3 country code: 'XYZ'"): + iso3_to_iso2("XYZ") diff --git a/tests/unit/test_electricitymaps.py b/tests/unit/test_electricitymaps.py new file mode 100644 index 00000000..be1b6bae --- /dev/null +++ b/tests/unit/test_electricitymaps.py @@ -0,0 +1,67 @@ +from unittest.mock import MagicMock, patch + +import pytest +from openapi.exceptions import ApiException + +from boaviztapi.service.electricitymaps import fetch_carbon_intensity + + +class TestFetchCarbonIntensity: + @patch("boaviztapi.service.electricitymaps._cache", {}) + @patch("boaviztapi.service.electricitymaps.create_client") + def test_success(self, mock_create_client): + mock_response = MagicMock() + mock_response.carbon_intensity = 52.0 + mock_client = MagicMock() + mock_client.carbon_intensity.latest.return_value = mock_response + mock_create_client.return_value = mock_client + + result = fetch_carbon_intensity("test-key", "FR") + + assert result["unit"] == "kg CO2eq/kWh" + assert result["source"] == "Electricity Maps API (lifecycle)" + assert result["value"] == pytest.approx(0.052) + assert result["min"] == pytest.approx(0.052) + assert result["max"] == pytest.approx(0.052) + + mock_create_client.assert_called_once_with(api_key="test-key") + mock_client.carbon_intensity.latest.assert_called_once_with(zone_key="FR") + + @patch("boaviztapi.service.electricitymaps._cache", {}) + @patch("boaviztapi.service.electricitymaps.create_client") + def test_api_error(self, mock_create_client): + mock_client = MagicMock() + mock_client.carbon_intensity.latest.side_effect = ApiException( + status=500, reason="Server Error" + ) + mock_create_client.return_value = mock_client + + with pytest.raises(ConnectionError, match="request failed"): + fetch_carbon_intensity("test-key", "FR") + + @patch("boaviztapi.service.electricitymaps._cache", {}) + @patch("boaviztapi.service.electricitymaps.create_client") + def test_auth_error(self, mock_create_client): + mock_client = MagicMock() + mock_client.carbon_intensity.latest.side_effect = ApiException( + status=403, reason="Forbidden" + ) + mock_create_client.return_value = mock_client + + with pytest.raises(ConnectionError, match="request failed"): + fetch_carbon_intensity("test-key", "FR") + + @patch("boaviztapi.service.electricitymaps._cache", {}) + @patch("boaviztapi.service.electricitymaps.create_client") + def test_cached_result_skips_api_call(self, mock_create_client): + mock_response = MagicMock() + mock_response.carbon_intensity = 52.0 + mock_client = MagicMock() + mock_client.carbon_intensity.latest.return_value = mock_response + mock_create_client.return_value = mock_client + + result1 = fetch_carbon_intensity("test-key", "FR") + result2 = fetch_carbon_intensity("test-key", "FR") + + assert result1 == result2 + mock_client.carbon_intensity.latest.assert_called_once() diff --git a/tests/unit/test_factor_provider_electricitymaps.py b/tests/unit/test_factor_provider_electricitymaps.py new file mode 100644 index 00000000..588fc72e --- /dev/null +++ b/tests/unit/test_factor_provider_electricitymaps.py @@ -0,0 +1,72 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from boaviztapi.service.factor_provider import ( + get_electrical_impact_factor, + get_electrical_min_max, +) + + +class TestGetElectricalImpactFactorElectricitymaps: + @patch("boaviztapi.service.factor_provider.config") + @patch("boaviztapi.service.electricitymaps._cache", {}) + @patch("boaviztapi.service.electricitymaps.create_client") + def test_gwp_success(self, mock_create_client, mock_config): + mock_config.electricity_maps_api_key = "test-key" + + mock_response = MagicMock() + mock_response.carbon_intensity = 80.0 + mock_client = MagicMock() + mock_client.carbon_intensity.latest.return_value = mock_response + mock_create_client.return_value = mock_client + + result = get_electrical_impact_factor("FRA", "gwp") + + assert result["value"] == pytest.approx(0.08) + assert result["unit"] == "kg CO2eq/kWh" + assert result["source"] == "Electricity Maps API (lifecycle)" + + @patch("boaviztapi.service.factor_provider.config") + def test_non_gwp_falls_back_to_hardcoded(self, mock_config): + mock_config.electricity_maps_api_key = "test-key" + + result = get_electrical_impact_factor("FRA", "pe") + + assert result["value"] == pytest.approx(11.289) + assert result["unit"] == "MJ" + + +class TestGetElectricalImpactFactorHardcoded: + @patch("boaviztapi.service.factor_provider.config") + def test_hardcoded_returns_factors_yml_data(self, mock_config): + mock_config.electricity_maps_api_key = None + + result = get_electrical_impact_factor("FRA", "gwp") + + assert result["value"] == pytest.approx(0.098) + + @patch("boaviztapi.service.factor_provider.config") + def test_hardcoded_unknown_location_raises(self, mock_config): + mock_config.electricity_maps_api_key = None + + with pytest.raises(NotImplementedError): + get_electrical_impact_factor("NONEXISTENT", "gwp") + + +class TestGetElectricalMinMaxElectricitymaps: + @patch("boaviztapi.service.factor_provider.config") + def test_gwp_returns_hardcoded_bounds(self, mock_config): + mock_config.electricity_maps_api_key = "test-key" + + result = get_electrical_min_max("gwp", "min") + + assert isinstance(result, float) + + @patch("boaviztapi.service.factor_provider.config") + def test_non_gwp_returns_hardcoded_bounds(self, mock_config): + mock_config.electricity_maps_api_key = "test-key" + + result = get_electrical_min_max("pe", "min") + + assert result == pytest.approx(0.013)