diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index bffc19ce4..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: @@ -175,29 +176,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: + # np.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"] = 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..efd207313 100644 --- a/tests/test_powermetrics.py +++ b/tests/test_powermetrics.py @@ -73,6 +73,59 @@ 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 + + @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