Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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`
Expand Down
34 changes: 32 additions & 2 deletions codecarbon/emissions_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions codecarbon/output_methods/base_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions codecarbon/output_methods/live.py
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 15 additions & 1 deletion docs/how-to/visualize.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
4 changes: 4 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 -- <command>`
Expand Down
49 changes: 49 additions & 0 deletions tests/cli/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down
69 changes: 69 additions & 0 deletions tests/output_methods/test_live.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 60 additions & 1 deletion tests/test_custom_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Loading