diff --git a/ecologits/__init__.py b/ecologits/__init__.py index 1c3938a5..45c6e31e 100644 --- a/ecologits/__init__.py +++ b/ecologits/__init__.py @@ -1,7 +1,27 @@ from ._ecologits import EcoLogits +from .config import load_config_from_env, load_config_from_json, load_config_from_yaml +from .exceptions import ( + ConfigurationError, + EcoLogitsError, + InvalidProviderError, + ModelingError, + OpenTelemetryNotInstalledError, + ProviderNotInstalledError, + TracerInitializationError, +) __version__ = "0.10.1" __all__ = [ "EcoLogits", - "__version__" + "load_config_from_json", + "load_config_from_yaml", + "load_config_from_env", + "EcoLogitsError", + "TracerInitializationError", + "ModelingError", + "ProviderNotInstalledError", + "InvalidProviderError", + "ConfigurationError", + "OpenTelemetryNotInstalledError", + "__version__", ] diff --git a/ecologits/_ecologits.py b/ecologits/_ecologits.py index df877e14..90fc1935 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, # noqa: F401 – re-exported for convenience +) from ecologits.log import logger if TYPE_CHECKING: @@ -117,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, @@ -153,14 +171,75 @@ 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 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: + """ + 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: """ @@ -198,9 +277,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 @@ -210,7 +290,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() diff --git a/ecologits/config.py b/ecologits/config.py new file mode 100644 index 00000000..9ef44c31 --- /dev/null +++ b/ecologits/config.py @@ -0,0 +1,111 @@ +"""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_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. + + 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 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]`" + ) 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", ] diff --git a/ecologits/impacts/llm.py b/ecologits/impacts/llm.py index 4d56e10c..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. @@ -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 diff --git a/ecologits/impacts/modeling.py b/ecologits/impacts/modeling.py index af91c42f..80d18456 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): """ @@ -130,6 +149,7 @@ class PE(BaseImpact): name: str = "Primary Energy" unit: str = "MJ" + class WCF(BaseImpact): """ Water Consumption Footprint (WCF) impact. @@ -226,3 +246,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})" + ) diff --git a/ecologits/status_messages.py b/ecologits/status_messages.py index 824df53a..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.") @@ -76,9 +79,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]] = { 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", +] 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() diff --git a/ecologits/utils/range_value.py b/ecologits/utils/range_value.py index 86701411..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__ @@ -89,7 +93,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] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..05f50b83 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,98 @@ +"""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, load_config_from_yaml +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"] + + +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"] 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 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"]) 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"} 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) 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() diff --git a/tests/test_range_value.py b/tests/test_range_value.py index 586707bf..10c70b68 100644 --- a/tests/test_range_value.py +++ b/tests/test_range_value.py @@ -66,3 +66,41 @@ 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 + + def test_rsub_scalar_minus_range(self): + r = 10 - RangeValue(min=2, max=4) + assert r.min == 6 and r.max == 8 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) 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