diff --git a/carbonserver/carbonserver/api/infra/database/sql_models.py b/carbonserver/carbonserver/api/infra/database/sql_models.py index 8872abb21..0cbb4a315 100644 --- a/carbonserver/carbonserver/api/infra/database/sql_models.py +++ b/carbonserver/carbonserver/api/infra/database/sql_models.py @@ -25,6 +25,7 @@ class Emission(Base): gpu_utilization_percent = Column(Float, nullable=True) ram_utilization_percent = Column(Float, nullable=True) wue = Column(Float, nullable=False, default=0) + water_consumed = Column(Float, nullable=False, default=0) run_id = Column(UUID(as_uuid=True), ForeignKey("runs.id", ondelete="CASCADE")) run = relationship("Run", back_populates="emissions") diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py b/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py index 893ab86e1..f6462ae87 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py @@ -41,6 +41,8 @@ def add_emission(self, emission: EmissionCreate) -> UUID: gpu_energy=emission.gpu_energy, ram_energy=emission.ram_energy, energy_consumed=emission.energy_consumed, + # coalesce None to 0: the column is NOT NULL + water_consumed=emission.water_consumed or 0, cpu_utilization_percent=emission.cpu_utilization_percent, gpu_utilization_percent=emission.gpu_utilization_percent, ram_utilization_percent=emission.ram_utilization_percent, @@ -108,6 +110,7 @@ def map_sql_to_schema(emission: sql_models.Emission) -> Emission: gpu_energy=emission.gpu_energy, ram_energy=emission.ram_energy, energy_consumed=emission.energy_consumed, + water_consumed=emission.water_consumed, cpu_utilization_percent=emission.cpu_utilization_percent, gpu_utilization_percent=emission.gpu_utilization_percent, ram_utilization_percent=emission.ram_utilization_percent, diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py b/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py index 51b5be1f8..88226a068 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py @@ -85,6 +85,7 @@ def get_project_global_sums_by_experiment(self, project_id): SqlModelExperiment.description, func.sum(SqlModelEmission.emissions_sum).label("emission_sum"), func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), + func.sum(SqlModelEmission.water_consumed).label("water_consumed"), func.sum(SqlModelEmission.duration).label("duration"), ) .join( @@ -133,6 +134,7 @@ def get_project_detailed_sums_by_experiment( func.sum(SqlModelEmission.gpu_energy).label("gpu_energy"), func.sum(SqlModelEmission.ram_energy).label("ram_energy"), func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), + func.sum(SqlModelEmission.water_consumed).label("water_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), func.avg(SqlModelEmission.cpu_utilization_percent).label( diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py index f7d19669c..4663acf0e 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py @@ -114,6 +114,7 @@ def get_organization_detailed_sums( func.sum(SqlModelEmission.gpu_energy).label("gpu_energy"), func.sum(SqlModelEmission.ram_energy).label("ram_energy"), func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), + func.sum(SqlModelEmission.water_consumed).label("water_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), func.avg(SqlModelEmission.cpu_utilization_percent).label( diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py index a389f1dd5..3d682bc12 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py @@ -128,6 +128,7 @@ def get_project_detailed_sums( func.sum(SqlModelEmission.gpu_energy).label("gpu_energy"), func.sum(SqlModelEmission.ram_energy).label("ram_energy"), func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), + func.sum(SqlModelEmission.water_consumed).label("water_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), func.avg(SqlModelEmission.cpu_utilization_percent).label( diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py index c14fb25e8..00ed44ac8 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py @@ -170,6 +170,7 @@ def get_experiment_detailed_sums_by_run( func.sum(SqlModelEmission.gpu_energy).label("gpu_energy"), func.sum(SqlModelEmission.ram_energy).label("ram_energy"), func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), + func.sum(SqlModelEmission.water_consumed).label("water_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), func.avg(SqlModelEmission.cpu_utilization_percent).label( diff --git a/carbonserver/carbonserver/api/schemas.py b/carbonserver/carbonserver/api/schemas.py index a3343929d..9c9305e03 100644 --- a/carbonserver/carbonserver/api/schemas.py +++ b/carbonserver/carbonserver/api/schemas.py @@ -100,6 +100,11 @@ class EmissionBase(BaseModel): ge=0, description="The WUE (Water Usage Effectiveness) must be greater than or equal to zero", ) + water_consumed: Optional[float] = Field( + default=0, + ge=0, + description="The water consumed (L) must be greater than or equal to zero", + ) model_config = ConfigDict( json_schema_extra={ @@ -117,6 +122,7 @@ class EmissionBase(BaseModel): "ram_energy": 2.0, "energy_consumed": 57.21874, "wue": 0, + "water_consumed": 0, } } ) @@ -190,6 +196,7 @@ class RunReport(RunBase): gpu_energy: float ram_energy: float energy_consumed: float + water_consumed: float = 0 duration: float emissions_rate: float emissions_count: int @@ -283,6 +290,7 @@ class ExperimentReport(ExperimentBase): gpu_energy: float ram_energy: float energy_consumed: float + water_consumed: float = 0 duration: int emissions_rate: float emissions_count: int @@ -396,6 +404,7 @@ class ProjectReport(ProjectBase): gpu_energy: float ram_energy: float energy_consumed: float + water_consumed: float = 0 duration: int emissions_rate: float emissions_count: int @@ -443,6 +452,7 @@ class OrganizationReport(OrganizationBase): gpu_energy: float ram_energy: float energy_consumed: float + water_consumed: float = 0 duration: int emissions_rate: float emissions_count: int diff --git a/carbonserver/carbonserver/database/alembic/versions/20260902_add_water_consumed.py b/carbonserver/carbonserver/database/alembic/versions/20260902_add_water_consumed.py new file mode 100644 index 000000000..60030f089 --- /dev/null +++ b/carbonserver/carbonserver/database/alembic/versions/20260902_add_water_consumed.py @@ -0,0 +1,27 @@ +"""add water_consumed to emissions + +Revision ID: 20260902_add_water +Revises: 20251119_add_utilization +Create Date: 2026-09-02 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "20260902_add_water" +down_revision = "20251119_add_utilization" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "emissions", + sa.Column("water_consumed", sa.Float(), nullable=False, server_default="0"), + ) + + +def downgrade(): + op.drop_column("emissions", "water_consumed") diff --git a/carbonserver/tests/api/routers/test_emissions.py b/carbonserver/tests/api/routers/test_emissions.py index f33f70f9d..8affe99f0 100644 --- a/carbonserver/tests/api/routers/test_emissions.py +++ b/carbonserver/tests/api/routers/test_emissions.py @@ -40,6 +40,7 @@ "ram_energy": 2.0, "energy_consumed": 57.21874, "wue": 0, + "water_consumed": 0.0, } EMISSION_1 = { @@ -57,6 +58,7 @@ "ram_energy": 2.0, "energy_consumed": 57.21874, "wue": 0, + "water_consumed": 0.0, "cpu_utilization_percent": None, "gpu_utilization_percent": None, "ram_utilization_percent": None, @@ -77,6 +79,7 @@ "ram_energy": 2.0, "energy_consumed": 57.21874, "wue": 0, + "water_consumed": 0.0, } @@ -95,6 +98,7 @@ "ram_energy": 2.0, "energy_consumed": 57.21874, "wue": 0, + "water_consumed": 0.0, } @@ -229,7 +233,7 @@ def test_add_emission_with_default_wue_value(client, custom_test_server): "gpu_energy": 0.0, "ram_energy": 2.0, "energy_consumed": 57.21874, - # Note: wue is not provided, should default to 0 + # Note: wue and water_consumed are not provided, should default to 0 } repository_mock = mock.Mock(spec=EmissionRepository) @@ -266,6 +270,9 @@ def test_add_emission_with_default_wue_value(client, custom_test_server): # Verify that the repository was called with WUE defaulting to 0 called_emission = repository_mock.add_emission.call_args[0][0] assert called_emission.wue == 0, "WUE should default to 0 when not provided" + assert ( + called_emission.water_consumed == 0 + ), "water_consumed should default to 0 when not provided" def test_add_emission_with_custom_wue_value(client, custom_test_server): @@ -285,6 +292,7 @@ def test_add_emission_with_custom_wue_value(client, custom_test_server): "ram_energy": 2.0, "energy_consumed": 57.21874, "wue": 1.5, + "water_consumed": 12.5, } repository_mock = mock.Mock(spec=EmissionRepository) @@ -321,3 +329,6 @@ def test_add_emission_with_custom_wue_value(client, custom_test_server): # Verify that the repository was called with the correct WUE value called_emission = repository_mock.add_emission.call_args[0][0] assert called_emission.wue == 1.5, "WUE should be set to the provided value" + assert ( + called_emission.water_consumed == 12.5 + ), "water_consumed should be set to the provided value" diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..477be9904 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -358,6 +358,34 @@ def config(): ) +def _cli_provided(ctx, name: str) -> bool: + """ + Whether the option `name` was typed on the command line (or set through its + environment variable) rather than left at its Typer default. + + Options left at their default must not be forwarded to the tracker: doing so + would silently override the values coming from `.codecarbon.config` and the + `CODECARBON_*` environment variables, which the tracker reads itself. + """ + get_source = getattr(ctx, "get_parameter_source", None) + if get_source is None: + # `monitor` called directly from Python, not through Click: every value + # given is explicit. + return True + source = get_source(name) + return source is None or source.name in ("COMMANDLINE", "ENVIRONMENT") + + +def _external_config() -> dict: + """The configuration files and CODECARBON_* variables, as a plain dict.""" + from codecarbon.core.config import get_hierarchical_config + + try: + return dict(get_hierarchical_config()) + except Exception: + return {} + + @codecarbon.command( "monitor", short_help="Monitor your machine's carbon emissions.", @@ -393,15 +421,27 @@ def monitor( ): """Monitor your machine's carbon emissions.""" - # Shared tracker args so monitor and run_and_monitor behave the same + external_conf = _external_config() + + # Shared tracker args so monitor and run_and_monitor behave the same. + # Only the options actually given are forwarded: the others are left to the + # tracker, which resolves them from the configuration file and environment. tracker_args = { - "measure_power_secs": measure_power_secs, - "api_call_interval": api_call_interval, - "log_level": log_level, + name: value + for name, value in ( + ("measure_power_secs", measure_power_secs), + ("api_call_interval", api_call_interval), + ("log_level", log_level), + ) + if _cli_provided(ctx, name) } + if "log_level" not in tracker_args and "log_level" not in external_conf: + # Nothing configures it: keep the unattended monitor quiet. + tracker_args["log_level"] = log_level + # Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode if offline: - if not country_iso_code: + if not country_iso_code and "country_iso_code" not in external_conf: print( "ERROR: Country ISO code is required for offline mode. Add it to your configuration or provide it via the command line: `--country-iso-code FRA`", file=sys.stderr, @@ -410,8 +450,8 @@ def monitor( tracker_args = { **tracker_args, - "country_iso_code": country_iso_code, - "region": region, + **({"country_iso_code": country_iso_code} if country_iso_code else {}), + **({"region": region} if region else {}), } else: experiment_id = get_existing_exp_id() diff --git a/codecarbon/cli/monitor.py b/codecarbon/cli/monitor.py index 41b3ca353..dbecf7855 100644 --- a/codecarbon/cli/monitor.py +++ b/codecarbon/cli/monitor.py @@ -3,6 +3,7 @@ import os import subprocess import sys +from typing import Optional import typer from rich import print @@ -12,9 +13,9 @@ def run_and_monitor( ctx: typer.Context, log_level: Annotated[ - str, + Optional[str], typer.Option(help="Log level (critical, error, warning, info, debug)"), - ] = "error", + ] = None, offline: bool = False, **tracker_args, ): @@ -51,7 +52,11 @@ def run_and_monitor( from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker from codecarbon.external.logger import set_logger_level - set_logger_level(log_level) + # `log_level` is None when nothing set it: leave it to the tracker, which + # resolves it from the configuration file and the environment. + if log_level is not None: + set_logger_level(log_level) + tracker_args["log_level"] = log_level # Get the command from remaining args (strip nested subcommand / `--` leftovers) command = list(getattr(ctx, "args", None) or []) @@ -67,7 +72,6 @@ def run_and_monitor( tracker_cls = OfflineEmissionsTracker if offline else EmissionsTracker tracker = tracker_cls( - log_level=log_level, save_to_logger=False, tracking_mode="process", **tracker_args, diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index bc2e0974e..f5a2ccdcc 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -211,6 +211,7 @@ def add_emission(self, carbon_emission: dict): gpu_utilization_percent=carbon_emission.get("gpu_utilization_percent"), ram_utilization_percent=carbon_emission.get("ram_utilization_percent"), wue=carbon_emission.get("wue", 0), + water_consumed=carbon_emission.get("water_consumed", 0), ) try: payload = dataclasses.asdict(emission) diff --git a/codecarbon/core/schemas.py b/codecarbon/core/schemas.py index 84d1e9c77..894756a20 100644 --- a/codecarbon/core/schemas.py +++ b/codecarbon/core/schemas.py @@ -26,6 +26,7 @@ class EmissionBase: gpu_utilization_percent: Optional[float] = None ram_utilization_percent: Optional[float] = None wue: Optional[float] = 0 + water_consumed: Optional[float] = 0 class EmissionCreate(EmissionBase): diff --git a/codecarbon/core/units.py b/codecarbon/core/units.py index c1f770b06..e27ebc7e1 100644 --- a/codecarbon/core/units.py +++ b/codecarbon/core/units.py @@ -49,6 +49,21 @@ def from_kgs_per_kWh(cls, kgs_per_kWh: float) -> "EmissionsPerKWh": return cls(kgs_per_kWh=kgs_per_kWh) +@dataclass +class WaterPerKWh: + """ + Measured in L/kWh + """ + + GALUS_TO_L = 3.785411784 + + l_per_kWh: float + + @classmethod + def from_gal_us_per_MWh(cls, gal_us_per_MWh: float) -> "WaterPerKWh": + return cls(l_per_kWh=gal_us_per_MWh * WaterPerKWh.GALUS_TO_L * 0.001) + + @dataclass(order=True) class Energy: """ diff --git a/codecarbon/core/water_consumption.py b/codecarbon/core/water_consumption.py new file mode 100644 index 000000000..fb6cf9ac7 --- /dev/null +++ b/codecarbon/core/water_consumption.py @@ -0,0 +1,221 @@ +""" +Provides functionality to compute the water consumed to generate the +electricity used by the compute, for cloud & private infra. + +This is the *indirect* water consumption (water evaporated or consumed by +power plants to generate electricity), estimated from the energy mix of the +country or region where the compute runs. The *direct* water consumption of +a data center (cooling) is covered by the ``wue`` parameter of the trackers. +""" + +from typing import Dict, Optional, Tuple + +from codecarbon.core.units import Energy, WaterPerKWh +from codecarbon.external.geography import CloudMetadata, GeoMetadata +from codecarbon.external.logger import logger +from codecarbon.input import DataSource + + +class WaterConsumption: + # Fraction of the electricity production that must be covered by + # per-source water data before we trust the computed intensity. Below + # this threshold we fall back to the world average. + MIN_ENERGY_MIX_COVERAGE = 0.9 + + # Map of the source names of regional energy mix files (e.g. + # canada_energy_mix.json) to the source names of + # water_consumption_per_source.json + REGION_SOURCE_TO_WATER_SOURCE = { + "coal": "coal", + "petroleum": "oil", + "naturalGas": "gas", + "nuclear": "nuclear", + "hydro": "hydroelectricity", + "biomass": "biofuel", + "solar": "solar", + "wind": "wind", + } + + def __init__(self, data_source: DataSource): + self._data_source = data_source + # The water intensity is constant for a given location: cache it so + # it is not recomputed, and its warnings not re-logged, on every + # measurement cycle. + self._intensity_cache: Dict[ + Tuple[Optional[str], Optional[str]], WaterPerKWh + ] = {} + + def get_cloud_water_consumption( + self, energy: Energy, cloud: CloudMetadata, geo: Optional[GeoMetadata] = None + ) -> float: + """ + Computes water consumption for cloud infra. + Cloud providers do not publish per-region water usage data, so the + water intensity of the electricity of the country hosting the + machine is used when known, else the world average. + :param energy: Energy consumed by the process (kWh) + :param cloud: Cloud provider and region of compute + :param geo: Instance of GeoMetadata to fall back on + :return: water consumption in L + """ + if geo: + return self.get_private_infra_water_consumption(energy, geo) + return self._world_average_water_intensity().l_per_kWh * energy.kWh + + def get_private_infra_water_consumption( + self, energy: Energy, geo: GeoMetadata + ) -> float: + """ + Computes water consumption for private infra. + :param energy: Energy consumed by the process (kWh) + :param geo: Country and region metadata + :return: water consumption in L + """ + cache_key = (geo.country_iso_code, geo.region) + water_per_kWh = self._intensity_cache.get(cache_key) + if water_per_kWh is None: + water_per_kWh = self._get_water_intensity(geo) + self._intensity_cache[cache_key] = water_per_kWh + return water_per_kWh.l_per_kWh * energy.kWh # L + + def get_region_water_consumption(self, energy: Energy, geo: GeoMetadata) -> float: + """ + Computes water consumption for a region on private infra, + using the regional energy mix when available. + :param energy: Energy consumed by the process (kWh) + :param geo: Country and region metadata + :return: water consumption in L + """ + return self._region_water_intensity(geo).l_per_kWh * energy.kWh # L + + def get_country_water_consumption(self, energy: Energy, geo: GeoMetadata) -> float: + """ + Computes water consumption for a country on private infra, + using the mix of energy sources of that country. + :param energy: Energy consumed by the process (kWh) + :param geo: Country and region metadata + :return: water consumption in L + """ + return self._country_water_intensity(geo).l_per_kWh * energy.kWh # L + + def _get_water_intensity(self, geo: GeoMetadata) -> WaterPerKWh: + country_iso_code = ( + geo.country_iso_code.upper() if geo.country_iso_code is not None else None + ) + # Canada is the only country with a regional energy mix data file. + # The USA regional data is at the emissions level, not the energy + # mix level, so it cannot be used for water. + compute_with_regional_data: bool = (geo.region is not None) and ( + country_iso_code == "CAN" + ) + + if compute_with_regional_data: + try: + return self._region_water_intensity(geo) + except Exception as e: + logger.debug( + f"Regional water intensity retrieval failed ({e})." + + " Falling back on the country water intensity." + ) + return self._country_water_intensity(geo) + + def _region_water_intensity(self, geo: GeoMetadata) -> WaterPerKWh: + country_energy_mix_data = self._data_source.get_country_energy_mix_data( + geo.country_iso_code.lower() + ) + energy_mix = country_energy_mix_data[geo.region] + return self._energy_mix_to_water_intensity( + energy_by_water_source={ + water_source: energy_mix.get(source) + for source, water_source in self.REGION_SOURCE_TO_WATER_SOURCE.items() + }, + energy_sum=energy_mix["total"], + place=f"the region {geo.region}", + ) + + def _country_water_intensity(self, geo: GeoMetadata) -> WaterPerKWh: + energy_mix = self._data_source.get_global_energy_mix_data() + + if geo.country_iso_code not in energy_mix: + logger.warning( + f"We do not have water data for {geo.country_iso_code}," + " using world average water intensity." + ) + return self._world_average_water_intensity() + + country_energy_mix: Dict = energy_mix[geo.country_iso_code] + # Iterate through the primary sources of energy in the country. + # Aggregated sources of global_energy_mix.json (fossil, renewables, + # low_carbon...) have no entry in water_consumption_per_source.json, + # so they are skipped and no energy is counted twice. + water_per_kWh = self._energy_mix_to_water_intensity( + energy_by_water_source={ + source[: -len("_TWh")]: energy_per_year + for source, energy_per_year in country_energy_mix.items() + if source.endswith("_TWh") + }, + energy_sum=country_energy_mix["total_TWh"], + place=str(geo.country_name), + ) + logger.debug( + f"We apply a water intensity of {water_per_kWh.l_per_kWh:.3f}" + + f" L/kWh for {geo.country_name}" + ) + return water_per_kWh + + def _energy_mix_to_water_intensity( + self, + energy_by_water_source: Dict[str, Optional[float]], + energy_sum: float, + place: str, + ) -> WaterPerKWh: + """ + Convert a mix of electricity sources into water consumed per kWh of + electricity, as the weighted average of the water consumption of the + sources with known water data. + :param energy_by_water_source: energy produced, keyed by the source + names of water_consumption_per_source.json. Sources with no + water data or a None energy are ignored. + :param energy_sum: total energy produced, in the same unit + :param place: name of the country or region, for logging + :return: a WaterPerKWh object representing the average water + intensity in L/kWh + """ + if not energy_sum: + logger.warning( + f"No total energy production for {place}, using world average" + " water intensity." + ) + return self._world_average_water_intensity() + + water_consumption_per_source = ( + self._data_source.get_water_consumption_per_source_data() + ) + water_intensity = 0 # gal us / MWh, weighted by source share + energy_sum_covered = 0 + for water_source, energy_for_source in energy_by_water_source.items(): + water_for_source = water_consumption_per_source.get(water_source) + if water_for_source is not None and energy_for_source is not None: + water_intensity += (energy_for_source / energy_sum) * water_for_source + energy_sum_covered += energy_for_source + + coverage = energy_sum_covered / energy_sum + if coverage < self.MIN_ENERGY_MIX_COVERAGE: + logger.warning( + f"Only {coverage:.0%} of the electricity produced in {place}" + " comes from sources with known water intensity," + " using world average." + ) + return self._world_average_water_intensity() + + # Attribute the average intensity of the covered sources to the + # (small) uncovered share of the mix. + return WaterPerKWh.from_gal_us_per_MWh(water_intensity / coverage) + + def _world_average_water_intensity(self) -> WaterPerKWh: + water_consumption_per_source = ( + self._data_source.get_water_consumption_per_source_data() + ) + return WaterPerKWh.from_gal_us_per_MWh( + water_consumption_per_source["world_average"] + ) diff --git a/codecarbon/data/private_infra/water_consumption_per_source.json b/codecarbon/data/private_infra/water_consumption_per_source.json new file mode 100644 index 000000000..7e876df86 --- /dev/null +++ b/codecarbon/data/private_infra/water_consumption_per_source.json @@ -0,0 +1,24 @@ +{ + "unit": "gal us Water.eq/MWh", + "comment": "Operational water consumption of electricity generation per source. Keys match the source names used in global_energy_mix.json (without the _TWh suffix).", + "world_average": 991.66, + "world_average_source": "Computed from the per-source values of this file, weighted by the 2021 world energy mix of global_energy_mix.json (99.7% coverage), so that the fallback is consistent with the computed country values. For comparison, https://www.mdpi.com/2073-4441/12/9/2482 reports 216.62 gal us/MWh with a different accounting of hydropower.", + "coal": 550, + "coal_source": "https://iopscience.iop.org/article/10.1088/1748-9326/8/1/015031", + "gas": 210, + "gas_source": "https://iopscience.iop.org/article/10.1088/1748-9326/8/1/015031", + "oil": 550, + "oil_source": "No dedicated figure in the literature reviewed; approximated with the coal value (both are thermal steam cycles).", + "nuclear": 775, + "nuclear_source": "https://iopscience.iop.org/article/10.1088/1748-9326/8/1/015031", + "hydroelectricity": 4491, + "hydroelectricity_source": "https://iopscience.iop.org/article/10.1088/1748-9326/7/4/045802 (median for hydropower; reservoir evaporation, highly site-dependent and uncertain)", + "biofuel": 553, + "biofuel_source": "https://iopscience.iop.org/article/10.1088/1748-9326/7/4/045802 (biopower, steam cycle with cooling tower)", + "geothermal": 290, + "geothermal_source": "https://iopscience.iop.org/article/10.1088/1748-9326/8/1/015031", + "solar": 85, + "solar_source": "https://iopscience.iop.org/article/10.1088/1748-9326/8/1/015031", + "wind": 11, + "wind_source": "https://iopscience.iop.org/article/10.1088/1748-9326/8/1/015031" +} diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 54e95a9fa..3eb3d79d2 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -356,6 +356,7 @@ def _initialize_emissions_context(self) -> None: self._data_source = DataSource() self._geo = None self._emissions = None + self._water_consumption = None def _ensure_cloud_conf(self) -> None: if self._conf.get("_cloud_conf_initialized"): @@ -375,6 +376,9 @@ def _ensure_emissions_engine(self) -> None: self._electricitymaps_api_token, force_carbon_intensity_g_co2e_kwh=self.force_carbon_intensity_g_co2e_kwh, ) + from codecarbon.core.water_consumption import WaterConsumption + + self._water_consumption = WaterConsumption(self._data_source) def _ensure_geo_metadata(self) -> None: """Load geo metadata on first use to avoid blocking tracker construction.""" @@ -506,7 +510,10 @@ def __init__( and the reported energy, including forced values: with `force_cpu_power=100` and `pue=1.5` the CPU is reported at 150 W. :param wue: WUE (Water Usage Effectiveness) of the data center. Units of L/kWh: - litres of water consumed per kilowatt-hour of electricity consumed. + litres of water consumed on-site (cooling) per kilowatt-hour of + electricity consumed. This direct water consumption is added to + the indirect water consumed to generate the electricity, which + is estimated from the local energy mix. :param force_carbon_intensity_g_co2e_kwh: Override grid carbon intensity in gCO2e/kWh for emissions calculations. :param force_mode_cpu_load: Force the addition of a CPU in MODE_CPU_LOAD @@ -847,6 +854,7 @@ def stop_task(self, task_name: str = None) -> EmissionsData: emissions_data_delta.gpu_energy = 0.0 emissions_data_delta.ram_energy = 0.0 emissions_data_delta.energy_consumed = 0.0 + emissions_data_delta.water_consumed = 0.0 else: emissions_data_delta = dataclasses.replace(emissions_data) emissions_data_delta.compute_delta_emission( @@ -998,8 +1006,32 @@ def _update_emissions(self) -> None: delta_energy, cloud, self._geo ) self._total_emissions += delta_emissions + self._update_water(delta_energy, cloud) self._last_energy_covered = self._total_energy + def _update_water(self, delta_energy: Energy, cloud: CloudMetadata) -> None: + """ + Estimate the water consumed to produce the electricity used since the + last update and add it to the total. This comes on top of the direct + water consumption of the data center accumulated from the WUE in + _do_measurements(). A failure of this auxiliary estimation must never + break the emissions tracking. + """ + try: + if cloud.is_on_private_infra: + delta_water = ( + self._water_consumption.get_private_infra_water_consumption( + delta_energy, self._geo + ) + ) # float: L + else: + delta_water = self._water_consumption.get_cloud_water_consumption( + delta_energy, cloud, self._geo + ) # float: L + self._total_water += Water.from_litres(litres=delta_water) + except Exception as e: + logger.warning(f"Failed to estimate the water consumption: {e}") + def _prepare_emissions_data(self) -> EmissionsData: """ Prepare the emissions data to be sent to the API or written to a file. @@ -1614,7 +1646,10 @@ def track_emissions( the reported power and the reported energy, including forced values: with `force_cpu_power=100` and `pue=1.5` the CPU is reported at 150 W. :param wue: WUE (Water Usage Effectiveness) of the data center. Units of L/kWh: - litres of water consumed per kilowatt-hour of electricity consumed. + litres of water consumed on-site (cooling) per kilowatt-hour of + electricity consumed. This direct water consumption is added to + the indirect water consumed to generate the electricity, which + is estimated from the local energy mix. :param force_carbon_intensity_g_co2e_kwh: Override grid carbon intensity in gCO2e/kWh for emissions calculations. :param rapl_include_dram: Include DRAM in the counter-based CPU measurements diff --git a/codecarbon/input.py b/codecarbon/input.py index 4ed23db2b..9adbab975 100644 --- a/codecarbon/input.py +++ b/codecarbon/input.py @@ -53,6 +53,11 @@ def _load_static_data() -> None: with open(path) as f: _CACHE["carbon_intensity_per_source"] = json.load(f) + # Water consumption per source + path = _get_resource_path("data/private_infra/water_consumption_per_source.json") + with open(path) as f: + _CACHE["water_consumption_per_source"] = json.load(f) + # CPU power data path = _get_resource_path("data/hardware/cpu_power.csv") _CACHE["cpu_power"] = pd.read_csv(path) @@ -84,6 +89,7 @@ def __init__(self): "can_energy_mix_data_path": "data/private_infra/2023/canada_energy_mix.json", # noqa: E501 "global_energy_mix_data_path": "data/private_infra/global_energy_mix.json", # noqa: E501 "carbon_intensity_per_source_path": "data/private_infra/carbon_intensity_per_source.json", + "water_consumption_per_source_path": "data/private_infra/water_consumption_per_source.json", "cpu_power_path": "data/hardware/cpu_power.csv", } self.module_name = "codecarbon" @@ -119,6 +125,15 @@ def carbon_intensity_per_source_path(self): self.module_name, self.config["carbon_intensity_per_source_path"] ) + @property + def water_consumption_per_source_path(self): + """ + Get the path from the package resources. + """ + return self.get_ressource_path( + self.module_name, self.config["water_consumption_per_source_path"] + ) + def country_emissions_data_path(self, country: str): return self.get_ressource_path( self.module_name, self.config[f"{country}_emissions_data_path"] @@ -195,6 +210,14 @@ def get_carbon_intensity_per_source_data(self) -> Dict: _ensure_static_data_loaded() return _CACHE["carbon_intensity_per_source"] + def get_water_consumption_per_source_data(self) -> Dict: + """ + Returns water consumption per energy source. In gal us Water.eq/MWh. + Data is loaded on first access and cached for all tracker instances. + """ + _ensure_static_data_loaded() + return _CACHE["water_consumption_per_source"] + def get_cpu_power_data(self) -> pd.DataFrame: """ Returns CPU power Data. diff --git a/docs/explanation/methodology.md b/docs/explanation/methodology.md index fb51122d0..3ba996d97 100644 --- a/docs/explanation/methodology.md +++ b/docs/explanation/methodology.md @@ -78,6 +78,62 @@ As you can see, we try to be as accurate as possible in estimating carbon intensity of electricity. Still there is room for improvement and all contributions are welcome. +## Water Consumption + +Computing also consumes water, and CodeCarbon reports it in the +`water_consumed` field (in litres). Two contributions are summed: + +- **Direct water**: the water evaporated on-site by the data center + cooling systems. It is computed from the WUE (Water Usage + Effectiveness, in L/kWh) that you can provide with the `wue` + parameter, as `wue × energy_consumed`. It defaults to 0 because + a machine outside of a data center consumes no cooling water. +- **Indirect water**: the water consumed by power plants to generate + the electricity that you use (cooling of thermal and nuclear plants, + evaporation from hydroelectric reservoirs...). Like the carbon + intensity, it is estimated as a weighted average of the water + consumption of the energy sources in the local Energy Mix: + +| Energy Source | Water Consumption (gal US/MWh) | +|---------------|-------------------------------| +| Coal | 550 | +| Natural Gas | 210 | +| Oil | 550 (approximated with the coal value) | +| Nuclear | 775 | +| Hydroelectricity | 4,491 | +| Biofuel | 553 | +| Geothermal | 290 | +| Solar | 85 | +| Wind | 11 | + +*Operational water consumption across energy sources +([data](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/water_consumption_per_source.json))* + +Sources: + +- [Meldrum et al. 2013, Life cycle water use for electricity generation](https://iopscience.iop.org/article/10.1088/1748-9326/8/1/015031) +- [Macknick et al. 2012, Operational water consumption and withdrawal factors for electricity generating technologies](https://iopscience.iop.org/article/10.1088/1748-9326/7/4/045802) + +When the energy mix of a country is unknown, or when sources with known +water consumption cover less than 90% of its electricity production +(e.g. geothermal-heavy countries such as Kenya or Iceland), we apply a +world average of about 3.75 L/kWh, computed from the table above +weighted by the world energy mix so that the fallback is consistent +with the computed country values. + +!!! note "Uncertainty" + Water intensity values are much more uncertain than carbon + intensity values: they depend heavily on the cooling technology of + each plant, and the hydroelectricity value (reservoir evaporation) + is highly site-dependent and debated in the literature. Treat + `water_consumed` as an order-of-magnitude estimate. For example, a + hydro-heavy country like Norway gets a water intensity of about + 15 L/kWh, while its carbon intensity is among the lowest. + +Cloud providers do not publish water usage data per region, so on cloud +the water intensity of the country hosting your machine (or the world +average) is used. + ## Power Usage Power supply to the underlying hardware is tracked at frequent time diff --git a/docs/how-to/examples.md b/docs/how-to/examples.md index 06f084aca..6ca8b7a45 100644 --- a/docs/how-to/examples.md +++ b/docs/how-to/examples.md @@ -75,6 +75,7 @@ The directory [examples/](https://github.com/mlco2/codecarbon/tree/master/exampl |---------|------|-------------| | [pue.py](https://github.com/mlco2/codecarbon/blob/master/examples/pue.py) | Python Script | Calculate Power Usage Effectiveness (PUE) with CodeCarbon | | [wue.py](https://github.com/mlco2/codecarbon/blob/master/examples/wue.py) | Python Script | Calculate Water Usage Effectiveness (WUE) of your computing | +| [water_consumption.py](https://github.com/mlco2/codecarbon/blob/master/examples/water_consumption.py) | Python Script | Estimate the water consumed by your computing (direct WUE + electricity generation) | ## Interactive Notebooks diff --git a/docs/reference/output.md b/docs/reference/output.md index ddc5881a8..8608e64bd 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -43,6 +43,7 @@ The package has an in-built logger that logs data into a CSV file named `emissio | gpu_energy | Energy used per GPU (kWh) | | ram_energy | Energy used per RAM (kWh) | | energy_consumed | Sum of cpu_energy, gpu_energy and ram_energy (kWh) | +| water_consumed | Water consumed (L): direct data center cooling water (`wue` × energy) plus the water consumed to generate the electricity, estimated from the local energy mix. See [Methodology](../explanation/methodology.md#water-consumption) | | country_name | Name of the country where the infrastructure is hosted | | country_iso_code | 3-letter alphabet ISO Code of the respective country | | region | Province/State/City where the compute infrastructure is hosted | diff --git a/examples/water_consumption.py b/examples/water_consumption.py new file mode 100644 index 000000000..58c409653 --- /dev/null +++ b/examples/water_consumption.py @@ -0,0 +1,47 @@ +""" +Demonstrates the water consumption estimation. + +CodeCarbon reports in `water_consumed` (litres) the sum of: +- the direct cooling water of the data center (`wue` parameter, L/kWh), +- the indirect water consumed to generate the electricity, estimated + from the energy mix of the country where the machine runs. + +Run it from the repo root: + + python examples/water_consumption.py +""" + +import time + +from codecarbon import EmissionsTracker + + +def cpu_load(seconds: float) -> None: + end = time.time() + seconds + x = 0 + while time.time() < end: + x = (x + 1) % 1_000_000 + + +def main() -> None: + # Set wue to the Water Usage Effectiveness of your data center to also + # count its cooling water; leave it at 0 outside a data center. + tracker = EmissionsTracker(save_to_file=False, wue=0) + + tracker.start() + try: + cpu_load(5) + finally: + emissions = tracker.stop() + + data = tracker.final_emissions_data + print(f"emissions: {emissions * 1000:.6f} g CO2eq") + print(f"energy_consumed: {data.energy_consumed:.6f} kWh") + print(f"water_consumed: {data.water_consumed:.6f} L") + if data.energy_consumed: + ratio = data.water_consumed / data.energy_consumed + print(f"water intensity: {ratio:.2f} L/kWh in {data.country_name}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 795be29b1..d04c78938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ codecarbon = [ "data/canada_provinces.geojson", "data/private_infra/global_energy_mix.json", "data/private_infra/carbon_intensity_per_source.json", + "data/private_infra/water_consumption_per_source.json", "data/private_infra/nordic_emissions.json", "data/private_infra/2016/usa_emissions.json", "data/private_infra/2023/canada_energy_mix.json", diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 8bb4d66f4..6016b1db1 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -386,6 +386,94 @@ def stop(self): assert calls["kwargs"]["region"] == "IDF" +def _fake_offline_monitor(monkeypatch, tmp_path): + """Patch the offline tracker and run the monitor loop in `tmp_path`.""" + calls = {} + + class FakeOfflineTracker: + def __init__(self, **kwargs): + calls["kwargs"] = kwargs + self._another_instance_already_running = True + + def start(self): + pass + + def stop(self): + return None + + monkeypatch.setattr( + "codecarbon.emissions_tracker.OfflineEmissionsTracker", FakeOfflineTracker + ) + monkeypatch.setattr(cli_main.signal, "signal", lambda *args, **kwargs: None) + # Isolate from any config file of the user running the tests + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + return calls + + +def test_monitor_does_not_override_config_with_cli_defaults(monkeypatch, tmp_path): + """Options left at their default must not shadow the config file.""" + calls = _fake_offline_monitor(monkeypatch, tmp_path) + (tmp_path / ".codecarbon.config").write_text( + "[codecarbon]\n" + "log_level = DEBUG\n" + "measure_power_secs = 30\n" + "api_call_interval = 10\n" + "country_iso_code = FRA\n" + ) + + runner = CliRunner() + result = runner.invoke(cli_main.codecarbon, ["monitor", "--offline"]) + + assert result.exit_code == 0 + # Nothing is forwarded: the tracker reads those values from the config itself + for name in ("log_level", "measure_power_secs", "api_call_interval"): + assert name not in calls["kwargs"] + + +def test_monitor_cli_options_win_over_config(monkeypatch, tmp_path): + calls = _fake_offline_monitor(monkeypatch, tmp_path) + (tmp_path / ".codecarbon.config").write_text( + "[codecarbon]\nlog_level = DEBUG\nmeasure_power_secs = 30\n" + "country_iso_code = FRA\n" + ) + + runner = CliRunner() + result = runner.invoke( + cli_main.codecarbon, + ["monitor", "--offline", "--log-level", "warning", "--measure-power-secs", "5"], + ) + + assert result.exit_code == 0 + assert calls["kwargs"]["log_level"] == "warning" + assert calls["kwargs"]["measure_power_secs"] == 5 + + +def test_monitor_stays_quiet_without_configured_log_level(monkeypatch, tmp_path): + calls = _fake_offline_monitor(monkeypatch, tmp_path) + + runner = CliRunner() + result = runner.invoke( + cli_main.codecarbon, ["monitor", "--offline", "--country-iso-code", "FRA"] + ) + + assert result.exit_code == 0 + assert calls["kwargs"]["log_level"] == "error" + + +def test_monitor_offline_accepts_country_iso_code_from_config(monkeypatch, tmp_path): + calls = _fake_offline_monitor(monkeypatch, tmp_path) + (tmp_path / ".codecarbon.config").write_text( + "[codecarbon]\ncountry_iso_code = FRA\n" + ) + + runner = CliRunner() + result = runner.invoke(cli_main.codecarbon, ["monitor", "--offline"]) + + assert result.exit_code == 0 + assert "country_iso_code" not in calls["kwargs"] + + def test_monitor_delegates_offline_flag_to_run_and_monitor(monkeypatch): captured = {} diff --git a/tests/cli/test_monitor.py b/tests/cli/test_monitor.py index 0a9bda365..f3fa4852c 100644 --- a/tests/cli/test_monitor.py +++ b/tests/cli/test_monitor.py @@ -150,6 +150,54 @@ def wait(self): assert captured["kwargs"]["save_to_api"] is True +def _run_and_monitor_capturing(monkeypatch, **kwargs): + """Run `run_and_monitor` on a dummy command, capturing what it does.""" + captured = {"levels": []} + + class FakeCapturingTracker(FakeTracker): + def __init__(self, **tracker_kwargs): + captured["kwargs"] = tracker_kwargs + super().__init__() + + class FakePopen: + def __init__(self, command, text=True): + pass + + def wait(self): + return 0 + + _patch_trackers( + monkeypatch, online_cls=FakeCapturingTracker, offline_cls=FakeCapturingTracker + ) + monkeypatch.setattr(monitor_module.subprocess, "Popen", FakePopen) + monkeypatch.setattr(monitor_module, "print", lambda *args, **kwargs: None) + monkeypatch.setattr( + "codecarbon.external.logger.set_logger_level", + lambda level: captured["levels"].append(level), + ) + + with pytest.raises(typer.Exit) as exc_info: + monitor_module.run_and_monitor(SimpleNamespace(args=["echo", "hi"]), **kwargs) + + assert exc_info.value.exit_code == 0 + return captured + + +def test_run_and_monitor_leaves_log_level_to_the_config_by_default(monkeypatch): + """No log level given: the tracker resolves it from the config, not from us.""" + captured = _run_and_monitor_capturing(monkeypatch) + + assert captured["levels"] == [] + assert "log_level" not in captured["kwargs"] + + +def test_run_and_monitor_applies_given_log_level(monkeypatch): + captured = _run_and_monitor_capturing(monkeypatch, log_level="debug") + + assert captured["levels"] == ["debug"] + assert captured["kwargs"]["log_level"] == "debug" + + def test_run_and_monitor_handles_keyboard_interrupt(monkeypatch): process_info = {"terminated": 0, "killed": 0} diff --git a/tests/test_water_consumption.py b/tests/test_water_consumption.py new file mode 100644 index 000000000..d8aac410f --- /dev/null +++ b/tests/test_water_consumption.py @@ -0,0 +1,187 @@ +import unittest +import unittest.mock + +from codecarbon.core.units import Energy, WaterPerKWh +from codecarbon.core.water_consumption import WaterConsumption +from codecarbon.external.geography import CloudMetadata, GeoMetadata +from codecarbon.input import DataSource +from tests.testutils import get_test_data_source + +# World average water intensity of electricity, in L/kWh +# 991.66 gal us/MWh * 3.785411784 L/gal / 1000 +WORLD_AVERAGE_L_PER_KWH = 3.75 + + +class TestWaterPerKWh(unittest.TestCase): + def test_from_gal_us_per_MWh(self): + water = WaterPerKWh.from_gal_us_per_MWh(1000) + self.assertAlmostEqual(water.l_per_kWh, 3.785411784, places=6) + + def test_l_per_kWh(self): + self.assertEqual(WaterPerKWh(l_per_kWh=1.5).l_per_kWh, 1.5) + + +class TestWaterConsumption(unittest.TestCase): + def setUp(self) -> None: + # GIVEN + self._data_source = get_test_data_source() + self._water = WaterConsumption(self._data_source) + + def test_water_consumption_per_source_data(self): + water_per_source = DataSource().get_water_consumption_per_source_data() + # The keys must match the source names of global_energy_mix.json + # (without the _TWh suffix) for the energy mix computation to work. + for source in [ + "coal", + "gas", + "oil", + "nuclear", + "hydroelectricity", + "biofuel", + "solar", + "wind", + "world_average", + ]: + self.assertIn(source, water_per_source) + self.assertGreater(water_per_source[source], 0) + + def test_water_consumption_covers_energy_mix(self): + """ + The water consumption sources must cover most of the energy mix of + most countries, else everything falls back to the world average. + """ + water_per_source = DataSource().get_water_consumption_per_source_data() + energy_mix = self._data_source.get_global_energy_mix_data() + low_coverage_countries = [] + for iso, mix in energy_mix.items(): + if not isinstance(mix, dict) or not mix.get("total_TWh"): + continue + covered = sum( + energy or 0 + for source, energy in mix.items() + if source.endswith("_TWh") + and source[: -len("_TWh")] in water_per_source + ) + if covered / mix["total_TWh"] < WaterConsumption.MIN_ENERGY_MIX_COVERAGE: + low_coverage_countries.append(iso) + # A few geothermal-heavy countries (e.g. Kenya, Iceland) are expected + # to fall back to the world average. + self.assertLess(len(low_coverage_countries), 10) + + def test_get_water_consumption_PRIVATE_INFRA_FRA(self): + # WHEN + water = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata(country_iso_code="FRA", country_name="France"), + ) + # THEN: nuclear and hydro heavy mix, well above the world average + self.assertIsInstance(water, float) + self.assertGreater(water, 2) + self.assertLess(water, 6) + + def test_get_water_consumption_PRIVATE_INFRA_POL(self): + # WHEN: Poland has a coal-heavy mix + water = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata(country_iso_code="POL", country_name="Poland"), + ) + # THEN: close to the coal intensity (550 gal us/MWh ~ 2.08 L/kWh) + self.assertGreater(water, 1) + self.assertLess(water, 3) + + def test_water_consumption_scales_with_energy(self): + geo = GeoMetadata(country_iso_code="FRA", country_name="France") + water_1 = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), geo + ) + water_10 = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=10), geo + ) + self.assertAlmostEqual(water_10, 10 * water_1, places=6) + + def test_get_water_consumption_PRIVATE_INFRA_unknown_country(self): + # WHEN + water = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata(country_iso_code="XXX", country_name="Atlantis"), + ) + # THEN: world average + self.assertAlmostEqual(water, WORLD_AVERAGE_L_PER_KWH, places=2) + + def test_get_water_consumption_PRIVATE_INFRA_low_coverage_country(self): + # WHEN: more than 10% of Kenya's electricity comes from geothermal, + # which is not a source of global_energy_mix.json + water = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata(country_iso_code="KEN", country_name="Kenya"), + ) + # THEN: world average fallback + self.assertAlmostEqual(water, WORLD_AVERAGE_L_PER_KWH, places=2) + + def test_get_water_consumption_PRIVATE_INFRA_CANADA_region(self): + # WHEN: hydro-heavy Canadian region + water = self._water.get_region_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata(country_iso_code="CAN", country_name="Canada", region="quebec"), + ) + # THEN: dominated by the hydro intensity (4491 gal us/MWh ~ 17 L/kWh) + self.assertGreater(water, 10) + self.assertLess(water, 20) + + def test_get_water_consumption_PRIVATE_INFRA_USA_region_falls_back(self): + # WHEN: there is no regional energy mix data for the USA (only + # regional emissions data), so the country value must be used + water_region = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata( + country_iso_code="USA", + country_name="United States", + region="california", + ), + ) + water_country = self._water.get_country_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata(country_iso_code="USA", country_name="United States"), + ) + self.assertAlmostEqual(water_region, water_country, places=6) + + def test_water_intensity_is_cached_per_location(self): + # WHEN: computing twice for the same location + geo = GeoMetadata(country_iso_code="FRA", country_name="France") + self._water.get_private_infra_water_consumption(Energy.from_energy(kWh=1), geo) + # THEN: the energy mix is not read again, the tracker calls this on + # every measurement cycle + with unittest.mock.patch.object( + self._data_source, "get_global_energy_mix_data" + ) as mocked_energy_mix: + self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), geo + ) + mocked_energy_mix.assert_not_called() + + def test_get_water_consumption_CLOUD_with_geo_fallback(self): + # WHEN: no cloud water data exists, so the country of the machine + # is used + water = self._water.get_cloud_water_consumption( + Energy.from_energy(kWh=1), + CloudMetadata(provider="aws", region="eu-west-1"), + GeoMetadata(country_iso_code="FRA", country_name="France"), + ) + water_country = self._water.get_private_infra_water_consumption( + Energy.from_energy(kWh=1), + GeoMetadata(country_iso_code="FRA", country_name="France"), + ) + self.assertAlmostEqual(water, water_country, places=6) + + def test_get_water_consumption_CLOUD_without_geo(self): + # WHEN + water = self._water.get_cloud_water_consumption( + Energy.from_energy(kWh=1), + CloudMetadata(provider="aws", region="eu-west-1"), + ) + # THEN: world average + self.assertAlmostEqual(water, WORLD_AVERAGE_L_PER_KWH, places=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/webapp/src/api/mock/data.ts b/webapp/src/api/mock/data.ts index 0d6f0a748..6d91d3492 100644 --- a/webapp/src/api/mock/data.ts +++ b/webapp/src/api/mock/data.ts @@ -148,6 +148,7 @@ function makeExperimentReport(args: { description: args.description, emissions: args.emissions, energy_consumed: args.energyConsumed, + water_consumed: args.energyConsumed * 2.4, duration: args.durationSeconds, }; } @@ -195,6 +196,7 @@ function makeEmissionSeries(args: { gpu_energy: 0.3 + i * 0.02, ram_energy: 0.05, energy_consumed: 0.45 + i * 0.03, + water_consumed: (0.45 + i * 0.03) * 2.4, }; }); } @@ -221,6 +223,7 @@ export interface MockRunRow { emissions: number; timestamp: string; energy_consumed: number; + water_consumed: number; duration: number; } @@ -238,6 +241,7 @@ function makeRunRow(args: { emissions: args.emissions, timestamp: args.timestamp, energy_consumed: args.energyConsumed, + water_consumed: args.energyConsumed * 2.4, duration: args.durationSeconds, }; } @@ -268,6 +272,7 @@ const organizationReport: OrganizationReport = { name: organization.name, emissions: 1.801, energy_consumed: 8.023, + water_consumed: 19.25, duration: 5400, }; diff --git a/webapp/src/api/mock/handlers.ts b/webapp/src/api/mock/handlers.ts index f2d49b18f..425665e57 100644 --- a/webapp/src/api/mock/handlers.ts +++ b/webapp/src/api/mock/handlers.ts @@ -159,6 +159,7 @@ const handlers: Handler[] = [ gpu_energy: e.gpu_energy, ram_energy: e.ram_energy, energy_consumed: e.energy_consumed, + water_consumed: e.water_consumed, }), ); return ok({ items }); diff --git a/webapp/src/api/organizations.ts b/webapp/src/api/organizations.ts index ede313dee..a965739e0 100644 --- a/webapp/src/api/organizations.ts +++ b/webapp/src/api/organizations.ts @@ -21,7 +21,13 @@ export async function getOrganizationEmissionsByProject( return await fetchApi(endpoint, OrganizationReportSchema); } catch (error) { console.error("[getOrganizationEmissionsByProject] failed", error); - return { name: "", emissions: 0, energy_consumed: 0, duration: 0 }; + return { + name: "", + emissions: 0, + energy_consumed: 0, + water_consumed: 0, + duration: 0, + }; } } diff --git a/webapp/src/api/runs.ts b/webapp/src/api/runs.ts index 2f44eddcb..df2e6e65b 100644 --- a/webapp/src/api/runs.ts +++ b/webapp/src/api/runs.ts @@ -37,6 +37,7 @@ export async function getRunEmissionsByExperiment( emissions: z.number(), timestamp: z.string(), energy_consumed: z.number(), + water_consumed: z.number().default(0), duration: z.number(), }), ), @@ -47,6 +48,7 @@ export async function getRunEmissionsByExperiment( emissions: runReport.emissions, timestamp: runReport.timestamp, energy_consumed: runReport.energy_consumed, + water_consumed: runReport.water_consumed, duration: runReport.duration, })); } catch (error) { @@ -77,6 +79,7 @@ export async function getEmissionsTimeSeries( gpu_energy: z.number(), ram_energy: z.number(), energy_consumed: z.number(), + water_consumed: z.number().default(0), }), ), }), diff --git a/webapp/src/api/schemas.ts b/webapp/src/api/schemas.ts index f7fe8772d..953615a4a 100644 --- a/webapp/src/api/schemas.ts +++ b/webapp/src/api/schemas.ts @@ -95,6 +95,7 @@ export const ExperimentReportSchema = z.object({ name: z.string(), emissions: z.number(), energy_consumed: z.number(), + water_consumed: z.number().default(0), duration: z.number(), description: z.string().nullish(), }); @@ -105,6 +106,7 @@ export const RunReportSchema = z.object({ emissions: z.number(), timestamp: z.string(), energy_consumed: z.number(), + water_consumed: z.number().default(0), duration: z.number(), }); export type RunReport = z.infer; @@ -121,6 +123,7 @@ export const EmissionSchema = z.object({ gpu_energy: z.number(), ram_energy: z.number(), energy_consumed: z.number(), + water_consumed: z.number().default(0), }); export type Emission = z.infer; @@ -147,6 +150,7 @@ export const OrganizationReportSchema = z.object({ name: z.string(), emissions: z.number(), energy_consumed: z.number(), + water_consumed: z.number().default(0), duration: z.number(), }); export type OrganizationReport = z.infer; @@ -161,6 +165,7 @@ export type EmissionsTimeSeries = z.infer; // Dashboard prop types (not API responses, but shared across components) export interface RadialChartData { energy: { label: string; value: number }; + water: { label: string; value: number }; emissions: { label: string; value: number }; duration: { label: string; value: number }; } diff --git a/webapp/src/components/project-dashboard-base.tsx b/webapp/src/components/project-dashboard-base.tsx index d26e19272..9dc1a3f2a 100644 --- a/webapp/src/components/project-dashboard-base.tsx +++ b/webapp/src/components/project-dashboard-base.tsx @@ -106,6 +106,14 @@ export default function ProjectDashboardBase({ } }; + const radialFallback = ( + + + + + + ); + return (
@@ -128,7 +136,7 @@ export default function ProjectDashboardBase({ />
-
+
{isLoading ? ( <> @@ -200,63 +208,36 @@ export default function ProjectDashboardBase({
{isLoading ? ( - - - - - + radialFallback ) : ( - - - - - - } - > + )}
{isLoading ? ( - - - - - + radialFallback ) : ( - - - - - - } - > + + + + )} +
+
+ {isLoading ? ( + radialFallback + ) : ( + )}
{isLoading ? ( - - - - - + radialFallback ) : ( - - - - - - } - > + )} diff --git a/webapp/src/components/project-dashboard.tsx b/webapp/src/components/project-dashboard.tsx index c5714f3b7..15cc98ff1 100644 --- a/webapp/src/components/project-dashboard.tsx +++ b/webapp/src/components/project-dashboard.tsx @@ -98,6 +98,7 @@ export default function ProjectDashboard({ name: exp.name, emissions: exp.emissions, energy_consumed: exp.energy_consumed, + water_consumed: exp.water_consumed, duration: exp.duration, runs: runsWithDetails, }; diff --git a/webapp/src/helpers/dashboard-calculations.ts b/webapp/src/helpers/dashboard-calculations.ts index 2668f4823..065eeeee0 100644 --- a/webapp/src/helpers/dashboard-calculations.ts +++ b/webapp/src/helpers/dashboard-calculations.ts @@ -1,4 +1,8 @@ -import { ExperimentReport } from "@/api/schemas"; +import { + ConvertedValues, + ExperimentReport, + RadialChartData, +} from "@/api/schemas"; import { getEquivalentCarKm, getEquivalentCitizenPercentage, @@ -6,17 +10,7 @@ import { } from "./constants"; import { SECONDS_PER_DAY } from "./time-constants"; -export type RadialChartData = { - energy: { label: string; value: number }; - emissions: { label: string; value: number }; - duration: { label: string; value: number }; -}; - -export type ConvertedValues = { - citizen: string; - transportation: string; - tvTime: string; -}; +export type { ConvertedValues, RadialChartData }; /** * Calculate radial chart data from experiment reports @@ -33,6 +27,14 @@ export function calculateRadialChartData( .toFixed(2), ), }, + water: { + label: "L", + value: parseFloat( + report + .reduce((n, { water_consumed }) => n + water_consumed, 0) + .toFixed(2), + ), + }, emissions: { label: "kg eq CO2", value: parseFloat( @@ -78,6 +80,7 @@ export function calculateConvertedValues( export function getDefaultRadialChartData(): RadialChartData { return { energy: { label: "kWh", value: 0 }, + water: { label: "L", value: 0 }, emissions: { label: "kg eq CO2", value: 0 }, duration: { label: "days", value: 0 }, }; diff --git a/webapp/src/pages/OrgDashboardPage.tsx b/webapp/src/pages/OrgDashboardPage.tsx index dd3c7f607..dbfee72fb 100644 --- a/webapp/src/pages/OrgDashboardPage.tsx +++ b/webapp/src/pages/OrgDashboardPage.tsx @@ -35,7 +35,13 @@ export default function OrgDashboardPage() { }); const [organizationReport, setOrganizationReport] = useState< OrganizationReport | undefined - >({ name: "", duration: 0, emissions: 0, energy_consumed: 0 }); + >({ + name: "", + duration: 0, + emissions: 0, + energy_consumed: 0, + water_consumed: 0, + }); useEffect(() => { async function fetchOrganizationReport() { @@ -69,6 +75,12 @@ export default function OrgDashboardPage() { ? parseFloat(organizationReport.energy_consumed.toFixed(2)) : 0, }, + water: { + label: "L", + value: organizationReport?.water_consumed + ? parseFloat(organizationReport.water_consumed.toFixed(2)) + : 0, + }, emissions: { label: "kg eq CO2", value: organizationReport?.emissions @@ -160,10 +172,13 @@ export default function OrgDashboardPage() {

-
+
+ + + diff --git a/webapp/src/utils/export.ts b/webapp/src/utils/export.ts index 176e9a7e8..d5c5ce784 100644 --- a/webapp/src/utils/export.ts +++ b/webapp/src/utils/export.ts @@ -62,12 +62,14 @@ export function exportExperimentsToCsv( const csvRows: string[] = []; // Add header row with exact field names - csvRows.push("experiment_id,name,emissions,energy_consumed,duration"); + csvRows.push( + "experiment_id,name,emissions,energy_consumed,water_consumed,duration", + ); if (experiments && experiments.length > 0) { experiments.forEach((exp) => { csvRows.push( - `${exp.experiment_id},${exp.name},${exp.emissions},${exp.energy_consumed},${exp.duration}`, + `${exp.experiment_id},${exp.name},${exp.emissions},${exp.energy_consumed},${exp.water_consumed},${exp.duration}`, ); }); } @@ -104,13 +106,13 @@ export async function exportRunsToCsv( // Extended header row with metadata fields csvRows.push( - "runId,timestamp,emissions,energy_consumed,duration,os,python_version,codecarbon_version,cpu_count,cpu_model,gpu_count,gpu_model,region,provider,ram_total_size,tracking_mode", + "runId,timestamp,emissions,energy_consumed,water_consumed,duration,os,python_version,codecarbon_version,cpu_count,cpu_model,gpu_count,gpu_model,region,provider,ram_total_size,tracking_mode", ); if (runs && runs.length > 0) { runs.forEach((run) => { const metadata = metadataMap.get(run.runId); - let row = `${run.runId},${run.timestamp},${run.emissions},${run.energy_consumed},${run.duration}`; + let row = `${run.runId},${run.timestamp},${run.emissions},${run.energy_consumed},${run.water_consumed},${run.duration}`; // Add metadata fields if available if (metadata) { @@ -149,13 +151,13 @@ export function exportEmissionsTimeSeriesCsv( // Add emissions data header and rows csvRows.push( - "timestamp,emissions_sum,emissions_rate,cpu_power,gpu_power,ram_power,cpu_energy,gpu_energy,ram_energy,energy_consumed", + "timestamp,emissions_sum,emissions_rate,cpu_power,gpu_power,ram_power,cpu_energy,gpu_energy,ram_energy,energy_consumed,water_consumed", ); if (timeSeries.emissions && timeSeries.emissions.length > 0) { timeSeries.emissions.forEach((emission) => { csvRows.push( - `${emission.timestamp},${emission.emissions_sum},${emission.emissions_rate},${emission.cpu_power},${emission.gpu_power},${emission.ram_power},${emission.cpu_energy},${emission.gpu_energy},${emission.ram_energy},${emission.energy_consumed}`, + `${emission.timestamp},${emission.emissions_sum},${emission.emissions_rate},${emission.cpu_power},${emission.gpu_power},${emission.ram_power},${emission.cpu_energy},${emission.gpu_energy},${emission.ram_energy},${emission.energy_consumed},${emission.water_consumed}`, ); }); } diff --git a/webapp/tests/components/project-dashboard-base.test.tsx b/webapp/tests/components/project-dashboard-base.test.tsx index 4e8bf6a95..ae538ec14 100644 --- a/webapp/tests/components/project-dashboard-base.test.tsx +++ b/webapp/tests/components/project-dashboard-base.test.tsx @@ -45,6 +45,7 @@ const baseProps = { onDateChange: vi.fn(), radialChartData: { energy: { label: "kWh", value: 0 }, + water: { label: "L", value: 0 }, emissions: { label: "kg eq CO2", value: 0 }, duration: { label: "days", value: 0 }, },