From 70644903419cf98b51c639f4ebafdb0aa9d1cc1f Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:30:47 -0700 Subject: [PATCH 01/33] fix: remove PUE double-counting in request_usage_wcf (#230) request_energy already embeds datacenter_pue. The previous formula multiplied by datacenter_pue a second time for the electricity-mix WUE term, inflating water impact by a factor of PUE^2 on that component. WUE is also defined relative to IT energy (pre-PUE), so the datacenter cooling term now divides out PUE before applying datacenter_wue. --- ecologits/impacts/llm.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ecologits/impacts/llm.py b/ecologits/impacts/llm.py index 4d56e10c..63073f98 100644 --- a/ecologits/impacts/llm.py +++ b/ecologits/impacts/llm.py @@ -250,14 +250,21 @@ def request_usage_wcf( Compute the water usage impact of the request. Args: - request_energy: Energy consumption of the request in kWh. + request_energy: Energy consumption of the request in kWh (already includes PUE). if_electricity_mix_wue: WCF impact factor of electricity consumption in L / kWh. - datacenter_wue: Water Usage Effectiveness of the data center in L/kWh. + datacenter_wue: Water Usage Effectiveness of the data center in L/kWh (relative to IT energy). datacenter_pue: Power Usage Effectiveness of the data center. + Returns: The water usage impact of the request in liters. + + Note: + `request_energy` already embeds PUE. WUE is defined relative to IT energy (before PUE), + so we divide by PUE to recover IT energy for the datacenter cooling term. + The electricity-mix term uses `request_energy` directly (grid sees the full PUE-inflated draw). """ - return request_energy * (datacenter_wue + datacenter_pue * if_electricity_mix_wue) + it_energy = request_energy / datacenter_pue + return it_energy * datacenter_wue + request_energy * if_electricity_mix_wue @dag.asset From 233051b95fe5012844fc77a6288ba7ce4a6e2105 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:31:18 -0700 Subject: [PATCH 02/33] test: add regression tests for WCF PUE double-counting bug (#230) --- tests/test_wcf_pue.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/test_wcf_pue.py diff --git a/tests/test_wcf_pue.py b/tests/test_wcf_pue.py new file mode 100644 index 00000000..38976f2a --- /dev/null +++ b/tests/test_wcf_pue.py @@ -0,0 +1,51 @@ +"""Regression tests for WCF PUE double-counting bug (#230).""" +import pytest +from ecologits.impacts.llm import compute_llm_impacts + + +def _base_kwargs(**overrides): + defaults = dict( + model_active_parameter_count=7.0, + model_total_parameter_count=7.0, + output_token_count=100, + if_electricity_mix_adpe=1e-8, + if_electricity_mix_pe=9.0, + if_electricity_mix_gwp=0.4, + if_electricity_mix_wue=0.5, + datacenter_pue=1.2, + datacenter_wue=1.8, + request_latency=2.0, + ) + defaults.update(overrides) + return defaults + + +def test_wcf_does_not_scale_with_pue_squared(): + """WCF must not grow with PUE^2; doubling PUE should not quadruple WCF.""" + impacts_low = compute_llm_impacts(**_base_kwargs(datacenter_pue=1.0)) + impacts_high = compute_llm_impacts(**_base_kwargs(datacenter_pue=2.0)) + + wcf_low = impacts_low.wcf.value + wcf_high = impacts_high.wcf.value + + # If PUE were double-counted the ratio would be ~4x; correct formula keeps it sub-linear. + ratio = wcf_high / wcf_low if isinstance(wcf_high, (int, float)) else wcf_high.mean / wcf_low.mean + assert ratio < 4.0, f"WCF ratio {ratio:.2f} suggests PUE is being squared" + + +def test_wcf_zero_wue_equals_elec_mix_contribution_only(): + """With datacenter_wue=0, WCF should equal request_energy * if_electricity_mix_wue.""" + impacts = compute_llm_impacts(**_base_kwargs(datacenter_wue=0.0, if_electricity_mix_wue=1.0)) + wcf = impacts.wcf.value + energy = impacts.energy.value + wcf_val = wcf.mean if hasattr(wcf, "mean") else wcf + energy_val = energy.mean if hasattr(energy, "mean") else energy + assert abs(wcf_val - energy_val) < 1e-9, "WCF with zero datacenter WUE should equal energy * mix WUE" + + +def test_wcf_positive(): + """WCF should always be positive.""" + impacts = compute_llm_impacts(**_base_kwargs()) + wcf = impacts.wcf.value + val = wcf.min if hasattr(wcf, "min") else wcf + assert val > 0 From c7cfe8cb9a23eb37e3bf0e4240d0478039e0ccff Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:31:35 -0700 Subject: [PATCH 03/33] feat: add ProviderNotInstalledError, InvalidProviderError, ConfigurationError exceptions (#100) --- ecologits/exceptions.py | 58 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/ecologits/exceptions.py b/ecologits/exceptions.py index f2a44899..dcd3e1e0 100644 --- a/ecologits/exceptions.py +++ b/ecologits/exceptions.py @@ -1,12 +1,66 @@ +from __future__ import annotations + +_PROVIDER_INSTALL_HINTS: dict[str, str] = { + "openai": "pip install ecologits[openai]", + "anthropic": "pip install ecologits[anthropic]", + "mistralai": "pip install ecologits[mistralai]", + "huggingface_hub": "pip install ecologits[huggingface-hub]", + "cohere": "pip install ecologits[cohere]", + "google_genai": "pip install ecologits[google-genai]", + "litellm": "pip install ecologits[litellm]", +} + +_VALID_PROVIDERS = list(_PROVIDER_INSTALL_HINTS.keys()) + + class EcoLogitsError(Exception): pass class TracerInitializationError(EcoLogitsError): - """Tracer is initialized twice""" + """Tracer is initialized twice.""" pass class ModelingError(EcoLogitsError): - """Operation or computation not allowed""" + """Operation or computation not allowed.""" + pass + + +class ProviderNotInstalledError(EcoLogitsError): + """Required provider package is not installed.""" + + def __init__(self, provider: str) -> None: + self.provider = provider + hint = _PROVIDER_INSTALL_HINTS.get(provider, f"pip install {provider}") + super().__init__( + f"Provider '{provider}' is not installed. " + f"Install it with: `{hint}`" + ) + + +class InvalidProviderError(EcoLogitsError): + """Provider name is not recognised by EcoLogits.""" + + def __init__(self, provider: str) -> None: + self.provider = provider + valid = ", ".join(f"'{p}'" for p in _VALID_PROVIDERS) + super().__init__( + f"Unknown provider '{provider}'. " + f"Valid providers are: {valid}." + ) + + +class ConfigurationError(EcoLogitsError): + """EcoLogits configuration is invalid or missing.""" pass + + +class OpenTelemetryNotInstalledError(EcoLogitsError): + """OpenTelemetry packages are not installed.""" + + def __init__(self) -> None: + super().__init__( + "OpenTelemetry package is not installed. " + "Install it with: `pip install ecologits[opentelemetry]`" + ) From 59f059aaf374bc8734835176fa1dac6ad7a8d128 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:32:19 -0700 Subject: [PATCH 04/33] refactor: use InvalidProviderError and OpenTelemetryNotInstalledError in init (#100) --- ecologits/_ecologits.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ecologits/_ecologits.py b/ecologits/_ecologits.py index df877e14..6594d35d 100644 --- a/ecologits/_ecologits.py +++ b/ecologits/_ecologits.py @@ -8,7 +8,14 @@ from packaging.version import Version -from ecologits.exceptions import EcoLogitsError +from ecologits.exceptions import ( + ConfigurationError, + EcoLogitsError, + InvalidProviderError, + OpenTelemetryNotInstalledError, + ProviderNotInstalledError, + _PROVIDER_INSTALL_HINTS, +) from ecologits.log import logger if TYPE_CHECKING: @@ -153,9 +160,7 @@ def init( if opentelemetry_endpoint is not None: if not is_opentelemetry_installed(): - logger.error("OpenTelemetry package is not installed. Install with " - "`pip install ecologits[opentelemetry]`.") - raise EcoLogitsError("OpenTelemetry package is not installed.") + raise OpenTelemetryNotInstalledError() from ecologits.utils.opentelemetry import OpenTelemetry @@ -210,7 +215,7 @@ async def text_summarization(text: str) -> str: def init_instruments(providers: list[str]) -> None: for provider in providers: if provider not in _INSTRUMENTS: - raise EcoLogitsError(f"Could not find tracer for the `{provider}` provider.") + raise InvalidProviderError(provider) if provider not in EcoLogits.config.providers: init_func = _INSTRUMENTS[provider] init_func() From 5e104116d22e8e9f66cad62cd23d35f6179079a8 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:32:49 -0700 Subject: [PATCH 05/33] refactor: raise ConfigurationError with actionable message in EcoLogits.label (#100) --- ecologits/_ecologits.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ecologits/_ecologits.py b/ecologits/_ecologits.py index 6594d35d..40b3b39e 100644 --- a/ecologits/_ecologits.py +++ b/ecologits/_ecologits.py @@ -203,9 +203,10 @@ async def text_summarization(text: str) -> str: ``` """ if EcoLogits.config.opentelemetry is None: - logger.error("You must enable OpenTelemetry to use labels. Initialize with " - "opentelemetry_endpoint='http://localhost:4318/v1/metrics' for instance.") - raise EcoLogitsError("OpenTelemetry is not enabled.") + raise ConfigurationError( + "OpenTelemetry is not enabled. Initialize EcoLogits with " + "opentelemetry_endpoint='http://localhost:4318/v1/metrics' to use labels." + ) from ecologits.utils.opentelemetry import OpenTelemetryLabels From 1a1c34d0345fd818c1a25a8914bac9230c4c61bc Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:33:21 -0700 Subject: [PATCH 06/33] feat: add load_config_from_json and load_config_from_env helpers (#98) --- ecologits/config.py | 75 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 ecologits/config.py diff --git a/ecologits/config.py b/ecologits/config.py new file mode 100644 index 00000000..6694d3f3 --- /dev/null +++ b/ecologits/config.py @@ -0,0 +1,75 @@ +"""Configuration loading utilities for EcoLogits (#98).""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + + +def load_config_from_json(path: str | Path) -> dict[str, Any]: + """ + Load EcoLogits configuration from a JSON file. + + Args: + path: Path to the JSON config file. + + Returns: + Dictionary of configuration values suitable for passing to ``EcoLogits.init``. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file is not valid JSON. + + Example:: + + config = load_config_from_json("ecologits.json") + EcoLogits.init(**config) + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + try: + with path.open() as f: + return json.load(f) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in config file {path}: {exc}") from exc + + +def load_config_from_env(prefix: str = "ECOLOGITS_") -> dict[str, Any]: + """ + Load EcoLogits configuration from environment variables. + + Recognised variables (all prefixed with *prefix*): + + - ``ECOLOGITS_PROVIDERS`` – comma-separated list of providers, e.g. ``openai,anthropic`` + - ``ECOLOGITS_ELECTRICITY_MIX_ZONE`` – ISO 3166-1 alpha-3 zone code, e.g. ``FRA`` + - ``ECOLOGITS_OPENTELEMETRY_ENDPOINT`` – OpenTelemetry collector URL + + Args: + prefix: Environment variable prefix (default ``"ECOLOGITS_"``). + + Returns: + Dictionary of configuration values with only the keys that were set. + + Example:: + + # With ECOLOGITS_PROVIDERS=openai,anthropic in the environment: + config = load_config_from_env() + EcoLogits.init(**config) + """ + config: dict[str, Any] = {} + + raw_providers = os.environ.get(f"{prefix}PROVIDERS") + if raw_providers: + config["providers"] = [p.strip() for p in raw_providers.split(",") if p.strip()] + + zone = os.environ.get(f"{prefix}ELECTRICITY_MIX_ZONE") + if zone: + config["electricity_mix_zone"] = zone + + otel = os.environ.get(f"{prefix}OPENTELEMETRY_ENDPOINT") + if otel: + config["opentelemetry_endpoint"] = otel + + return config From 84cb0f8f10583537f4b26010fc6b2dc90d189585 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:34:07 -0700 Subject: [PATCH 07/33] feat: add EcoLogits.init_from_config and init_from_env class methods (#98) --- ecologits/_ecologits.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/ecologits/_ecologits.py b/ecologits/_ecologits.py index 40b3b39e..5b8ddab6 100644 --- a/ecologits/_ecologits.py +++ b/ecologits/_ecologits.py @@ -166,6 +166,49 @@ def init( EcoLogits.config.opentelemetry = OpenTelemetry(endpoint=opentelemetry_endpoint) + @staticmethod + def init_from_config(path: "str | Path") -> None: + """ + Initialize EcoLogits from a JSON configuration file. + + Args: + path: Path to a JSON file containing ``providers``, + ``electricity_mix_zone``, and/or ``opentelemetry_endpoint`` keys. + + Example:: + + EcoLogits.init_from_config("ecologits.json") + """ + from pathlib import Path as Path_ + from ecologits.config import load_config_from_json + config = load_config_from_json(Path_(path)) + EcoLogits.init(**config) + + @staticmethod + def init_from_env(prefix: str = "ECOLOGITS_") -> None: + """ + Initialize EcoLogits from environment variables. + + Reads ``ECOLOGITS_PROVIDERS``, ``ECOLOGITS_ELECTRICITY_MIX_ZONE``, + and ``ECOLOGITS_OPENTELEMETRY_ENDPOINT`` (all overridable via *prefix*). + + Args: + prefix: Environment variable prefix (default ``"ECOLOGITS_"``). + + Example:: + + # export ECOLOGITS_PROVIDERS=openai,anthropic + EcoLogits.init_from_env() + """ + from ecologits.config import load_config_from_env + config = load_config_from_env(prefix=prefix) + if not config: + raise ConfigurationError( + "No EcoLogits configuration found in environment variables. " + f"Set at least {prefix}PROVIDERS to one or more provider names." + ) + EcoLogits.init(**config) + @staticmethod def label(**labels: str) -> OpenTelemetryLabels: """ From 40fc66b82d93bd4f2e8c1de720292d0c1d1d8f6b Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:34:27 -0700 Subject: [PATCH 08/33] test: add tests for load_config_from_json and load_config_from_env (#98) --- tests/test_config.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..8dc6f159 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,72 @@ +"""Tests for config loading helpers (#98).""" +import json +import os +import pytest +from pathlib import Path + +from ecologits.config import load_config_from_json, load_config_from_env +from ecologits.exceptions import ConfigurationError + + +class TestLoadConfigFromJson: + def test_loads_providers(self, tmp_path): + cfg = {"providers": ["openai", "anthropic"]} + p = tmp_path / "ecologits.json" + p.write_text(json.dumps(cfg)) + result = load_config_from_json(p) + assert result["providers"] == ["openai", "anthropic"] + + def test_loads_electricity_mix_zone(self, tmp_path): + cfg = {"providers": ["openai"], "electricity_mix_zone": "FRA"} + p = tmp_path / "ecologits.json" + p.write_text(json.dumps(cfg)) + result = load_config_from_json(p) + assert result["electricity_mix_zone"] == "FRA" + + def test_raises_file_not_found(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_config_from_json(tmp_path / "missing.json") + + def test_raises_value_error_on_invalid_json(self, tmp_path): + p = tmp_path / "bad.json" + p.write_text("not json {{{") + with pytest.raises(ValueError, match="Invalid JSON"): + load_config_from_json(p) + + def test_accepts_string_path(self, tmp_path): + cfg = {"providers": ["openai"]} + p = tmp_path / "ecologits.json" + p.write_text(json.dumps(cfg)) + result = load_config_from_json(str(p)) + assert result["providers"] == ["openai"] + + +class TestLoadConfigFromEnv: + def test_returns_empty_dict_when_no_env_vars(self, monkeypatch): + monkeypatch.delenv("ECOLOGITS_PROVIDERS", raising=False) + monkeypatch.delenv("ECOLOGITS_ELECTRICITY_MIX_ZONE", raising=False) + monkeypatch.delenv("ECOLOGITS_OPENTELEMETRY_ENDPOINT", raising=False) + result = load_config_from_env() + assert result == {} + + def test_parses_comma_separated_providers(self, monkeypatch): + monkeypatch.setenv("ECOLOGITS_PROVIDERS", "openai, anthropic , mistralai") + result = load_config_from_env() + assert result["providers"] == ["openai", "anthropic", "mistralai"] + + def test_parses_electricity_mix_zone(self, monkeypatch): + monkeypatch.setenv("ECOLOGITS_PROVIDERS", "openai") + monkeypatch.setenv("ECOLOGITS_ELECTRICITY_MIX_ZONE", "DEU") + result = load_config_from_env() + assert result["electricity_mix_zone"] == "DEU" + + def test_parses_opentelemetry_endpoint(self, monkeypatch): + monkeypatch.setenv("ECOLOGITS_PROVIDERS", "openai") + monkeypatch.setenv("ECOLOGITS_OPENTELEMETRY_ENDPOINT", "http://localhost:4318/v1/metrics") + result = load_config_from_env() + assert result["opentelemetry_endpoint"] == "http://localhost:4318/v1/metrics" + + def test_custom_prefix(self, monkeypatch): + monkeypatch.setenv("MY_PROVIDERS", "cohere") + result = load_config_from_env(prefix="MY_") + assert result["providers"] == ["cohere"] From 1d3e65445ab479f9082a816513283a9857f4e152 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:37:21 -0700 Subject: [PATCH 09/33] test: add comprehensive exception tests for all new error classes (#100) --- tests/test_exceptions.py | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/test_exceptions.py diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000..c1017681 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,82 @@ +"""Tests for exception classes and error messages (#100).""" +import pytest + +from ecologits.exceptions import ( + ConfigurationError, + EcoLogitsError, + InvalidProviderError, + ModelingError, + OpenTelemetryNotInstalledError, + ProviderNotInstalledError, + TracerInitializationError, +) + + +class TestExceptionHierarchy: + def test_tracer_initialization_error_is_ecologits_error(self): + assert issubclass(TracerInitializationError, EcoLogitsError) + + def test_modeling_error_is_ecologits_error(self): + assert issubclass(ModelingError, EcoLogitsError) + + def test_provider_not_installed_is_ecologits_error(self): + assert issubclass(ProviderNotInstalledError, EcoLogitsError) + + def test_invalid_provider_is_ecologits_error(self): + assert issubclass(InvalidProviderError, EcoLogitsError) + + def test_configuration_error_is_ecologits_error(self): + assert issubclass(ConfigurationError, EcoLogitsError) + + def test_otel_not_installed_is_ecologits_error(self): + assert issubclass(OpenTelemetryNotInstalledError, EcoLogitsError) + + +class TestProviderNotInstalledError: + def test_message_contains_provider_name(self): + err = ProviderNotInstalledError("openai") + assert "openai" in str(err) + + def test_message_contains_install_hint(self): + err = ProviderNotInstalledError("openai") + assert "pip install" in str(err) + + def test_message_contains_extras_bracket(self): + err = ProviderNotInstalledError("anthropic") + assert "[anthropic]" in str(err) + + def test_unknown_provider_still_shows_hint(self): + err = ProviderNotInstalledError("custom_provider") + assert "custom_provider" in str(err) + + def test_provider_attribute(self): + err = ProviderNotInstalledError("mistralai") + assert err.provider == "mistralai" + + +class TestInvalidProviderError: + def test_message_contains_bad_provider(self): + err = InvalidProviderError("fakeai") + assert "fakeai" in str(err) + + def test_message_lists_valid_providers(self): + err = InvalidProviderError("fakeai") + assert "openai" in str(err) + + def test_provider_attribute(self): + err = InvalidProviderError("bad") + assert err.provider == "bad" + + +class TestOpenTelemetryNotInstalledError: + def test_message_contains_install_hint(self): + err = OpenTelemetryNotInstalledError() + assert "pip install" in str(err) + assert "opentelemetry" in str(err) + + +class TestInvalidProviderViaInit: + def test_init_raises_invalid_provider_error(self): + from ecologits import EcoLogits + with pytest.raises(InvalidProviderError): + EcoLogits.init(providers=["definitely_not_a_provider"]) From 56c14e0fd5677a828dba0dac566475e0b547de6e Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:37:57 -0700 Subject: [PATCH 10/33] feat: add __repr__, __sub__, __neg__, __abs__, to_dict to RangeValue (#118) --- ecologits/utils/range_value.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/ecologits/utils/range_value.py b/ecologits/utils/range_value.py index 86701411..c3883b43 100644 --- a/ecologits/utils/range_value.py +++ b/ecologits/utils/range_value.py @@ -89,7 +89,28 @@ def __gt__(self, other: Any) -> bool: else: return self.min > other - def __format__(self, format_spec:str)-> str: - return f"{format(self.mean,format_spec)} [{format(self.min,format_spec)} - {format(self.max,format_spec)}]" + def __format__(self, format_spec: str) -> str: + return f"{format(self.mean, format_spec)} [{format(self.min, format_spec)} - {format(self.max, format_spec)}]" + + def __repr__(self) -> str: + return f"RangeValue(min={self.min}, max={self.max}, mean={self.mean})" + + def __sub__(self, other: "Union[RangeValue, int, float]") -> "RangeValue": + if isinstance(other, RangeValue): + return RangeValue(min=self.min - other.max, max=self.max - other.min) + return RangeValue(min=self.min - other, max=self.max - other) + + def __neg__(self) -> "RangeValue": + return RangeValue(min=-self.max, max=-self.min) + + def __abs__(self) -> "RangeValue": + return RangeValue(min=min(abs(self.min), abs(self.max)), max=max(abs(self.min), abs(self.max))) + + def to_dict(self) -> dict: + """Return a plain dictionary representation.""" + return {"min": self.min, "max": self.max, "mean": self.mean} + + __rsub__ = __sub__ + ValueOrRange = Union[int, float, RangeValue] From d7da97321ca4835657aee8e8170b238a72597677 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:38:24 -0700 Subject: [PATCH 11/33] feat: add to_dict and __repr__ to BaseImpact (#118) --- ecologits/impacts/modeling.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/ecologits/impacts/modeling.py b/ecologits/impacts/modeling.py index af91c42f..a35e9037 100644 --- a/ecologits/impacts/modeling.py +++ b/ecologits/impacts/modeling.py @@ -1,10 +1,13 @@ +from __future__ import annotations + +import json from functools import total_ordering -from typing import TypeVar +from typing import Any, TypeVar from pydantic import BaseModel from ecologits.exceptions import ModelingError -from ecologits.utils.range_value import ValueOrRange +from ecologits.utils.range_value import RangeValue, ValueOrRange Impact = TypeVar("Impact", bound="BaseImpact") @@ -58,6 +61,22 @@ def __ge__(self, other: object) -> bool: raise ModelingError(f"Error occurred, cannot compare a {self.type} Impact with {other.type} Impact.") return self.value >= other.value + def _value_as_serializable(self) -> Any: + v = self.value + return v.to_dict() if isinstance(v, RangeValue) else v + + def to_dict(self) -> dict[str, Any]: + """Return a plain-dict representation of this impact.""" + return { + "type": self.type, + "name": self.name, + "value": self._value_as_serializable(), + "unit": self.unit, + } + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(value={self.value!r}, unit={self.unit!r})" + class Energy(BaseImpact): """ From 6611aa1afcb85afdd54e58133744116b73a18ec9 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:39:06 -0700 Subject: [PATCH 12/33] feat: add to_dict, to_json, summary, __repr__ to Impacts model (#118) --- ecologits/impacts/modeling.py | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/ecologits/impacts/modeling.py b/ecologits/impacts/modeling.py index a35e9037..44b0dd86 100644 --- a/ecologits/impacts/modeling.py +++ b/ecologits/impacts/modeling.py @@ -245,3 +245,58 @@ class Impacts(BaseModel): wcf: WCF usage: Usage embodied: Embodied + + def to_dict(self) -> dict[str, Any]: + """Return a plain-dict representation of all impacts.""" + return { + "energy": self.energy.to_dict(), + "gwp": self.gwp.to_dict(), + "adpe": self.adpe.to_dict(), + "pe": self.pe.to_dict(), + "wcf": self.wcf.to_dict(), + "usage": { + "energy": self.usage.energy.to_dict(), + "gwp": self.usage.gwp.to_dict(), + "adpe": self.usage.adpe.to_dict(), + "pe": self.usage.pe.to_dict(), + "wcf": self.usage.wcf.to_dict(), + }, + "embodied": { + "gwp": self.embodied.gwp.to_dict(), + "adpe": self.embodied.adpe.to_dict(), + "pe": self.embodied.pe.to_dict(), + }, + } + + def to_json(self, indent: int = 2) -> str: + """Return a JSON string representation of all impacts.""" + return json.dumps(self.to_dict(), indent=indent) + + def summary(self) -> str: + """Return a concise human-readable summary of the top-level impacts.""" + + def _fmt(v: ValueOrRange, unit: str) -> str: + if isinstance(v, RangeValue): + return f"{v.mean:.4g} {unit} (range: {v.min:.4g}–{v.max:.4g})" + return f"{v:.4g} {unit}" + + lines = [ + "EcoLogits Impact Summary", + "------------------------", + f"Energy : {_fmt(self.energy.value, self.energy.unit)}", + f"GWP : {_fmt(self.gwp.value, self.gwp.unit)}", + f"ADPe : {_fmt(self.adpe.value, self.adpe.unit)}", + f"PE : {_fmt(self.pe.value, self.pe.unit)}", + f"WCF : {_fmt(self.wcf.value, self.wcf.unit)}", + ] + return "\n".join(lines) + + def __repr__(self) -> str: + e = self.energy.value + g = self.gwp.value + e_val = e.mean if isinstance(e, RangeValue) else e + g_val = g.mean if isinstance(g, RangeValue) else g + return ( + f"Impacts(energy={e_val:.4g} {self.energy.unit}, " + f"gwp={g_val:.4g} {self.gwp.unit})" + ) From c1342742c1b4fd2fa130a379a7d0f095eac87022 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:39:29 -0700 Subject: [PATCH 13/33] test: add comprehensive tests for Impacts.to_dict, to_json, summary, __repr__ (#118) --- tests/test_output_options.py | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_output_options.py diff --git a/tests/test_output_options.py b/tests/test_output_options.py new file mode 100644 index 00000000..ba9cefac --- /dev/null +++ b/tests/test_output_options.py @@ -0,0 +1,109 @@ +"""Tests for Impacts output options: to_dict, to_json, summary (#118).""" +import json +import pytest + +from ecologits.impacts.llm import compute_llm_impacts +from ecologits.utils.range_value import RangeValue + + +def _make_impacts(**overrides): + defaults = dict( + model_active_parameter_count=7.0, + model_total_parameter_count=7.0, + output_token_count=100, + if_electricity_mix_adpe=1e-8, + if_electricity_mix_pe=9.0, + if_electricity_mix_gwp=0.4, + if_electricity_mix_wue=0.5, + datacenter_pue=1.2, + datacenter_wue=1.8, + request_latency=2.0, + ) + defaults.update(overrides) + return compute_llm_impacts(**defaults) + + +class TestToDict: + def test_returns_dict(self): + impacts = _make_impacts() + d = impacts.to_dict() + assert isinstance(d, dict) + + def test_has_top_level_keys(self): + d = _make_impacts().to_dict() + assert set(d.keys()) == {"energy", "gwp", "adpe", "pe", "wcf", "usage", "embodied"} + + def test_energy_has_value_and_unit(self): + d = _make_impacts().to_dict() + assert "value" in d["energy"] + assert d["energy"]["unit"] == "kWh" + + def test_usage_has_all_impact_types(self): + d = _make_impacts().to_dict() + assert set(d["usage"].keys()) == {"energy", "gwp", "adpe", "pe", "wcf"} + + def test_embodied_has_gwp_adpe_pe(self): + d = _make_impacts().to_dict() + assert set(d["embodied"].keys()) == {"gwp", "adpe", "pe"} + + def test_range_value_serialized_as_dict(self): + impacts = _make_impacts( + model_active_parameter_count=RangeValue(min=3.0, max=7.0), + model_total_parameter_count=RangeValue(min=3.0, max=7.0), + ) + d = impacts.to_dict() + energy_val = d["energy"]["value"] + assert isinstance(energy_val, dict) + assert "min" in energy_val and "max" in energy_val and "mean" in energy_val + + +class TestToJson: + def test_returns_string(self): + assert isinstance(_make_impacts().to_json(), str) + + def test_is_valid_json(self): + result = json.loads(_make_impacts().to_json()) + assert "energy" in result + + def test_indent_parameter(self): + compact = _make_impacts().to_json(indent=None) + indented = _make_impacts().to_json(indent=4) + assert len(indented) > len(compact) + + def test_round_trips_through_json(self): + impacts = _make_impacts() + d_original = impacts.to_dict() + d_round = json.loads(impacts.to_json()) + assert d_original == d_round + + +class TestSummary: + def test_returns_string(self): + assert isinstance(_make_impacts().summary(), str) + + def test_contains_energy_label(self): + assert "Energy" in _make_impacts().summary() + + def test_contains_gwp_label(self): + assert "GWP" in _make_impacts().summary() + + def test_contains_wcf_label(self): + assert "WCF" in _make_impacts().summary() + + def test_contains_unit(self): + assert "kWh" in _make_impacts().summary() + + def test_range_shows_range(self): + impacts = _make_impacts( + model_active_parameter_count=RangeValue(min=3.0, max=7.0), + model_total_parameter_count=RangeValue(min=3.0, max=7.0), + ) + assert "range:" in impacts.summary() + + +class TestRepr: + def test_impacts_repr_contains_energy(self): + assert "energy" in repr(_make_impacts()).lower() + + def test_impacts_repr_contains_gwp(self): + assert "gwp" in repr(_make_impacts()).lower() From eb04faf2c8b9cef1e4b1fa1a15ac5c9483edda55 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:40:10 -0700 Subject: [PATCH 14/33] test: add tests for RangeValue.__sub__, __neg__, __abs__, to_dict, __repr__ (#118) --- tests/test_range_value.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_range_value.py b/tests/test_range_value.py index 586707bf..43ceb2a1 100644 --- a/tests/test_range_value.py +++ b/tests/test_range_value.py @@ -66,3 +66,37 @@ def test_value_range_compare(val_1, val_2, op, result): def test_value_range_transformation(val_1, val_2, op, exp_result): result = op(val_1, val_2) assert result.min == exp_result.min and result.max == exp_result.max + + +class TestRangeValueNewMethods: + def test_sub_range_range(self): + r = RangeValue(min=3, max=5) - RangeValue(min=1, max=2) + assert r.min == 1 and r.max == 4 + + def test_sub_range_scalar(self): + r = RangeValue(min=3, max=5) - 1 + assert r.min == 2 and r.max == 4 + + def test_neg(self): + r = -RangeValue(min=1, max=3) + assert r.min == -3 and r.max == -1 + + def test_abs_positive(self): + r = abs(RangeValue(min=2, max=5)) + assert r.min == 2 and r.max == 5 + + def test_abs_negative(self): + r = abs(RangeValue(min=-5, max=-2)) + assert r.min == 2 and r.max == 5 + + def test_to_dict_keys(self): + d = RangeValue(min=1.0, max=3.0).to_dict() + assert set(d.keys()) == {"min", "max", "mean"} + + def test_to_dict_mean(self): + d = RangeValue(min=1.0, max=3.0).to_dict() + assert d["mean"] == 2.0 + + def test_repr_contains_min_max_mean(self): + r = repr(RangeValue(min=1, max=3)) + assert "min=1" in r and "max=3" in r and "mean=2.0" in r From 6dd6f96006cf57f215cfee9db8aa33d2c7e93295 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 11:40:43 -0700 Subject: [PATCH 15/33] feat: add ElectricityMixNotAvailableWarning, ModelArchDeprecatedWarning, ProviderDataUnavailableWarning, ImpactEstimateUncertainWarning --- ecologits/status_messages.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/ecologits/status_messages.py b/ecologits/status_messages.py index 824df53a..9ff47bc2 100644 --- a/ecologits/status_messages.py +++ b/ecologits/status_messages.py @@ -76,9 +76,33 @@ class ZoneNotRegisteredError(ErrorMessage): message: str = "The zone is not registered." +class ElectricityMixNotAvailableWarning(WarningMessage): + code: str = "electricity-mix-not-available" + message: str = "Electricity mix data is not available for this zone; using world average." + + +class ModelArchDeprecatedWarning(WarningMessage): + code: str = "model-arch-deprecated" + message: str = "The model architecture is deprecated; impact estimates may be less accurate." + + +class ProviderDataUnavailableWarning(WarningMessage): + code: str = "provider-data-unavailable" + message: str = "Provider-specific hardware data is unavailable; using generic defaults." + + +class ImpactEstimateUncertainWarning(WarningMessage): + code: str = "impact-estimate-uncertain" + message: str = "Impact estimate has high uncertainty due to missing model parameters." + + _warning_codes: dict[str, type[WarningMessage]] = { "model-arch-not-released": ModelArchNotReleasedWarning, - "model-arch-multimodal": ModelArchMultimodalWarning + "model-arch-multimodal": ModelArchMultimodalWarning, + "electricity-mix-not-available": ElectricityMixNotAvailableWarning, + "model-arch-deprecated": ModelArchDeprecatedWarning, + "provider-data-unavailable": ProviderDataUnavailableWarning, + "impact-estimate-uncertain": ImpactEstimateUncertainWarning, } _error_codes: dict[str, type[ErrorMessage]] = { From ef846ea3e92a64c1aed4436f5dba4ca1e9555002 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 12:04:27 -0700 Subject: [PATCH 16/33] test: add tests for new warning codes and WarningMessage.from_code --- tests/test_status_messages.py | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_status_messages.py b/tests/test_status_messages.py index 79bffc5b..ac70be2c 100644 --- a/tests/test_status_messages.py +++ b/tests/test_status_messages.py @@ -1,7 +1,15 @@ +import pytest + from ecologits.status_messages import ( + ElectricityMixNotAvailableWarning, + ImpactEstimateUncertainWarning, + ModelArchDeprecatedWarning, ModelArchMultimodalWarning, ModelArchNotReleasedWarning, ModelNotRegisteredError, + ProviderDataUnavailableWarning, + WarningMessage, + ErrorMessage, ZoneNotRegisteredError, ) from ecologits.tracers.utils import llm_impacts @@ -43,3 +51,39 @@ def test_zone_error(): assert impacts.energy is None assert impacts.has_errors assert isinstance(impacts.errors[0], ZoneNotRegisteredError) + + +class TestNewWarningCodes: + def test_electricity_mix_not_available_from_code(self): + w = WarningMessage.from_code("electricity-mix-not-available") + assert isinstance(w, ElectricityMixNotAvailableWarning) + assert "electricity" in str(w).lower() + + def test_model_arch_deprecated_from_code(self): + w = WarningMessage.from_code("model-arch-deprecated") + assert isinstance(w, ModelArchDeprecatedWarning) + + def test_provider_data_unavailable_from_code(self): + w = WarningMessage.from_code("provider-data-unavailable") + assert isinstance(w, ProviderDataUnavailableWarning) + + def test_impact_estimate_uncertain_from_code(self): + w = WarningMessage.from_code("impact-estimate-uncertain") + assert isinstance(w, ImpactEstimateUncertainWarning) + + def test_unknown_warning_code_raises(self): + with pytest.raises(ValueError, match="does not exist"): + WarningMessage.from_code("totally-unknown-code") + + def test_warning_str_contains_docs_url(self): + w = ElectricityMixNotAvailableWarning() + assert "ecologits.ai" in str(w) + + def test_all_new_warnings_are_warning_message(self): + for cls in [ + ElectricityMixNotAvailableWarning, + ModelArchDeprecatedWarning, + ProviderDataUnavailableWarning, + ImpactEstimateUncertainWarning, + ]: + assert issubclass(cls, WarningMessage) From 22c2b82bd30166fe2f127b99c9051483369bb232 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 12:05:20 -0700 Subject: [PATCH 17/33] feat: export config helpers and new exception classes from top-level package --- ecologits/__init__.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ecologits/__init__.py b/ecologits/__init__.py index 1c3938a5..910ac451 100644 --- a/ecologits/__init__.py +++ b/ecologits/__init__.py @@ -1,7 +1,26 @@ from ._ecologits import EcoLogits +from .config import load_config_from_env, load_config_from_json +from .exceptions import ( + ConfigurationError, + EcoLogitsError, + InvalidProviderError, + ModelingError, + OpenTelemetryNotInstalledError, + ProviderNotInstalledError, + TracerInitializationError, +) __version__ = "0.10.1" __all__ = [ "EcoLogits", - "__version__" + "load_config_from_json", + "load_config_from_env", + "EcoLogitsError", + "TracerInitializationError", + "ModelingError", + "ProviderNotInstalledError", + "InvalidProviderError", + "ConfigurationError", + "OpenTelemetryNotInstalledError", + "__version__", ] From f800f2f56516777893881b806090155d7c3ce4aa Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 12:05:37 -0700 Subject: [PATCH 18/33] feat: add impacts_to_csv export utility (#118) --- ecologits/utils/export.py | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 ecologits/utils/export.py diff --git a/ecologits/utils/export.py b/ecologits/utils/export.py new file mode 100644 index 00000000..f19d29ad --- /dev/null +++ b/ecologits/utils/export.py @@ -0,0 +1,57 @@ +"""Export utilities for EcoLogits impact data (#118).""" +from __future__ import annotations + +import csv +import io +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ecologits.impacts.modeling import Impacts + from ecologits.utils.range_value import RangeValue + + +def _scalar(value: "float | int | RangeValue") -> float: + """Return the mean if value is a RangeValue, otherwise return the value itself.""" + return value.mean if hasattr(value, "mean") else float(value) + + +def impacts_to_csv(impacts: "Impacts", include_phases: bool = True) -> str: + """ + Serialize an :class:`~ecologits.impacts.modeling.Impacts` object to CSV. + + Args: + impacts: The impacts object to serialize. + include_phases: When ``True`` (default), include usage and embodied phase rows. + + Returns: + A CSV string with columns ``phase``, ``metric``, ``value``, ``unit``. + + Example:: + + from ecologits.utils.export import impacts_to_csv + csv_str = impacts_to_csv(response.impacts) + with open("impacts.csv", "w") as f: + f.write(csv_str) + """ + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["phase", "metric", "value", "unit"]) + + writer.writerow(["total", "energy", _scalar(impacts.energy.value), impacts.energy.unit]) + writer.writerow(["total", "gwp", _scalar(impacts.gwp.value), impacts.gwp.unit]) + writer.writerow(["total", "adpe", _scalar(impacts.adpe.value), impacts.adpe.unit]) + writer.writerow(["total", "pe", _scalar(impacts.pe.value), impacts.pe.unit]) + writer.writerow(["total", "wcf", _scalar(impacts.wcf.value), impacts.wcf.unit]) + + if include_phases: + writer.writerow(["usage", "energy", _scalar(impacts.usage.energy.value), impacts.usage.energy.unit]) + writer.writerow(["usage", "gwp", _scalar(impacts.usage.gwp.value), impacts.usage.gwp.unit]) + writer.writerow(["usage", "adpe", _scalar(impacts.usage.adpe.value), impacts.usage.adpe.unit]) + writer.writerow(["usage", "pe", _scalar(impacts.usage.pe.value), impacts.usage.pe.unit]) + writer.writerow(["usage", "wcf", _scalar(impacts.usage.wcf.value), impacts.usage.wcf.unit]) + + writer.writerow(["embodied", "gwp", _scalar(impacts.embodied.gwp.value), impacts.embodied.gwp.unit]) + writer.writerow(["embodied", "adpe", _scalar(impacts.embodied.adpe.value), impacts.embodied.adpe.unit]) + writer.writerow(["embodied", "pe", _scalar(impacts.embodied.pe.value), impacts.embodied.pe.unit]) + + return buf.getvalue() From ecc6129d79aa580b3fd013f7017df89603476c6b Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 12:06:51 -0700 Subject: [PATCH 19/33] test: add tests for impacts_to_csv export (#118) --- tests/test_export.py | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_export.py diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 00000000..0c67b950 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,75 @@ +"""Tests for CSV export utility (#118).""" +import csv +import io +import pytest + +from ecologits.impacts.llm import compute_llm_impacts +from ecologits.utils.export import impacts_to_csv + + +def _make_impacts(): + return compute_llm_impacts( + model_active_parameter_count=7.0, + model_total_parameter_count=7.0, + output_token_count=100, + if_electricity_mix_adpe=1e-8, + if_electricity_mix_pe=9.0, + if_electricity_mix_gwp=0.4, + if_electricity_mix_wue=0.5, + datacenter_pue=1.2, + datacenter_wue=1.8, + request_latency=2.0, + ) + + +def _parse_csv(csv_str: str) -> list[dict]: + return list(csv.DictReader(io.StringIO(csv_str))) + + +class TestImpactsToCsv: + def test_returns_string(self): + assert isinstance(impacts_to_csv(_make_impacts()), str) + + def test_has_header(self): + rows = _parse_csv(impacts_to_csv(_make_impacts())) + assert "phase" in rows[0] + assert "metric" in rows[0] + assert "value" in rows[0] + assert "unit" in rows[0] + + def test_total_rows_present(self): + rows = _parse_csv(impacts_to_csv(_make_impacts())) + phases = [r["phase"] for r in rows] + assert "total" in phases + + def test_usage_rows_present_by_default(self): + rows = _parse_csv(impacts_to_csv(_make_impacts())) + phases = [r["phase"] for r in rows] + assert "usage" in phases + + def test_embodied_rows_present_by_default(self): + rows = _parse_csv(impacts_to_csv(_make_impacts())) + phases = [r["phase"] for r in rows] + assert "embodied" in phases + + def test_no_phase_rows_when_disabled(self): + rows = _parse_csv(impacts_to_csv(_make_impacts(), include_phases=False)) + phases = {r["phase"] for r in rows} + assert phases == {"total"} + + def test_energy_unit_is_kwh(self): + rows = _parse_csv(impacts_to_csv(_make_impacts())) + energy_rows = [r for r in rows if r["phase"] == "total" and r["metric"] == "energy"] + assert len(energy_rows) == 1 + assert energy_rows[0]["unit"] == "kWh" + + def test_value_is_numeric(self): + rows = _parse_csv(impacts_to_csv(_make_impacts())) + for row in rows: + float(row["value"]) + + def test_five_total_metrics(self): + rows = _parse_csv(impacts_to_csv(_make_impacts())) + total_rows = [r for r in rows if r["phase"] == "total"] + metrics = {r["metric"] for r in total_rows} + assert metrics == {"energy", "gwp", "adpe", "pe", "wcf"} From 5b637c78d821d2bcaee25946db8a410bf20e939a Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:30:25 -0700 Subject: [PATCH 20/33] style: add missing blank line before WCF class in modeling.py (PEP 8) --- ecologits/impacts/modeling.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ecologits/impacts/modeling.py b/ecologits/impacts/modeling.py index 44b0dd86..80d18456 100644 --- a/ecologits/impacts/modeling.py +++ b/ecologits/impacts/modeling.py @@ -149,6 +149,7 @@ class PE(BaseImpact): name: str = "Primary Energy" unit: str = "MJ" + class WCF(BaseImpact): """ Water Consumption Footprint (WCF) impact. From b0e3540ddcb3d119d199e49256830dc1a04062c4 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:48:31 -0700 Subject: [PATCH 21/33] feat: export RangeValue, ValueOrRange, impacts_to_csv from ecologits.utils --- ecologits/utils/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ecologits/utils/__init__.py b/ecologits/utils/__init__.py index e69de29b..e704b2f1 100644 --- a/ecologits/utils/__init__.py +++ b/ecologits/utils/__init__.py @@ -0,0 +1,8 @@ +from ecologits.utils.export import impacts_to_csv +from ecologits.utils.range_value import RangeValue, ValueOrRange + +__all__ = [ + "RangeValue", + "ValueOrRange", + "impacts_to_csv", +] From b721d88e1f87c8c49bbc656a2c9d6b5d59a0a12b Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:48:44 -0700 Subject: [PATCH 22/33] =?UTF-8?q?fix:=20correct=20gpu=5Fenergy=20docstring?= =?UTF-8?q?=20=E2=80=94=20gamma=20was=20mislabelled=20as=20'Beta=20coeffic?= =?UTF-8?q?ient'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ecologits/impacts/llm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecologits/impacts/llm.py b/ecologits/impacts/llm.py index 63073f98..15b1e71d 100644 --- a/ecologits/impacts/llm.py +++ b/ecologits/impacts/llm.py @@ -51,7 +51,7 @@ def gpu_energy( batch_size: Number of requests handled concurrently by the server. gpu_energy_alpha: Alpha coefficient of the energy regression. gpu_energy_beta: Beta coefficient of the energy regression. - gpu_energy_gamma: Beta coefficient of the energy regression. + gpu_energy_gamma: Gamma coefficient of the energy regression. Returns: The energy consumption of a single GPU in kWh. From 935196a5dc5e9859717cd524808f78b36423fed6 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:49:09 -0700 Subject: [PATCH 23/33] feat: add load_config_from_yaml helper (#98) --- ecologits/config.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ecologits/config.py b/ecologits/config.py index 6694d3f3..9ef44c31 100644 --- a/ecologits/config.py +++ b/ecologits/config.py @@ -36,6 +36,42 @@ def load_config_from_json(path: str | Path) -> dict[str, Any]: raise ValueError(f"Invalid JSON in config file {path}: {exc}") from exc +def load_config_from_yaml(path: str | Path) -> dict[str, Any]: + """ + Load EcoLogits configuration from a YAML file. + + Requires ``pyyaml`` (``pip install pyyaml``). + + Args: + path: Path to the YAML config file. + + Returns: + Dictionary of configuration values suitable for passing to ``EcoLogits.init``. + + Raises: + FileNotFoundError: If the file does not exist. + ImportError: If ``pyyaml`` is not installed. + + Example:: + + config = load_config_from_yaml("ecologits.yaml") + EcoLogits.init(**config) + """ + try: + import yaml + except ImportError as exc: + raise ImportError( + "PyYAML is required to load YAML config files. " + "Install it with: pip install pyyaml" + ) from exc + + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + with path.open() as f: + return yaml.safe_load(f) or {} + + def load_config_from_env(prefix: str = "ECOLOGITS_") -> dict[str, Any]: """ Load EcoLogits configuration from environment variables. From a7641033ab2ca591848e85dda9fae061dfeae1be Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:49:26 -0700 Subject: [PATCH 24/33] feat: add EcoLogits.init_from_yaml class method (#98) --- ecologits/_ecologits.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ecologits/_ecologits.py b/ecologits/_ecologits.py index 5b8ddab6..57746b32 100644 --- a/ecologits/_ecologits.py +++ b/ecologits/_ecologits.py @@ -14,7 +14,7 @@ InvalidProviderError, OpenTelemetryNotInstalledError, ProviderNotInstalledError, - _PROVIDER_INSTALL_HINTS, + _PROVIDER_INSTALL_HINTS, # noqa: F401 – re-exported for convenience ) from ecologits.log import logger @@ -166,6 +166,26 @@ def init( EcoLogits.config.opentelemetry = OpenTelemetry(endpoint=opentelemetry_endpoint) + @staticmethod + def init_from_yaml(path: "str | Path") -> None: + """ + Initialize EcoLogits from a YAML configuration file. + + Requires ``pyyaml`` (``pip install pyyaml``). + + Args: + path: Path to a YAML file containing ``providers``, + ``electricity_mix_zone``, and/or ``opentelemetry_endpoint`` keys. + + Example:: + + EcoLogits.init_from_yaml("ecologits.yaml") + """ + from pathlib import Path as Path_ + from ecologits.config import load_config_from_yaml + config = load_config_from_yaml(Path_(path)) + EcoLogits.init(**config) + @staticmethod def init_from_config(path: "str | Path") -> None: """ From 84f4d156abbfa1d2a92ec14ca2c3add4eab09fa0 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:49:47 -0700 Subject: [PATCH 25/33] feat: export load_config_from_yaml from top-level package --- ecologits/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ecologits/__init__.py b/ecologits/__init__.py index 910ac451..45c6e31e 100644 --- a/ecologits/__init__.py +++ b/ecologits/__init__.py @@ -1,5 +1,5 @@ from ._ecologits import EcoLogits -from .config import load_config_from_env, load_config_from_json +from .config import load_config_from_env, load_config_from_json, load_config_from_yaml from .exceptions import ( ConfigurationError, EcoLogitsError, @@ -14,6 +14,7 @@ __all__ = [ "EcoLogits", "load_config_from_json", + "load_config_from_yaml", "load_config_from_env", "EcoLogitsError", "TracerInitializationError", From 667b7336dcf79467c11423951e1c55e5c05b51fb Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:50:19 -0700 Subject: [PATCH 26/33] test: add YAML config loading tests (#98) --- tests/test_config.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 8dc6f159..05f50b83 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,7 @@ import pytest from pathlib import Path -from ecologits.config import load_config_from_json, load_config_from_env +from ecologits.config import load_config_from_json, load_config_from_env, load_config_from_yaml from ecologits.exceptions import ConfigurationError @@ -70,3 +70,29 @@ def test_custom_prefix(self, monkeypatch): monkeypatch.setenv("MY_PROVIDERS", "cohere") result = load_config_from_env(prefix="MY_") assert result["providers"] == ["cohere"] + + +class TestLoadConfigFromYaml: + pytest.importorskip("yaml", reason="pyyaml not installed") + + def test_loads_providers(self, tmp_path): + p = tmp_path / "ecologits.yaml" + p.write_text("providers:\n - openai\n - anthropic\n") + result = load_config_from_yaml(p) + assert result["providers"] == ["openai", "anthropic"] + + def test_raises_file_not_found(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_config_from_yaml(tmp_path / "missing.yaml") + + def test_empty_yaml_returns_empty_dict(self, tmp_path): + p = tmp_path / "empty.yaml" + p.write_text("") + result = load_config_from_yaml(p) + assert result == {} + + def test_accepts_string_path(self, tmp_path): + p = tmp_path / "ecologits.yaml" + p.write_text("providers:\n - openai\n") + result = load_config_from_yaml(str(p)) + assert result["providers"] == ["openai"] From f2579a0a627676521df8ad27cc0e864e2f30f2bf Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:50:41 -0700 Subject: [PATCH 27/33] test: add unit tests for BaseImpact.to_dict, __repr__, and Impacts methods --- tests/test_impacts_modeling.py | 93 ++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/test_impacts_modeling.py diff --git a/tests/test_impacts_modeling.py b/tests/test_impacts_modeling.py new file mode 100644 index 00000000..6b7b973f --- /dev/null +++ b/tests/test_impacts_modeling.py @@ -0,0 +1,93 @@ +"""Tests for BaseImpact and Impacts model methods.""" +import json +import pytest + +from ecologits.impacts.modeling import Energy, GWP, ADPe, PE, WCF, Impacts, Usage, Embodied +from ecologits.utils.range_value import RangeValue + + +def _make_energy(value=0.001): + return Energy(value=value) + + +def _make_gwp(value=0.0004): + return GWP(value=value) + + +def _make_impacts_obj(): + e = Energy(value=0.001) + gwp = GWP(value=0.0004) + adpe = ADPe(value=1e-10) + pe = PE(value=0.009) + wcf = WCF(value=0.0018) + usage = Usage(energy=e, gwp=gwp, adpe=adpe, pe=pe, wcf=wcf) + embodied = Embodied(gwp=GWP(value=0.0001), adpe=ADPe(value=1e-11), pe=PE(value=0.001)) + return Impacts( + energy=e, + gwp=gwp + GWP(value=0.0001), + adpe=adpe + ADPe(value=1e-11), + pe=pe + PE(value=0.001), + wcf=wcf, + usage=usage, + embodied=embodied, + ) + + +class TestBaseImpactToDict: + def test_returns_dict(self): + assert isinstance(_make_energy().to_dict(), dict) + + def test_has_all_keys(self): + d = _make_energy().to_dict() + assert set(d.keys()) == {"type", "name", "value", "unit"} + + def test_type_is_energy(self): + assert _make_energy().to_dict()["type"] == "energy" + + def test_unit_is_kwh(self): + assert _make_energy().to_dict()["unit"] == "kWh" + + def test_value_is_scalar(self): + d = _make_energy(value=0.005).to_dict() + assert d["value"] == 0.005 + + def test_range_value_serialized(self): + e = Energy(value=RangeValue(min=0.001, max=0.002)) + d = e.to_dict() + assert isinstance(d["value"], dict) + assert "min" in d["value"] and "max" in d["value"] + + def test_repr_contains_value_and_unit(self): + r = repr(_make_energy(value=0.005)) + assert "0.005" in r + assert "kWh" in r + + +class TestImpactsModelMethods: + def test_to_dict_is_dict(self): + assert isinstance(_make_impacts_obj().to_dict(), dict) + + def test_to_json_round_trip(self): + imp = _make_impacts_obj() + d = json.loads(imp.to_json()) + assert d["energy"]["unit"] == "kWh" + + def test_summary_is_str(self): + assert isinstance(_make_impacts_obj().summary(), str) + + def test_repr_is_str(self): + assert isinstance(repr(_make_impacts_obj()), str) + + def test_impact_addition(self): + e1 = Energy(value=0.001) + e2 = Energy(value=0.002) + result = e1 + e2 + assert result.value == 0.003 + + def test_impact_equality(self): + assert Energy(value=0.001) == Energy(value=0.001) + + def test_impact_type_mismatch_add_raises(self): + from ecologits.exceptions import ModelingError + with pytest.raises(ModelingError): + Energy(value=0.001) + GWP(value=0.001) From 5dc51d0e01bfcc94876d934eb17d1cf81cbdfd6b Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:51:02 -0700 Subject: [PATCH 28/33] feat: add __repr__ to _StatusMessage; fix from_code return type annotation --- ecologits/status_messages.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ecologits/status_messages.py b/ecologits/status_messages.py index 9ff47bc2..15f5e807 100644 --- a/ecologits/status_messages.py +++ b/ecologits/status_messages.py @@ -17,8 +17,11 @@ class _StatusMessage(BaseModel): def __str__(self) -> str: return f"{self.message} For further information visit {STATUS_DOCS_URL.format(code=self.code)}" + def __repr__(self) -> str: + return f"{self.__class__.__name__}(code={self.code!r})" + @classmethod - def from_code(cls, code: str) -> type["_StatusMessage"]: + def from_code(cls, code: str) -> "_StatusMessage": raise NotImplementedError("Should be called from WarningMessage or ErrorMessage.") From 53abc2a5296a660a3a9e6a003c0c5cda2f4d9925 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:51:20 -0700 Subject: [PATCH 29/33] test: add integration tests for EcoLogits.init_from_config and init_from_env (#98, #100) --- tests/test_ecologits_init.py | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_ecologits_init.py diff --git a/tests/test_ecologits_init.py b/tests/test_ecologits_init.py new file mode 100644 index 00000000..02b6bc6b --- /dev/null +++ b/tests/test_ecologits_init.py @@ -0,0 +1,59 @@ +"""Tests for EcoLogits.init, init_from_config, init_from_env (#98, #100).""" +import json +import os +import pytest + +from ecologits import EcoLogits +from ecologits.exceptions import ConfigurationError, InvalidProviderError + + +class TestInitFromConfig: + def test_valid_json_config_initializes(self, tmp_path): + cfg = {"providers": ["openai"]} + p = tmp_path / "ecologits.json" + p.write_text(json.dumps(cfg)) + EcoLogits.init_from_config(p) + assert "openai" in EcoLogits.config.providers + + def test_missing_file_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + EcoLogits.init_from_config(tmp_path / "nope.json") + + def test_invalid_provider_in_config_raises(self, tmp_path): + cfg = {"providers": ["not_a_real_provider"]} + p = tmp_path / "bad.json" + p.write_text(json.dumps(cfg)) + with pytest.raises(InvalidProviderError): + EcoLogits.init_from_config(p) + + +class TestInitFromEnv: + def test_raises_when_no_env_vars(self, monkeypatch): + monkeypatch.delenv("ECOLOGITS_PROVIDERS", raising=False) + monkeypatch.delenv("ECOLOGITS_ELECTRICITY_MIX_ZONE", raising=False) + monkeypatch.delenv("ECOLOGITS_OPENTELEMETRY_ENDPOINT", raising=False) + with pytest.raises(ConfigurationError): + EcoLogits.init_from_env() + + def test_initializes_from_providers_env(self, monkeypatch): + monkeypatch.setenv("ECOLOGITS_PROVIDERS", "openai") + EcoLogits.init_from_env() + assert "openai" in EcoLogits.config.providers + + def test_sets_electricity_mix_zone(self, monkeypatch): + monkeypatch.setenv("ECOLOGITS_PROVIDERS", "openai") + monkeypatch.setenv("ECOLOGITS_ELECTRICITY_MIX_ZONE", "FRA") + EcoLogits.init_from_env() + assert EcoLogits.config.electricity_mix_zone == "FRA" + + +class TestInitValidation: + def test_invalid_provider_raises_with_helpful_message(self): + with pytest.raises(InvalidProviderError) as exc_info: + EcoLogits.init(providers=["bad_provider"]) + assert "bad_provider" in str(exc_info.value) + assert "openai" in str(exc_info.value) + + def test_string_provider_accepted(self): + EcoLogits.init(providers="openai") + assert "openai" in EcoLogits.config.providers From 579f8983344682919210abadcaf62e315e4d88b2 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:51:38 -0700 Subject: [PATCH 30/33] feat: export all impact model classes from ecologits.impacts --- ecologits/impacts/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ecologits/impacts/__init__.py b/ecologits/impacts/__init__.py index 6cd40d76..edf2fe46 100644 --- a/ecologits/impacts/__init__.py +++ b/ecologits/impacts/__init__.py @@ -1,7 +1,14 @@ from .llm import compute_llm_impacts -from .modeling import Impacts +from .modeling import ADPe, Embodied, Energy, GWP, Impacts, PE, Usage, WCF __all__ = [ "Impacts", - "compute_llm_impacts" + "Energy", + "GWP", + "ADPe", + "PE", + "WCF", + "Usage", + "Embodied", + "compute_llm_impacts", ] From d2cf0dcc1cecab82bf998d024a365bed1e81f19a Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:52:10 -0700 Subject: [PATCH 31/33] fix: add __rsub__ to RangeValue so scalar - RangeValue is computed correctly --- ecologits/utils/range_value.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ecologits/utils/range_value.py b/ecologits/utils/range_value.py index c3883b43..a598ab6d 100644 --- a/ecologits/utils/range_value.py +++ b/ecologits/utils/range_value.py @@ -55,6 +55,10 @@ def __truediv__(self, other: Union[int, float]) -> "RangeValue": max=self.max / other ) + def __rsub__(self, other: "Union[int, float]") -> "RangeValue": + """Support ``scalar - RangeValue`` — note: subtraction is not commutative.""" + return RangeValue(min=other - self.max, max=other - self.min) + __radd__ = __add__ __rmul__ = __mul__ __rtruediv__ = __truediv__ From 5804008cf3257fd63291c191b38dba5da5f87e25 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:52:44 -0700 Subject: [PATCH 32/33] test: add __rsub__ test for scalar minus RangeValue --- tests/test_range_value.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_range_value.py b/tests/test_range_value.py index 43ceb2a1..10c70b68 100644 --- a/tests/test_range_value.py +++ b/tests/test_range_value.py @@ -100,3 +100,7 @@ def test_to_dict_mean(self): def test_repr_contains_min_max_mean(self): r = repr(RangeValue(min=1, max=3)) assert "min=1" in r and "max=3" in r and "mean=2.0" in r + + def test_rsub_scalar_minus_range(self): + r = 10 - RangeValue(min=2, max=4) + assert r.min == 6 and r.max == 8 From 1559f397d837563df795ccb56f370c33cafafa97 Mon Sep 17 00:00:00 2001 From: Vinisha Date: Wed, 13 May 2026 14:52:58 -0700 Subject: [PATCH 33/33] feat: add __repr__ to EcoLogits._Config and EcoLogits.reset() for test isolation --- ecologits/_ecologits.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ecologits/_ecologits.py b/ecologits/_ecologits.py index 57746b32..90fc1935 100644 --- a/ecologits/_ecologits.py +++ b/ecologits/_ecologits.py @@ -124,8 +124,19 @@ class _Config: electricity_mix_zone: str | None = None opentelemetry: OpenTelemetry | None = None + def __repr__(self) -> str: + return ( + f"EcoLogits.Config(providers={self.providers!r}, " + f"electricity_mix_zone={self.electricity_mix_zone!r})" + ) + config = _Config() + @classmethod + def reset(cls) -> None: + """Reset EcoLogits configuration to defaults. Useful in tests.""" + cls.config = cls._Config() + @staticmethod def init( providers: str | list[str] | None = None,