From eb2cfef111ab21ac25ce5d2b665d94ad0a3ebb56 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Tue, 18 Aug 2026 16:57:21 +0200 Subject: [PATCH 1/4] fix: keep null coordinates when creating a run (#1330) `round(self.conf.get("longitude", 0), 1)` crashed with a TypeError whenever the key was present but None, which is the case for the offline tracker. The API output method then never created a run and silently dropped every emission. Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: inimaz <49730431+inimaz@users.noreply.github.com> --- codecarbon/core/api_client.py | 9 +++++++-- tests/test_api_call.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) 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/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) From 41e84fc29bcc3311df1d2f77590c1deefe27508d Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 17:25:18 +0200 Subject: [PATCH 2/4] fix(powermetrics): report 0 W when no samples np.mean([]) returns NaN without raising, so an empty powermetrics log (missing sudoers rule, or a sampler that emits no GPU Power lines) fed NaN into every tracker accumulator, making energy, emissions and emissions_rate NaN for the rest of the run. Closes #1306 Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/core/powermetrics.py | 40 ++++++++++++++------------------- tests/test_powermetrics.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index bffc19ce4..62cea9e75 100644 --- a/codecarbon/core/powermetrics.py +++ b/codecarbon/core/powermetrics.py @@ -175,29 +175,23 @@ 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: + # np.mean([]) is NaN, and NaN poisons every downstream total, + # so report 0 W instead and make the situation visible. + logger.warning( + f"Powermetrics returned no '{chip_part} Power' sample in " + + f"{self._log_file_path}, reporting 0 W." + ) + 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"] = np.mean(watts) + details[f"{chip_part} Energy Delta"] = np.sum( + [(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_powermetrics.py b/tests/test_powermetrics.py index b20f5df2c..cd1a6ca09 100644 --- a/tests/test_powermetrics.py +++ b/tests/test_powermetrics.py @@ -73,6 +73,41 @@ def test_get_details(self, mock_setup, mock_log_values): assert cpu_details == expected_details + @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 + def test_is_powermetrics_available_returns_false_on_instantiation_error(self): from codecarbon.core.powermetrics import clear_powermetrics_cache From a7a66773f2adb2436a078703e594bb154aded29a Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 13 Aug 2026 08:33:05 +0200 Subject: [PATCH 3/4] fix: warn once when powermetrics returns no power samples get_details() runs on every measurement cycle, so a machine whose cpu_power sampler never emits "GPU Power:" lines logged a warning at every interval for the whole run. Warn once per chip part instead, matching the warn-once guards used in the RAPL and GPU fallbacks. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/core/powermetrics.py | 13 +++++++++---- tests/test_powermetrics.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index 62cea9e75..007b9fc3b 100644 --- a/codecarbon/core/powermetrics.py +++ b/codecarbon/core/powermetrics.py @@ -112,6 +112,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: @@ -180,10 +181,14 @@ def get_details(self) -> Dict: if not power_list: # np.mean([]) is NaN, and NaN poisons every downstream total, # so report 0 W instead and make the situation visible. - logger.warning( - f"Powermetrics returned no '{chip_part} Power' sample in " - + f"{self._log_file_path}, reporting 0 W." - ) + # 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 diff --git a/tests/test_powermetrics.py b/tests/test_powermetrics.py index cd1a6ca09..efd207313 100644 --- a/tests/test_powermetrics.py +++ b/tests/test_powermetrics.py @@ -108,6 +108,24 @@ def test_get_details_without_gpu_samples( 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 From ae1d50d8631dac10e088c0172bd5d5b995bb2dc7 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Sun, 16 Aug 2026 10:27:10 +0200 Subject: [PATCH 4/4] perf(powermetrics): drop numpy for statistics.fmean/math.fsum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numpy was never a declared dependency — it arrived transitively through pandas — and `import numpy as np` at the top of core/powermetrics.py cost ~20 ms of the ~78 ms bare `import codecarbon`. The only uses were np.mean/np.sum over a short list of floats, which statistics.fmean and math.fsum do exactly (and exactly rounded). The get_details() assertion is now tolerance-based: it was hardcoding numpy's pairwise summation order in the last ulp, not correctness. The pandas removal that this branch previously also carried is split out to scaling/03b-drop-pandas, where its 14-file reimplementation of read_csv/dropna semantics can be reviewed on its own. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/core/powermetrics.py | 17 ++++++++--------- tests/test_powermetrics.py | 6 +++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index 007b9fc3b..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 @@ -166,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 @@ -179,8 +178,8 @@ def get_details(self) -> Dict: for chip_part in ("CPU", "GPU"): power_list = re.findall(rf"{chip_part} Power: (\d+) mW", logfile) if not power_list: - # np.mean([]) is NaN, and NaN poisons every downstream total, - # so report 0 W instead and make the situation visible. + # 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: @@ -193,9 +192,9 @@ def get_details(self) -> Dict: details[f"{chip_part} Energy Delta"] = 0.0 continue watts = [float(power) / 1000 for power in power_list] - details[f"{chip_part} Power"] = np.mean(watts) - details[f"{chip_part} Energy Delta"] = np.sum( - [(self._interval / 1000) * watt for watt in watts] + 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( diff --git a/tests/test_powermetrics.py b/tests/test_powermetrics.py index efd207313..d1b331576 100644 --- a/tests/test_powermetrics.py +++ b/tests/test_powermetrics.py @@ -71,7 +71,11 @@ 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")