diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..21a673469 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -390,6 +390,10 @@ def monitor( str, typer.Option(help="Log level (critical, error, warning, info, debug)"), ] = "error", + live: Annotated[ + bool, + typer.Option(help="Show a live table of the current run in the terminal"), + ] = False, ): """Monitor your machine's carbon emissions.""" @@ -424,6 +428,11 @@ def monitor( tracker_args = {**tracker_args, "save_to_api": api} + if live: + from codecarbon.output_methods.live import LiveTableOutput + + tracker_args.setdefault("output_handlers", []).append(LiveTableOutput()) + from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker # If extra args are provided (e.g. `codecarbon monitor -- my_script.py`), delegate to `run_and_monitor` diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 96ed00c91..38b878184 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -975,12 +975,20 @@ def _update_emissions(self) -> None: self._total_emissions += delta_emissions self._last_energy_covered = self._total_energy - def _prepare_emissions_data(self) -> EmissionsData: + def _prepare_emissions_data(self, update: bool = True) -> EmissionsData: """ Prepare the emissions data to be sent to the API or written to a file. + + :param update: when False, report the emissions computed so far instead + of converting the energy measured since the last update. Read-only: + it does not advance `_last_energy_covered` nor fetch carbon + intensity. Used by live views that must not perturb the totals. :return: EmissionsData object with the total emissions data. """ - self._update_emissions() + if update: + self._update_emissions() + else: + self._ensure_geo_metadata() self._ensure_cloud_conf() cloud = self._get_cloud_metadata() duration: Time = Time.from_seconds(time.perf_counter() - self._start_time) @@ -1275,6 +1283,28 @@ def _measure_power_and_energy(self) -> None: self._do_measurements() self._last_measured_time = time.perf_counter() self._measure_occurrence += 1 + + # Handlers opting in with `live_out_every_measure = True` are called on + # every measure with a read-only snapshot (no `_update_emissions()`, so + # no state advance and no carbon intensity lookup) and `delta=None`: + # computing the delta here would consume it for the periodic call below. + # The power fields of EmissionsData are averages since `start()`, which + # is not what a live view wants, so they carry the last measured power. + live_handlers = [ + handler + for handler in self._output_handlers + if getattr(handler, "live_out_every_measure", False) + ] + if live_handlers: + live = dataclasses.replace( + self._prepare_emissions_data(update=False), + cpu_power=self._cpu_power.W, + gpu_power=self._gpu_power.W, + ram_power=self._ram_power.W, + ) + for handler in live_handlers: + handler.live_out(live, None) + # Special case: metrics and api calls are sent every `api_call_interval` measures if ( self._api_call_interval != -1 diff --git a/codecarbon/output_methods/base_output.py b/codecarbon/output_methods/base_output.py index 373d23edd..28c320158 100644 --- a/codecarbon/output_methods/base_output.py +++ b/codecarbon/output_methods/base_output.py @@ -41,6 +41,10 @@ class BaseOutput: emissions segregated by task """ + #: Opt in to being called by `live_out` on every measure instead of only + #: every `api_call_interval` measures. Such calls pass `delta=None`. + live_out_every_measure = False + def out(self, total: EmissionsData, delta: EmissionsData): pass diff --git a/codecarbon/output_methods/live.py b/codecarbon/output_methods/live.py new file mode 100644 index 000000000..2746c2507 --- /dev/null +++ b/codecarbon/output_methods/live.py @@ -0,0 +1,40 @@ +"""Live terminal view of the current run, used by `codecarbon monitor --live`.""" + +from rich.console import Console +from rich.live import Live +from rich.table import Table + +from codecarbon.output_methods.base_output import BaseOutput +from codecarbon.output_methods.emissions_data import EmissionsData + + +def _table(data: EmissionsData) -> Table: + table = Table(title="CodeCarbon live") + table.add_column("Metric") + table.add_column("Value", justify="right") + for name, value in ( + ("Duration", f"{data.duration:,.0f} s"), + ("Emissions", f"{data.emissions * 1000:,.3f} gCO2eq"), + ("Energy", f"{data.energy_consumed * 1000:,.3f} Wh"), + ("CPU power", f"{data.cpu_power:,.1f} W"), + ("GPU power", f"{data.gpu_power:,.1f} W"), + ("RAM power", f"{data.ram_power:,.1f} W"), + ): + table.add_row(name, value) + return table + + +class LiveTableOutput(BaseOutput): + """Reprint a Rich table in the terminal on every measure.""" + + live_out_every_measure = True + + def __init__(self, console: Console | None = None): + self._live = Live(console=console, refresh_per_second=4) + self._live.start() + + def live_out(self, total: EmissionsData, delta: EmissionsData | None = None): + self._live.update(_table(total)) + + def exit(self): + self._live.stop() diff --git a/docs/how-to/visualize.md b/docs/how-to/visualize.md index f307d4b48..58ed3839a 100644 --- a/docs/how-to/visualize.md +++ b/docs/how-to/visualize.md @@ -1,6 +1,20 @@ # Visualize -CodeCarbon provides two ways to visualize your emissions data: a local Python dashboard for offline analysis, and an online web dashboard for cloud-based tracking and team collaboration. +CodeCarbon provides three ways to visualize your emissions data: a live terminal table while a run is in progress, a local Python dashboard for offline analysis of finished runs, and an online web dashboard for cloud-based tracking and team collaboration. + +## Live Terminal View + +To watch power, energy and emissions while a run is in progress, add `--live` to the `monitor` command: + +``` bash +codecarbon monitor --live +``` + +The table is reprinted on every measurement. It also works with a wrapped command: + +``` bash +codecarbon monitor --live -- python train.py +``` ## Offline Visualization (carbonboard) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71ba05a85..3a1ad64b0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -45,6 +45,7 @@ Displays real-time emissions data for all processes on your machine. Press `Ctrl | `--offline` | flag | false | Run without internet access | | `--country-iso-code` | string | - | ISO 3166-1 alpha-3 country code (required in offline mode) | | `--log-level` | choice | ERROR | Log level: DEBUG, INFO, WARNING, ERROR | +| `--live` | flag | false | Show a live table of the current run in the terminal | **Examples:** ```bash @@ -59,6 +60,9 @@ codecarbon monitor --offline --country-iso-code FRA # Monitor with debug logging codecarbon monitor --log-level DEBUG + +# Monitor with a live table in the terminal +codecarbon monitor --live ``` ### `codecarbon monitor -- ` diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 8bb4d66f4..022ef06f4 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -386,6 +386,55 @@ def stop(self): assert calls["kwargs"]["region"] == "IDF" +def _invoke_offline_monitor(monkeypatch, extra_args): + """Run `codecarbon monitor --offline ...` against a tracker that does nothing.""" + calls = {"kwargs": None} + + class FakeOfflineTracker: + def __init__(self, **kwargs): + calls["kwargs"] = kwargs + # Breaks the CLI's infinite monitoring loop on the first iteration. + 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) + + result = CliRunner().invoke( + cli_main.codecarbon, + ["monitor", "--offline", "--country-iso-code", "FRA"] + extra_args, + ) + return result, calls["kwargs"] + + +def test_monitor_without_live_registers_no_output_handler(monkeypatch): + result, kwargs = _invoke_offline_monitor(monkeypatch, []) + + assert result.exit_code == 0 + assert "output_handlers" not in kwargs + + +def test_monitor_live_registers_a_live_table_handler(monkeypatch): + from codecarbon.output_methods.live import LiveTableOutput + + result, kwargs = _invoke_offline_monitor(monkeypatch, ["--live"]) + + (handler,) = kwargs["output_handlers"] + try: + assert isinstance(handler, LiveTableOutput) + assert handler.live_out_every_measure is True + assert result.exit_code == 0 + finally: + handler.exit() + + def test_monitor_delegates_offline_flag_to_run_and_monitor(monkeypatch): captured = {} diff --git a/tests/output_methods/test_live.py b/tests/output_methods/test_live.py new file mode 100644 index 000000000..d4260baec --- /dev/null +++ b/tests/output_methods/test_live.py @@ -0,0 +1,69 @@ +import dataclasses +import io + +from rich.console import Console + +from codecarbon.output_methods.emissions_data import EmissionsData +from codecarbon.output_methods.live import LiveTableOutput, _table + + +def _emissions_data(**overrides) -> EmissionsData: + """An EmissionsData with 0 everywhere but the fields the live table shows.""" + kwargs = { + field.name: 0 + for field in dataclasses.fields(EmissionsData) + if field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + } + kwargs.update( + duration=3661.4, + emissions=0.001234, + energy_consumed=0.0025, + cpu_power=12.34, + gpu_power=56.78, + ram_power=9.87, + ) + kwargs.update(overrides) + return EmissionsData(**kwargs) + + +def _render(renderable) -> str: + console = Console(file=io.StringIO(), force_terminal=False, width=120, record=True) + console.print(renderable) + return console.export_text() + + +def test_table_renders_every_metric_row_formatted(): + text = _render(_table(_emissions_data())) + + assert "CodeCarbon live" in text + assert "3,661 s" in text # duration, no decimals + assert "1.234 gCO2eq" in text # emissions, kg -> g + assert "2.500 Wh" in text # energy, kWh -> Wh + assert "12.3 W" in text + assert "56.8 W" in text + assert "9.9 W" in text + + +def test_live_output_starts_updates_and_stops(): + console = Console(file=io.StringIO(), force_terminal=False, width=120) + out = LiveTableOutput(console=console) + try: + assert out.live_out_every_measure is True + assert out._live.is_started + out.live_out(_emissions_data()) + out.live_out(_emissions_data(duration=42), delta=_emissions_data()) + finally: + out.exit() + + assert not out._live.is_started + + +def test_exit_is_safe_when_called_twice(): + out = LiveTableOutput( + console=Console(file=io.StringIO(), force_terminal=False, width=120) + ) + out.exit() + out.exit() + + assert not out._live.is_started diff --git a/tests/test_custom_handler.py b/tests/test_custom_handler.py index 8adcf7c37..355880d41 100644 --- a/tests/test_custom_handler.py +++ b/tests/test_custom_handler.py @@ -2,7 +2,11 @@ import unittest from typing import List -from codecarbon.emissions_tracker import EmissionsTracker, track_emissions +from codecarbon.emissions_tracker import ( + EmissionsTracker, + OfflineEmissionsTracker, + track_emissions, +) from codecarbon.output import BaseOutput, EmissionsData @@ -72,3 +76,58 @@ def verify_custom_handler_state( self.assertEqual(results.project_name, self.project_name) self.assertNotEqual(results.emissions, 0.0) self.assertAlmostEqual(results.emissions, 6.262572537957655e-05, places=2) + + +class LiveOutput(CustomOutput): + live_out_every_measure = True + + def live_out(self, total: EmissionsData, delta: EmissionsData = None): + self.log.append(total) + + +class TestLiveOutputHandler(unittest.TestCase): + """A live handler must not add `_update_emissions()` calls (state advance + + possible carbon intensity lookup) to the measurement loop.""" + + def _count_update_emissions(self, handlers): + tracker = OfflineEmissionsTracker( + country_iso_code="FRA", + output_handlers=handlers, + api_call_interval=2, + measure_power_secs=999, + save_to_file=False, + ) + tracker.start() + calls = [] + original = tracker._update_emissions + tracker._update_emissions = lambda: (calls.append(1), original())[1] + try: + for _ in range(4): + tracker._measure_power_and_energy() + finally: + tracker._update_emissions = original + tracker.stop() + return len(calls) + + def test_live_handler_does_not_add_update_emissions_calls(self): + without = self._count_update_emissions([CustomOutput()]) + with_live = self._count_update_emissions([LiveOutput()]) + self.assertEqual(without, with_live) + + def test_live_handler_is_called_on_every_measure(self): + handler = LiveOutput() + tracker = OfflineEmissionsTracker( + country_iso_code="FRA", + output_handlers=[handler], + api_call_interval=-1, + measure_power_secs=999, + save_to_file=False, + ) + tracker.start() + before = len(handler.log) + try: + for _ in range(3): + tracker._measure_power_and_energy() + finally: + tracker.stop() + self.assertEqual(len(handler.log) - before, 3)