diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..631e634b8 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -490,6 +490,65 @@ def detect(): f" BUT only tracking these GPU ids : {hardware_info['gpu_ids']}" ) print(f"- GPU model: {gpu_model_str}") + print("\nRun `codecarbon doctor` to see what is measured and what is estimated.") + + +@codecarbon.command("doctor", short_help="Report measurement quality per component.") +def doctor( + json_output: Annotated[ + bool, typer.Option("--json", help="Print the report as JSON.") + ] = False, + strict: Annotated[ + bool, + typer.Option( + "--strict", + help="Exit with code 1 if a component that could be measured is estimated.", + ), + ] = False, +): + """ + Tell you, for each power component, whether CodeCarbon measures it or + estimates it, why, and how to improve it. + """ + import json + + from codecarbon.diagnostics import diagnose, render_text, strict_failures, summary + from codecarbon.emissions_tracker import EmissionsTracker + from codecarbon.external.logger import logger, set_logger_level + + # The report is the output; the tracker's own start-up logs are noise here. + # The level has to be lowered before __init__, which logs before applying + # its own log_level, and restored so we do not reconfigure a caller's logger. + previous_level = logger.level + try: + set_logger_level("error") + # allow_multiple_runs: without it, a live run makes __init__ return early + # and leave the tracker half-built - exactly when someone runs `doctor`. + tracker = EmissionsTracker( + save_to_file=False, allow_multiple_runs=True, log_level="error" + ) + tracker._ensure_hardware_ready() + finally: + logger.setLevel(previous_level) + diagnostics = diagnose(tracker._hardware) + + if json_output: + typer.echo( + json.dumps( + { + "codecarbon_version": __version__, + "components": [d.as_dict() for d in diagnostics], + "summary": summary(diagnostics), + }, + indent=2, + ) + ) + else: + print(f"CodeCarbon {__version__} - measurement quality report\n") + print(render_text(diagnostics)) + + if strict and strict_failures(diagnostics): + raise typer.Exit(1) def questionary_prompt(prompt, list_options, default): diff --git a/codecarbon/diagnostics.py b/codecarbon/diagnostics.py new file mode 100644 index 000000000..2c830b649 --- /dev/null +++ b/codecarbon/diagnostics.py @@ -0,0 +1,264 @@ +""" +Measurement quality diagnostics. + +CodeCarbon always produces a number, but depending on the machine that number +may come from a hardware energy counter (RAPL, powermetrics, NVML, ...) or from +a model (CPU load over a TDP, RAM power estimation). This module inspects the +hardware objects the tracker built and reports, per component, whether the +reading is measured or estimated, why, and how to improve it. + +It adds no measurement code: it only reads the mode each hardware object landed +in and the availability checks the setup already runs. +""" + +import os +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Optional + +from rich.markup import escape + +from codecarbon.core import powermetrics, windows_emi +from codecarbon.core.util import is_linux_os, is_mac_os, is_windows_os +from codecarbon.external.hardware import CPU, GPU, AppleSiliconChip +from codecarbon.external.ram import RAM + +MEASURED = "measured" +ESTIMATED = "estimated" +UNAVAILABLE = "unavailable" + +RAPL_DOC = "https://docs.codecarbon.io/how-to/enable-rapl/" +METHODOLOGY_DOC = "https://docs.codecarbon.io/explanation/methodology/" + +DEFAULT_RAPL_ROOT = "/sys/class/powercap/intel-rapl" + +# CPU modes that read a hardware energy counter. +MEASURED_CPU_MODES = {"intel_rapl", "intel_power_gadget", "windows_emi"} + +# Components no platform exposes an energy counter for. They are ESTIMATED on +# every machine, so `--strict` must not fail on them, for the same reason an +# absent GPU is UNAVAILABLE rather than a failure: the user has nothing to fix. +ALWAYS_ESTIMATED_COMPONENTS = {"RAM"} + + +@dataclass +class ComponentDiagnostic: + """Measurement quality of a single power component.""" + + component: str # "CPU" | "RAM" | "GPU" + detail: str # model name / device list + status: str # MEASURED | ESTIMATED | UNAVAILABLE + method: str # "RAPL", "PowerMetrics", "CPU load model", ... + reason: Optional[str] = None # why a better method was not used + fix: Optional[str] = None # concrete command or doc link + + def as_dict(self) -> Dict[str, Any]: + return asdict(self) + + +def _rapl_reason() -> str: + """Tell 'no RAPL on this platform' apart from 'RAPL is there but root-only'.""" + if not os.path.exists(DEFAULT_RAPL_ROOT): + return ( + f"no RAPL interface at {DEFAULT_RAPL_ROOT} (not an Intel/AMD RAPL " + "platform, or a virtual machine that does not expose it)" + ) + for dirpath, _, filenames in os.walk(DEFAULT_RAPL_ROOT): + if "energy_uj" in filenames: + path = os.path.join(dirpath, "energy_uj") + if not os.access(path, os.R_OK): + return ( + f"{DEFAULT_RAPL_ROOT} exists but {path} is not readable by " + "this user (permission denied)" + ) + return ( + f"{DEFAULT_RAPL_ROOT} is readable but was not selected; run with " + "--log-level DEBUG to see why" + ) + return f"{DEFAULT_RAPL_ROOT} exists but exposes no energy counter" + + +def _rapl_fix() -> str: + if not os.path.exists(DEFAULT_RAPL_ROOT): + return f"none available on this platform; see {RAPL_DOC}" + return f"sudo chmod -R a+r {DEFAULT_RAPL_ROOT} (see {RAPL_DOC} to persist it)" + + +def _cpu_estimation_reason() -> str: + """Why no hardware CPU energy counter was used, for the current platform.""" + if is_linux_os(): + return _rapl_reason() + if is_mac_os(): + if not powermetrics.is_powermetrics_available(): + return ( + "powermetrics is not usable without a password: it needs a " + "passwordless sudo rule" + ) + return "powermetrics is available but was not selected" + if is_windows_os(): + if not windows_emi.is_emi_available(): + return ( + "the Windows Energy Meter Interface is not available; it " + "requires Windows 11 on bare metal (not a virtual machine)" + ) + return "the Windows Energy Meter Interface is available but was not selected" + return "no hardware energy counter is supported on this platform" + + +def _cpu_estimation_fix() -> Optional[str]: + if is_linux_os(): + return _rapl_fix() + if is_mac_os(): + return "allow passwordless sudo for powermetrics, see " f"{METHODOLOGY_DOC}#cpu" + return None + + +def _cpu_diagnostic(hw) -> ComponentDiagnostic: + mode = hw._mode + detail = hw._model or "unknown CPU" + if mode in MEASURED_CPU_MODES: + method = { + "intel_rapl": "RAPL", + "intel_power_gadget": "Intel Power Gadget", + "windows_emi": "Windows Energy Meter Interface", + }[mode] + return ComponentDiagnostic( + component="CPU", detail=detail, status=MEASURED, method=method + ) + + if mode == "constant": + method = f"constant {hw._tdp} W" + else: + method = f"CPU load model over a {hw._tdp} W TDP" + reason = _cpu_estimation_reason() + if hw._is_generic_tdp: + reason = ( + f"CPU model '{detail}' is not in the TDP registry, so a generic " + f"{hw._tdp} W constant is used; {reason}" + ) + return ComponentDiagnostic( + component="CPU", + detail=detail, + status=ESTIMATED, + method=method, + reason=reason, + fix=_cpu_estimation_fix(), + ) + + +def _apple_diagnostic(hw) -> ComponentDiagnostic: + return ComponentDiagnostic( + component=hw.chip_part, + detail=hw._model or "Apple Silicon", + status=MEASURED, + method="PowerMetrics", + ) + + +def _ram_diagnostic(hw) -> ComponentDiagnostic: + if hw._force_ram_power is not None: + return ComponentDiagnostic( + component="RAM", + detail=f"{hw.machine_memory_GB:.1f} GB", + status=ESTIMATED, + method=f"user-provided constant ({hw._force_ram_power} W)", + reason="force_ram_power is set, so no model and no counter is used", + ) + return ComponentDiagnostic( + component="RAM", + detail=f"{hw.machine_memory_GB:.1f} GB", + status=ESTIMATED, + method="RAM power estimation model", + reason="no platform exposes a DRAM energy counter to CodeCarbon", + fix=f"none; see {METHODOLOGY_DOC}#ram for the model used", + ) + + +def _gpu_diagnostic(hw) -> ComponentDiagnostic: + devices = hw.devices.get_gpu_static_info() + names = ", ".join(sorted({device["name"] for device in devices})) or "unknown" + return ComponentDiagnostic( + component="GPU", + detail=f"{len(devices)} x {names}", + status=MEASURED, + method="NVML/AMDSMI", + ) + + +def _no_gpu_diagnostic() -> ComponentDiagnostic: + return ComponentDiagnostic( + component="GPU", + detail="none detected", + status=UNAVAILABLE, + method="none", + reason="no NVIDIA GPU (nvidia-ml-py) and no AMD GPU (amdsmi) found", + fix="if you have a GPU, install the matching extra: pip install codecarbon[gpu]", + ) + + +def diagnose(hardware) -> List[ComponentDiagnostic]: + """ + Build a measurement quality report from the hardware objects a tracker set up. + + :param hardware: iterable of BaseHardware instances (``tracker._hardware``). + """ + diagnostics = [] + for hw in hardware: + if isinstance(hw, CPU): + diagnostics.append(_cpu_diagnostic(hw)) + elif isinstance(hw, AppleSiliconChip): + diagnostics.append(_apple_diagnostic(hw)) + elif isinstance(hw, RAM): + diagnostics.append(_ram_diagnostic(hw)) + elif isinstance(hw, GPU): + diagnostics.append(_gpu_diagnostic(hw)) + if not any(d.component == "GPU" for d in diagnostics): + diagnostics.append(_no_gpu_diagnostic()) + return diagnostics + + +def strict_failures( + diagnostics: List[ComponentDiagnostic], +) -> List[ComponentDiagnostic]: + """ + Components whose ESTIMATED status is actionable, i.e. what ``--strict`` fails on. + + RAM is excluded: it is modelled on every platform, so failing on it would make + ``--strict`` a gate no machine can pass. + """ + return [ + d + for d in diagnostics + if d.status == ESTIMATED and d.component not in ALWAYS_ESTIMATED_COMPONENTS + ] + + +def summary(diagnostics: List[ComponentDiagnostic]) -> str: + measured = sum(1 for d in diagnostics if d.status == MEASURED) + total = len(diagnostics) + line = f"{measured} of {total} power components are measured directly." + if measured < total: + line += " Run the fixes above to improve the accuracy of your results." + return line + + +def render_text(diagnostics: List[ComponentDiagnostic]) -> str: + """Human readable report, one block per component, with rich markup.""" + lines = [] + for diagnostic in diagnostics: + lines.append( + f"[bold]{diagnostic.component}[/bold] {escape(diagnostic.detail)}" + ) + colour = {MEASURED: "green", ESTIMATED: "yellow", UNAVAILABLE: "red"}[ + diagnostic.status + ] + lines.append( + f" [{colour}]{diagnostic.status.upper()}[/{colour}]" + f" - {escape(diagnostic.method)}" + ) + if diagnostic.reason: + lines.append(f" Why: {escape(diagnostic.reason)}") + if diagnostic.fix: + lines.append(f" Fix: {escape(diagnostic.fix)}") + lines.append("") + lines.append(summary(diagnostics)) + return "\n".join(lines) diff --git a/docs/how-to/enable-rapl.md b/docs/how-to/enable-rapl.md index ce297380b..1a75f50b5 100644 --- a/docs/how-to/enable-rapl.md +++ b/docs/how-to/enable-rapl.md @@ -1,5 +1,8 @@ # Improve Measurement Accuracy with RAPL +Run `codecarbon doctor` first: it tells you whether RAPL is already used on this +machine, and if not, whether the interface is missing or only unreadable. + RAPL (Running Average Power Limit) is a hardware feature on modern Intel and AMD processors that provides direct energy measurements through CPU counters. Enabling RAPL access gives CodeCarbon significantly more accurate CPU power measurements compared to software-based estimation. ## How RAPL Improves Accuracy diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71ba05a85..a56f14681 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -98,3 +98,63 @@ codecarbon detect ``` Displays detected RAM, CPU, GPU, and other hardware information that CodeCarbon uses to estimate energy consumption. Useful for verifying that CodeCarbon can see all your hardware. + +### `codecarbon doctor` + +Report, for each power component, whether CodeCarbon measures it or estimates it. + +**Usage:** +```bash +codecarbon doctor [OPTIONS] +``` + +CodeCarbon always produces a number, but on many machines part of it comes from a +model rather than from a hardware energy counter: the CPU falls back to a load +model when RAPL is not readable, RAM is always modelled, and a GPU that no driver +exposes contributes nothing. `doctor` runs the normal hardware detection and +prints the status of each component, why a better method was not used, and the +concrete fix. + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `--json` | flag | false | Print the report as JSON, for CI checks and bug reports | +| `--strict` | flag | false | Exit with code 1 if a component that could be measured is estimated | + +**Example output:** +```text +CodeCarbon 3.3.0 - measurement quality report + +RAM 31.1 GB + ESTIMATED - RAM power estimation model + Why: no platform exposes a DRAM energy counter to CodeCarbon + Fix: none; see https://docs.codecarbon.io/explanation/methodology/#ram + +CPU 12th Gen Intel(R) Core(TM) i7-1260P + ESTIMATED - CPU load model over a 28 W TDP + Why: /sys/class/powercap/intel-rapl exists but its energy counter is not + readable by this user (permission denied) + Fix: sudo chmod -R a+r /sys/class/powercap/intel-rapl + +GPU 1 x NVIDIA A100-SXM4-40GB + MEASURED - NVML/AMDSMI + +1 of 3 power components are measured directly. +``` + +Statuses are: + +- `MEASURED` — read from a hardware energy counter (RAPL, PowerMetrics, the + Windows Energy Meter Interface, NVML or AMDSMI). +- `ESTIMATED` — derived from a model, typically CPU load over a TDP value. +- `UNAVAILABLE` — the component was not found and contributes nothing. + +Use `--strict` in CI to fail a job when a machine silently falls back to +estimation, and paste `codecarbon doctor --json` into bug reports. + +`--strict` only fails on components that *could* have been measured. RAM is +exempt: no platform exposes a DRAM energy counter to CodeCarbon, so RAM is +`ESTIMATED` on every machine and failing on it would make `--strict` a gate +nothing can pass. An absent GPU is exempt for the same reason, being +`UNAVAILABLE` rather than a failure. diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 000000000..7239d1637 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,362 @@ +"""Tests for the measurement quality diagnostics and the `codecarbon doctor` CLI.""" + +import json +import os +from types import SimpleNamespace + +from typer.testing import CliRunner + +from codecarbon import diagnostics +from codecarbon.cli import main as cli_main +from codecarbon.diagnostics import ( + ESTIMATED, + MEASURED, + UNAVAILABLE, + ComponentDiagnostic, + diagnose, + render_text, + strict_failures, + summary, +) +from codecarbon.external import hardware, ram + +# diagnose() dispatches with isinstance, so the fixtures must be real hardware +# instances. Their __init__ probes the machine, so build them bare and set only +# the attributes the diagnostics read. + + +def _bare(cls, **attributes): + instance = object.__new__(cls) + instance.__dict__.update(attributes) + return instance + + +def CPU(mode, model="Fake CPU", tdp=65, is_generic_tdp=False): + return _bare( + hardware.CPU, + _mode=mode, + _model=model, + _tdp=tdp, + _is_generic_tdp=is_generic_tdp, + ) + + +def AppleSiliconChip(chip_part="CPU", model="Apple M2"): + return _bare(hardware.AppleSiliconChip, chip_part=chip_part, _model=model) + + +def RAM(force_ram_power=None): + return _bare(ram.RAM, _force_ram_power=force_ram_power, machine_memory_GB=32.0) + + +def GPU(names): + return _bare( + hardware.GPU, + devices=SimpleNamespace( + get_gpu_static_info=lambda: [{"name": name} for name in names] + ), + ) + + +def _make_rapl_tree(tmp_path, readable): + root = tmp_path / "intel-rapl" + domain = root / "intel-rapl:0" + domain.mkdir(parents=True) + energy = domain / "energy_uj" + energy.write_text("1000") + energy.chmod(0o444 if readable else 0o000) + return root + + +def test_cpu_measured_with_rapl(): + (found,) = [d for d in diagnose([CPU("intel_rapl")]) if d.component == "CPU"] + assert found.status == MEASURED + assert found.method == "RAPL" + assert found.reason is None + + +def test_cpu_estimated_when_rapl_unreadable(tmp_path, monkeypatch): + root = _make_rapl_tree(tmp_path, readable=False) + monkeypatch.setattr(diagnostics, "DEFAULT_RAPL_ROOT", str(root)) + monkeypatch.setattr(diagnostics, "is_linux_os", lambda: True) + monkeypatch.setattr(diagnostics, "is_mac_os", lambda: False) + + (found,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert found.status == ESTIMATED + if os.geteuid() != 0: # root can read anything, the distinction is moot + assert "permission denied" in found.reason + assert "chmod" in found.fix + + +def test_no_powercap_reason_differs_from_unreadable(tmp_path, monkeypatch): + monkeypatch.setattr(diagnostics, "is_linux_os", lambda: True) + monkeypatch.setattr(diagnostics, "is_mac_os", lambda: False) + + monkeypatch.setattr(diagnostics, "DEFAULT_RAPL_ROOT", str(tmp_path / "absent")) + absent = diagnose([CPU("cpu_load")])[0].reason + + root = _make_rapl_tree(tmp_path, readable=False) + monkeypatch.setattr(diagnostics, "DEFAULT_RAPL_ROOT", str(root)) + unreadable = diagnose([CPU("cpu_load")])[0].reason + + assert absent != unreadable + assert "not an Intel/AMD RAPL platform" in absent + + +def _force_platform(monkeypatch, linux=False, mac=False, windows=False): + monkeypatch.setattr(diagnostics, "is_linux_os", lambda: linux) + monkeypatch.setattr(diagnostics, "is_mac_os", lambda: mac) + monkeypatch.setattr(diagnostics, "is_windows_os", lambda: windows) + + +def test_readable_rapl_not_selected_is_reported(tmp_path, monkeypatch): + root = _make_rapl_tree(tmp_path, readable=True) + monkeypatch.setattr(diagnostics, "DEFAULT_RAPL_ROOT", str(root)) + _force_platform(monkeypatch, linux=True) + + (found,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert found.status == ESTIMATED + assert "readable but was not selected" in found.reason + # a readable tree must not be reported as a permission problem + assert "permission denied" not in found.reason + assert "chmod" in found.fix + + +def test_rapl_root_without_energy_counter(tmp_path, monkeypatch): + root = tmp_path / "intel-rapl" + root.mkdir() + monkeypatch.setattr(diagnostics, "DEFAULT_RAPL_ROOT", str(root)) + _force_platform(monkeypatch, linux=True) + + (found,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert "exposes no energy counter" in found.reason + + +def test_macos_powermetrics_available_but_unused(monkeypatch): + _force_platform(monkeypatch, mac=True) + monkeypatch.setattr( + diagnostics.powermetrics, "is_powermetrics_available", lambda: True + ) + + (found,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert found.reason == "powermetrics is available but was not selected" + assert "passwordless sudo" in found.fix + + +def test_macos_powermetrics_missing_asks_for_sudo_rule(monkeypatch): + _force_platform(monkeypatch, mac=True) + monkeypatch.setattr( + diagnostics.powermetrics, "is_powermetrics_available", lambda: False + ) + + (found,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert "passwordless sudo rule" in found.reason + + +def test_windows_emi_reasons_and_no_fix(monkeypatch): + _force_platform(monkeypatch, windows=True) + + monkeypatch.setattr(diagnostics.windows_emi, "is_emi_available", lambda: False) + (missing,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert "requires Windows 11 on bare metal" in missing.reason + + monkeypatch.setattr(diagnostics.windows_emi, "is_emi_available", lambda: True) + (present,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert present.reason.endswith("is available but was not selected") + + # Windows has no actionable fix to hand out + assert missing.fix is None and present.fix is None + + +def test_unknown_platform_has_no_counter_and_no_fix(monkeypatch): + _force_platform(monkeypatch) + + (found,) = [d for d in diagnose([CPU("cpu_load")]) if d.component == "CPU"] + assert found.reason == "no hardware energy counter is supported on this platform" + assert found.fix is None + + +def test_windows_emi_mode_is_measured(): + (found,) = [d for d in diagnose([CPU("windows_emi")]) if d.component == "CPU"] + assert found.status == MEASURED + assert found.method == "Windows Energy Meter Interface" + assert found.fix is None + + +def test_constant_mode_reports_the_constant(monkeypatch): + _force_platform(monkeypatch) + + (found,) = [d for d in diagnose([CPU("constant", tdp=95)]) if d.component == "CPU"] + assert found.status == ESTIMATED + assert found.method == "constant 95 W" + + +def test_cpu_load_mode_reports_the_tdp(monkeypatch): + _force_platform(monkeypatch) + + (found,) = [d for d in diagnose([CPU("cpu_load", tdp=95)]) if d.component == "CPU"] + assert found.method == "CPU load model over a 95 W TDP" + + +def test_apple_silicon_is_measured_by_powermetrics(): + diagnostics_list = diagnose([AppleSiliconChip(chip_part="GPU")]) + (found,) = [d for d in diagnostics_list if d.component == "GPU"] + assert found.status == MEASURED + assert found.method == "PowerMetrics" + assert found.detail == "Apple M2" + # the Apple GPU counts as a GPU, so no "none detected" entry is appended + assert len(diagnostics_list) == 1 + + +def test_generic_tdp_is_flagged(): + (found,) = [ + d + for d in diagnose([CPU("cpu_load", is_generic_tdp=True)]) + if d.component == "CPU" + ] + assert "not in the TDP registry" in found.reason + + +def test_ram_is_always_estimated(): + (found,) = [d for d in diagnose([RAM()]) if d.component == "RAM"] + assert found.status == ESTIMATED + + (forced,) = [d for d in diagnose([RAM(10)]) if d.component == "RAM"] + assert "user-provided constant" in forced.method + + +def test_gpu_measured_and_missing_gpu_reported(): + (found,) = [d for d in diagnose([GPU(["A100", "A100"])]) if d.component == "GPU"] + assert found.status == MEASURED + assert found.detail == "2 x A100" + + (missing,) = [d for d in diagnose([RAM()]) if d.component == "GPU"] + assert missing.status == UNAVAILABLE + + +def test_report_shape_and_summary(): + report = diagnose([RAM(), CPU("intel_rapl")]) + for component in report: + assert set(component.as_dict()) == { + "component", + "detail", + "status", + "method", + "reason", + "fix", + } + assert component.status in {MEASURED, ESTIMATED, UNAVAILABLE} + assert summary(report).startswith("1 of 3") + assert "RAM" in render_text(report) + + +def test_render_text_shows_status_reason_and_fix(): + report = [ + ComponentDiagnostic( + "CPU", "Fake CPU", ESTIMATED, "CPU load model", reason="why", fix="do this" + ), + ComponentDiagnostic("GPU", "1 x A100", MEASURED, "NVML/AMDSMI"), + ] + text = render_text(report) + + assert "[yellow]ESTIMATED[/yellow] - CPU load model" in text + assert "[green]MEASURED[/green] - NVML/AMDSMI" in text + assert " Why: why" in text + assert " Fix: do this" in text + # a component without a reason/fix must not emit empty Why/Fix lines + assert text.count("Why:") == 1 and text.count("Fix:") == 1 + assert text.endswith( + "1 of 2 power components are measured directly." + " Run the fixes above to improve the accuracy of your results." + ) + + +def test_render_text_escapes_markup_in_hardware_names(): + report = [ComponentDiagnostic("CPU", "Fake [i7] CPU", MEASURED, "RAPL")] + assert "Fake \\[i7] CPU" in render_text(report) + + +def test_summary_is_silent_when_everything_is_measured(): + report = [ComponentDiagnostic("CPU", "Fake CPU", MEASURED, "RAPL")] + assert summary(report) == "1 of 1 power components are measured directly." + + +def _patch_doctor_hardware(monkeypatch, hardware_list): + kwargs_seen = {} + + class FakeTracker: + def __init__(self, *args, **kwargs): + kwargs_seen.update(kwargs) + self._hardware = hardware_list + + def _ensure_hardware_ready(self): + pass + + monkeypatch.setattr("codecarbon.emissions_tracker.EmissionsTracker", FakeTracker) + return kwargs_seen + + +def test_doctor_allows_multiple_runs(monkeypatch): + # without it, a live run makes __init__ return early and _ensure_hardware_ready + # raises AttributeError on a half-built tracker. + kwargs_seen = _patch_doctor_hardware(monkeypatch, [CPU("intel_rapl")]) + assert CliRunner().invoke(cli_main.codecarbon, ["doctor"]).exit_code == 0 + assert kwargs_seen["allow_multiple_runs"] is True + + +def test_doctor_json_output(monkeypatch): + _patch_doctor_hardware(monkeypatch, [RAM(), CPU("intel_rapl")]) + result = CliRunner().invoke(cli_main.codecarbon, ["doctor", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert set(payload) == {"codecarbon_version", "components", "summary"} + assert payload["codecarbon_version"] == cli_main.__version__ + assert {c["component"] for c in payload["components"]} == {"RAM", "CPU", "GPU"} + assert payload["summary"].startswith( + "1 of 3 power components are measured directly." + ) + cpu = next(c for c in payload["components"] if c["component"] == "CPU") + assert cpu == { + "component": "CPU", + "detail": "Fake CPU", + "status": MEASURED, + "method": "RAPL", + "reason": None, + "fix": None, + } + + +def test_doctor_text_output(monkeypatch): + _patch_doctor_hardware(monkeypatch, [RAM(), CPU("intel_rapl")]) + result = CliRunner().invoke(cli_main.codecarbon, ["doctor"]) + assert result.exit_code == 0 + assert f"CodeCarbon {cli_main.__version__}" in result.output + assert "RAM" in result.output and "MEASURED" in result.output + assert "1 of 3 power components are measured directly." in result.output + # the human report is not JSON + assert not result.output.lstrip().startswith("{") + + +def test_doctor_strict_exit_code(monkeypatch): + _patch_doctor_hardware(monkeypatch, [RAM(), CPU("cpu_load")]) + assert CliRunner().invoke(cli_main.codecarbon, ["doctor"]).exit_code == 0 + assert ( + CliRunner().invoke(cli_main.codecarbon, ["doctor", "--strict"]).exit_code == 1 + ) + + +def test_doctor_strict_passes_on_a_machine_with_measured_cpu_and_gpu(monkeypatch): + # RAM is estimated here, as it is on every machine: --strict must still pass, + # otherwise it is a gate no machine can clear. + _patch_doctor_hardware(monkeypatch, [CPU("intel_rapl"), RAM(), GPU(["A100"])]) + result = CliRunner().invoke(cli_main.codecarbon, ["doctor", "--strict"]) + assert result.exit_code == 0, result.output + assert "ESTIMATED" in result.output # the RAM row is still reported + + +def test_strict_failures_ignores_ram_but_not_the_cpu(): + report = diagnose([CPU("intel_rapl"), RAM(), GPU(["A100"])]) + assert strict_failures(report) == [] + + (failure,) = strict_failures(diagnose([CPU("cpu_load"), RAM(), GPU(["A100"])])) + assert failure.component == "CPU"