diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index eaef94a53..bc2e0974e 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -22,6 +22,11 @@ from codecarbon.external.logger import logger +def _round_or_none(value: float | None) -> float | None: + """Round a coordinate, keeping None when it is unknown.""" + return None if value is None else round(value, 1) + + def get_datetime_with_timezone(): import arrow @@ -242,8 +247,8 @@ def _create_run(self, experiment_id: str): gpu_count=self.conf.get("gpu_count"), gpu_model=self.conf.get("gpu_model"), # Reduce precision for Privacy - longitude=round(self.conf.get("longitude", 0), 1), - latitude=round(self.conf.get("latitude", 0), 1), + longitude=_round_or_none(self.conf.get("longitude")), + latitude=_round_or_none(self.conf.get("latitude")), region=self.conf.get("region"), provider=self.conf.get("provider"), ram_total_size=self.conf.get("ram_total_size"), diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index bffc19ce4..9a506d2f1 100644 --- a/codecarbon/core/powermetrics.py +++ b/codecarbon/core/powermetrics.py @@ -1,13 +1,12 @@ +import math import os import re import shutil +import statistics import subprocess import sys import time from functools import lru_cache -from typing import Dict - -import numpy as np from codecarbon.core.util import detect_cpu_model from codecarbon.external.logger import logger @@ -112,6 +111,7 @@ def __init__( self._system = sys.platform.lower() self._n_points = n_points self._interval = interval + self._warned_missing_samples = set() self._setup_cli() def _setup_cli(self) -> None: @@ -165,7 +165,7 @@ def _log_values(self) -> None: ) return - def get_details(self) -> Dict: + def get_details(self) -> dict: """ Fetches the CPU Power Details by fetching values from a logged csv file in _log_values function @@ -175,29 +175,27 @@ def get_details(self) -> Dict: try: with open(self._log_file_path) as f: logfile = f.read() - cpu_pattern = r"CPU Power: (\d+) mW" - cpu_power_list = re.findall(cpu_pattern, logfile) - - details["CPU Power"] = np.mean( - [float(power) / 1000 for power in cpu_power_list] - ) - details["CPU Energy Delta"] = np.sum( - [ - (self._interval / 1000) * (float(power) / 1000) - for power in cpu_power_list - ] - ) - gpu_pattern = r"GPU Power: (\d+) mW" - gpu_power_list = re.findall(gpu_pattern, logfile) - details["GPU Power"] = np.mean( - [float(power) / 1000 for power in gpu_power_list] - ) - details["GPU Energy Delta"] = np.sum( - [ - (self._interval / 1000) * (float(power) / 1000) - for power in gpu_power_list - ] - ) + for chip_part in ("CPU", "GPU"): + power_list = re.findall(rf"{chip_part} Power: (\d+) mW", logfile) + if not power_list: + # An empty mean is NaN, and NaN poisons every downstream + # total, so report 0 W instead and make the situation visible. + # get_details() runs every measurement cycle, so warn only + # once per chip part to avoid flooding the log. + if chip_part not in self._warned_missing_samples: + self._warned_missing_samples.add(chip_part) + logger.warning( + f"Powermetrics returned no '{chip_part} Power' sample in " + + f"{self._log_file_path}, reporting 0 W (warned once)." + ) + details[f"{chip_part} Power"] = 0.0 + details[f"{chip_part} Energy Delta"] = 0.0 + continue + watts = [float(power) / 1000 for power in power_list] + details[f"{chip_part} Power"] = statistics.fmean(watts) + details[f"{chip_part} Energy Delta"] = math.fsum( + (self._interval / 1000) * watt for watt in watts + ) except Exception as e: logger.info( f"Unable to read Powermetrics logged file at {self._log_file_path}\n \ diff --git a/tests/test_api_call.py b/tests/test_api_call.py index d3b5bd96f..31e25c039 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -138,6 +138,39 @@ def test_call_api(self): assert payload["ram_utilization_percent"] == 56.5 assert payload["wue"] == 0.8 + def test_create_run_rounds_coordinates(self): + with requests_mock.Mocker() as m: + m.post("http://test.com/runs", json={"id": "run-1"}, status_code=201) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + + api._create_run("exp-1") + + payload = m.last_request.json() + self.assertEqual(payload["longitude"], -7.6) + self.assertEqual(payload["latitude"], 33.6) + + def test_create_run_keeps_unknown_coordinates_null(self): + offline_conf = dict(conf, longitude=None, latitude=None) + with requests_mock.Mocker() as m: + m.post("http://test.com/runs", json={"id": "run-1"}, status_code=201) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=offline_conf, + create_run_automatically=False, + ) + + self.assertEqual(api._create_run("exp-1"), "run-1") + + payload = m.last_request.json() + self.assertIsNone(payload["longitude"]) + self.assertIsNone(payload["latitude"]) + def test_check_auth_raises_on_error(self): with requests_mock.Mocker() as m: m.get("http://test.com/auth/check", text="bad", status_code=401) diff --git a/tests/test_powermetrics.py b/tests/test_powermetrics.py index b20f5df2c..d1b331576 100644 --- a/tests/test_powermetrics.py +++ b/tests/test_powermetrics.py @@ -71,7 +71,64 @@ def test_get_details(self, mock_setup, mock_log_values): ) cpu_details = powermetrics.get_details() - assert cpu_details == expected_details + # Tolerance rather than equality: these are float sums/means, and the + # exact last ulp depends on summation order, not on correctness. + assert sorted(cpu_details) == sorted(expected_details) + for key, expected in expected_details.items(): + assert cpu_details[key] == pytest.approx(expected) + + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._log_values") + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._setup_cli") + def test_get_details_without_samples(self, mock_setup, mock_log_values, tmp_path): + """An empty log must report 0 W, not NaN, which would poison all totals.""" + (tmp_path / "empty_powermetrics_log.txt").write_text("") + powermetrics = ApplePowermetrics( + output_dir=str(tmp_path), + log_file_name="empty_powermetrics_log.txt", + ) + + assert powermetrics.get_details() == { + "CPU Power": 0.0, + "CPU Energy Delta": 0.0, + "GPU Power": 0.0, + "GPU Energy Delta": 0.0, + } + + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._log_values") + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._setup_cli") + def test_get_details_without_gpu_samples( + self, mock_setup, mock_log_values, tmp_path + ): + """A log with no GPU line must report 0 W for the GPU, not NaN.""" + (tmp_path / "cpu_only_log.txt").write_text("CPU Power: 500 mW\n") + powermetrics = ApplePowermetrics( + output_dir=str(tmp_path), + log_file_name="cpu_only_log.txt", + ) + + details = powermetrics.get_details() + + assert details["CPU Power"] == 0.5 + assert details["GPU Power"] == 0.0 + assert details["GPU Energy Delta"] == 0.0 + + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._log_values") + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._setup_cli") + def test_missing_samples_warns_only_once( + self, mock_setup, mock_log_values, tmp_path + ): + """get_details() runs every cycle, so the warning must not flood the log.""" + (tmp_path / "cpu_only_log.txt").write_text("CPU Power: 500 mW\n") + powermetrics = ApplePowermetrics( + output_dir=str(tmp_path), + log_file_name="cpu_only_log.txt", + ) + + with mock.patch("codecarbon.core.powermetrics.logger.warning") as mock_warning: + for _ in range(3): + assert powermetrics.get_details()["GPU Power"] == 0.0 + + mock_warning.assert_called_once() def test_is_powermetrics_available_returns_false_on_instantiation_error(self): from codecarbon.core.powermetrics import clear_powermetrics_cache