From 43064225d02544ab0fb6a62f43dea84664df2b1a Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 17:52:27 +0200 Subject: [PATCH 01/12] feat: add carbon-aware `codecarbon wait` Fetch an Electricity Maps carbon intensity forecast, pick the window with the lowest mean intensity that still meets the deadline, and either report it (--dry-run) or sleep until it and delegate to run_and_monitor. Advisory/blocking only: no EmissionsData schema change, no decorator, and no static fallback profile. Without a token, get_forecast returns None and the job runs immediately -- a job is never blocked on a missing credential. get_forecast should become a method on the provider protocol once pluggable intensity providers land. Refs #1356 Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/main.py | 51 +++++++ codecarbon/cli/wait.py | 135 +++++++++++++++++++ codecarbon/core/intensity_forecast.py | 148 ++++++++++++++++++++ tests/cli/test_wait.py | 169 +++++++++++++++++++++++ tests/test_intensity_forecast.py | 187 ++++++++++++++++++++++++++ 5 files changed, 690 insertions(+) create mode 100644 codecarbon/cli/wait.py create mode 100644 codecarbon/core/intensity_forecast.py create mode 100644 tests/cli/test_wait.py create mode 100644 tests/test_intensity_forecast.py diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..a5c7758e9 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -465,6 +465,57 @@ def signal_handler(signum, frame): raise e +@codecarbon.command( + "wait", + short_help="Wait for a low-carbon window, then run a command.", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def wait( + ctx: typer.Context, + duration: Annotated[ + str, + typer.Option(help="Expected job length, e.g. '90m', '2h', '1h30m'."), + ] = "1h", + deadline: Annotated[ + str, + typer.Option(help="Maximum delay before the job must start."), + ] = "12h", + threshold: Annotated[ + Optional[float], + typer.Option(help="gCO2e/kWh at or below which we start immediately."), + ] = None, + dry_run: Annotated[ + bool, + typer.Option(help="Print the recommendation and exit without waiting."), + ] = False, + measure_power_secs: Annotated[ + int, + typer.Option(help="Interval between two measures."), + ] = 10, + log_level: Annotated[ + str, + typer.Option(help="Log level (critical, error, warning, info, debug)"), + ] = "error", +): + """Wait for the greenest window in the carbon intensity forecast, then run + a command under measurement. + + Requires an Electricity Maps API token; without one, the command runs + immediately rather than blocking. + """ + from codecarbon.cli.wait import wait_for_green_window + + return wait_for_green_window( + ctx, + duration=duration, + deadline=deadline, + threshold=threshold, + dry_run=dry_run, + log_level=log_level, + measure_power_secs=measure_power_secs, + ) + + @codecarbon.command("detect", short_help="Detect hardware and print information.") def detect(): """ diff --git a/codecarbon/cli/wait.py b/codecarbon/cli/wait.py new file mode 100644 index 000000000..e5dbc83c4 --- /dev/null +++ b/codecarbon/cli/wait.py @@ -0,0 +1,135 @@ +"""CodeCarbon CLI - Wait Command""" + +import re +import sys +import time +from datetime import datetime, timedelta, timezone +from typing import Optional + +import typer +from rich import print + +_DURATION_RE = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") + + +def parse_duration(value: str) -> timedelta: + """Parse "90m", "2h", "1h30m" or a plain number of seconds.""" + value = value.strip().lower() + if value.isdigit(): + return timedelta(seconds=int(value)) + match = _DURATION_RE.match(value) + if not match or not any(match.groups()): + raise ValueError(f"Invalid duration: {value!r}. Use e.g. '90m', '2h', '1h30m'.") + hours, minutes, seconds = (int(g or 0) for g in match.groups()) + return timedelta(hours=hours, minutes=minutes, seconds=seconds) + + +def find_green_window( + duration: timedelta, + deadline: timedelta, + token: Optional[str], +): + """Return (start, intensity, now_intensity) or None when we should run now.""" + from codecarbon.core.intensity_forecast import best_window, get_forecast + from codecarbon.external.geography import GeoMetadata + from codecarbon.input import DataSource + + geo = GeoMetadata.from_geo_js(DataSource().geo_js_url) + forecast = get_forecast(geo, token=token, horizon_hours=_ceil_hours(deadline)) + if forecast is None: + return None + + now = datetime.now(timezone.utc) + start, intensity = best_window(forecast, duration, deadline=now + deadline) + return start, intensity, forecast.points[0].g_co2e_per_kwh + + +def _ceil_hours(delta: timedelta) -> int: + return max(1, -(-int(delta.total_seconds()) // 3600)) + + +def wait_for_green_window( + ctx: typer.Context, + duration: str = "1h", + deadline: str = "12h", + threshold: Optional[float] = None, + dry_run: bool = False, + log_level: str = "error", + **tracker_args, +): + """Wait for the greenest window in the forecast, then run a command. + + This is a sleep, not a scheduler: it does not fork, daemonise or persist. + For deferral that must survive a reboot, use cron, systemd or Airflow. + + Examples: + + # Print the recommendation and exit + codecarbon wait --dry-run --deadline 24h --duration 90m + + # Block until the greenest window, then run under measurement + codecarbon wait --deadline 12h --duration 2h -- python train.py + """ + from codecarbon.cli.monitor import run_and_monitor + from codecarbon.core.config import get_hierarchical_config + from codecarbon.external.logger import set_logger_level + + set_logger_level(log_level) + + try: + job_duration = parse_duration(duration) + max_delay = parse_duration(deadline) + except ValueError as e: + print(f"ERROR: {e}", file=sys.stderr) + raise typer.Exit(1) + + config = get_hierarchical_config() + token = config.get("electricitymaps_api_token") or config.get( + "co2_signal_api_token" + ) + + window = find_green_window(job_duration, max_delay, token) + delay_seconds = 0.0 + if window is None: + print("🌱 CodeCarbon: no forecast available, running now.") + else: + start, intensity, now_intensity = window + delay_seconds = max(0.0, (start - datetime.now(timezone.utc)).total_seconds()) + if threshold is not None and now_intensity <= threshold: + print( + f"🌱 CodeCarbon: current intensity {now_intensity:.0f} gCO2e/kWh is " + f"at or below the {threshold:.0f} threshold, running now." + ) + delay_seconds = 0.0 + elif delay_seconds <= 0: + print( + f"🌱 CodeCarbon: now is already the greenest window " + f"({now_intensity:.0f} gCO2e/kWh)." + ) + else: + saving = ( + 100 * (now_intensity - intensity) / now_intensity + if now_intensity + else 0 + ) + print( + f"🌱 Best start: {start:%Y-%m-%d %H:%M} UTC " + f"({intensity:.0f} gCO2e/kWh, now: {now_intensity:.0f}) " + f"-> saves ~{saving:.0f}%" + ) + + if dry_run: + raise typer.Exit(0) + + if delay_seconds > 0: + print( + f" Waiting {delay_seconds / 3600:.1f}h before starting. Ctrl-C to run now." + ) + try: + time.sleep(delay_seconds) + except KeyboardInterrupt: + print("\nāš ļø Wait interrupted, starting now.", file=sys.stderr) + + # Strip our own subcommand name so `run_and_monitor` sees only the command. + ctx.args = [arg for arg in getattr(ctx, "args", []) if arg != "wait"] + run_and_monitor(ctx, log_level=log_level, **tracker_args) diff --git a/codecarbon/core/intensity_forecast.py b/codecarbon/core/intensity_forecast.py new file mode 100644 index 000000000..b1150f7ef --- /dev/null +++ b/codecarbon/core/intensity_forecast.py @@ -0,0 +1,148 @@ +"""Carbon intensity forecasts and greenest-window selection. + +The only provider able to serve a forecast today is Electricity Maps, and only +for users holding a token for it. When no provider can answer, `get_forecast` +returns ``None`` and every caller must degrade to "run now" -- a job is never +blocked on a missing credential. + +Once pluggable intensity providers land (see issue #1356), `get_forecast` +should become an optional `forecast()` method on the provider protocol rather +than a second HTTP client. +""" + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +import requests + +from codecarbon.core.electricitymaps_api import ELECTRICITYMAPS_API_TIMEOUT +from codecarbon.external.geography import GeoMetadata +from codecarbon.external.logger import logger + +FORECAST_URL: str = "https://api.electricitymaps.com/v3/carbon-intensity/forecast" + + +@dataclass(frozen=True) +class IntensityPoint: + at: datetime # timezone-aware, UTC + g_co2e_per_kwh: float + + +@dataclass(frozen=True) +class Forecast: + zone: str + points: List[IntensityPoint] # ordered, typically hourly + source: str + fetched_at: datetime + + +def _location_params(geo: GeoMetadata) -> Dict[str, Any]: + """Build the Electricity Maps location query, as `get_emissions` does.""" + if geo.latitude: + return {"lat": geo.latitude, "lon": geo.longitude} + return {"countryCode": geo.country_2letter_iso_code} + + +def _parse_datetime(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def get_forecast( + geo: GeoMetadata, + *, + token: Optional[str] = None, + horizon_hours: int = 48, +) -> Optional[Forecast]: + """Return an intensity forecast, or None when no provider can supply one. + + Never raises: a forecast is an optimisation, not a requirement. + """ + if not token: + logger.warning( + "No Electricity Maps API token configured, cannot fetch a carbon " + "intensity forecast." + ) + return None + + try: + resp = requests.get( + FORECAST_URL, + params=_location_params(geo), + headers={"auth-token": token}, + timeout=ELECTRICITYMAPS_API_TIMEOUT, + ) + if resp.status_code != 200: + body = resp.json() + raise ValueError(body.get("error") or body.get("message") or resp.text) + + data = resp.json() + horizon_end = datetime.now(timezone.utc) + timedelta(hours=horizon_hours) + points = [ + IntensityPoint( + at=_parse_datetime(entry["datetime"]), + g_co2e_per_kwh=float(entry["carbonIntensity"]), + ) + for entry in data["forecast"] + if entry.get("carbonIntensity") is not None + ] + points = sorted( + (point for point in points if point.at <= horizon_end), + key=lambda point: point.at, + ) + if not points: + raise ValueError("No usable forecast points in response") + + return Forecast( + zone=data.get("zone", ""), + points=points, + source="electricitymaps", + fetched_at=datetime.now(timezone.utc), + ) + except Exception as e: + logger.error( + f"intensity_forecast.get_forecast: {e} >>> Falling back to running now." + ) + return None + + +def best_window( + forecast: Forecast, + duration: timedelta, + deadline: Optional[datetime] = None, +) -> Tuple[datetime, float]: + """Start time minimising mean intensity over `duration`, and that mean. + + Only windows that both start at or after the first forecast point and + finish before `deadline` are considered. Returns the earliest point and its + intensity when no complete window fits, so "just run it" is the default. + """ + points = forecast.points + fallback = (points[0].at, points[0].g_co2e_per_kwh) + + # The forecast covers up to one step past its last point. + step = points[1].at - points[0].at if len(points) > 1 else duration + covered_until = points[-1].at + step + + best: Optional[Tuple[datetime, float]] = None + for start_index, start in enumerate(points): + window_end = start.at + duration + if window_end > covered_until: + break + if deadline is not None and window_end > deadline: + break + # ponytail: linear rescan per start, fine for hourly points over a few + # days; use a running sum if horizons ever grow by orders of magnitude. + covered = [ + point.g_co2e_per_kwh + for point in points[start_index:] + if point.at < window_end + ] + mean = sum(covered) / len(covered) + if best is None or mean < best[1]: + best = (start.at, mean) + + return best or fallback diff --git a/tests/cli/test_wait.py b/tests/cli/test_wait.py new file mode 100644 index 000000000..ddc532a81 --- /dev/null +++ b/tests/cli/test_wait.py @@ -0,0 +1,169 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest +import typer + +from codecarbon.cli import wait as wait_module +from codecarbon.core.intensity_forecast import Forecast, IntensityPoint + + +def _forecast(values, start): + return Forecast( + zone="FR", + points=[ + IntensityPoint(at=start + timedelta(hours=i), g_co2e_per_kwh=v) + for i, v in enumerate(values) + ], + source="test", + fetched_at=start, + ) + + +@pytest.fixture +def no_network(monkeypatch): + """Never let the wait command reach geolocation or the intensity API.""" + monkeypatch.setattr( + "codecarbon.external.geography.GeoMetadata.from_geo_js", + classmethod(lambda cls, url: SimpleNamespace()), + ) + monkeypatch.setattr( + "codecarbon.core.config.get_hierarchical_config", + lambda: {"electricitymaps_api_token": "tok"}, + ) + + +def _patch_forecast(monkeypatch, values): + now = datetime.now(timezone.utc) + monkeypatch.setattr( + "codecarbon.core.intensity_forecast.get_forecast", + lambda geo, **kwargs: _forecast(values, now), + ) + + +@pytest.mark.parametrize( + "value,expected", + [ + ("90m", timedelta(minutes=90)), + ("2h", timedelta(hours=2)), + ("1h30m", timedelta(hours=1, minutes=30)), + ("45s", timedelta(seconds=45)), + ("3600", timedelta(hours=1)), + ], +) +def test_parse_duration(value, expected): + assert wait_module.parse_duration(value) == expected + + +@pytest.mark.parametrize("value", ["", "soon", "2 hours", "h", "-1h"]) +def test_parse_duration_rejects_garbage(value): + with pytest.raises(ValueError): + wait_module.parse_duration(value) + + +def test_dry_run_prints_recommendation_and_exits(monkeypatch, capsys, no_network): + _patch_forecast(monkeypatch, [300, 300, 100, 100, 300]) + slept = [] + monkeypatch.setattr(wait_module.time, "sleep", lambda s: slept.append(s)) + + with pytest.raises(typer.Exit) as exc: + wait_module.wait_for_green_window( + SimpleNamespace(args=[]), duration="2h", deadline="6h", dry_run=True + ) + + assert exc.value.exit_code == 0 + assert slept == [] + out = capsys.readouterr().out + assert "Best start" in out + assert "saves ~67%" in out + + +def test_invalid_duration_exits_with_error(monkeypatch, capsys, no_network): + with pytest.raises(typer.Exit) as exc: + wait_module.wait_for_green_window( + SimpleNamespace(args=[]), duration="whenever", dry_run=True + ) + assert exc.value.exit_code == 1 + + +def test_no_forecast_runs_now(monkeypatch, capsys, no_network): + monkeypatch.setattr( + "codecarbon.core.intensity_forecast.get_forecast", lambda geo, **kwargs: None + ) + slept = [] + monkeypatch.setattr(wait_module.time, "sleep", lambda s: slept.append(s)) + called = {} + monkeypatch.setattr( + "codecarbon.cli.monitor.run_and_monitor", + lambda ctx, **kwargs: called.setdefault("args", list(ctx.args)), + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=["wait", "--", "python", "train.py"]) + ) + + assert slept == [] + assert called["args"] == ["--", "python", "train.py"] + assert "no forecast available" in capsys.readouterr().out + + +def test_threshold_short_circuits_the_wait(monkeypatch, capsys, no_network): + _patch_forecast(monkeypatch, [120, 300, 50, 50]) + slept = [] + monkeypatch.setattr(wait_module.time, "sleep", lambda s: slept.append(s)) + monkeypatch.setattr( + "codecarbon.cli.monitor.run_and_monitor", lambda ctx, **kwargs: None + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=["python", "train.py"]), + duration="1h", + deadline="6h", + threshold=150, + ) + + assert slept == [] + assert "running now" in capsys.readouterr().out + + +def test_sleeps_until_the_green_window_then_delegates(monkeypatch, no_network): + _patch_forecast(monkeypatch, [300, 300, 100, 100, 300]) + slept = [] + monkeypatch.setattr(wait_module.time, "sleep", lambda s: slept.append(s)) + called = {} + monkeypatch.setattr( + "codecarbon.cli.monitor.run_and_monitor", + lambda ctx, **kwargs: called.update(kwargs, args=list(ctx.args)), + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=["wait", "python", "train.py"]), + duration="2h", + deadline="6h", + measure_power_secs=15, + ) + + assert len(slept) == 1 + assert 2 * 3600 - 60 < slept[0] <= 2 * 3600 + assert called["args"] == ["python", "train.py"] + assert called["measure_power_secs"] == 15 + + +def test_keyboard_interrupt_during_wait_runs_immediately(monkeypatch, no_network): + _patch_forecast(monkeypatch, [300, 300, 100, 100, 300]) + + def _interrupt(seconds): + raise KeyboardInterrupt + + monkeypatch.setattr(wait_module.time, "sleep", _interrupt) + called = {} + monkeypatch.setattr( + "codecarbon.cli.monitor.run_and_monitor", + lambda ctx, **kwargs: called.setdefault("ran", True), + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=["python", "train.py"]), duration="2h", deadline="6h" + ) + + assert called["ran"] is True diff --git a/tests/test_intensity_forecast.py b/tests/test_intensity_forecast.py new file mode 100644 index 000000000..849b8948f --- /dev/null +++ b/tests/test_intensity_forecast.py @@ -0,0 +1,187 @@ +import unittest +from datetime import datetime, timedelta, timezone + +import responses + +from codecarbon.core import intensity_forecast +from codecarbon.core.intensity_forecast import ( + Forecast, + IntensityPoint, + best_window, + get_forecast, +) +from codecarbon.external.geography import GeoMetadata + +BASE = datetime(2026, 8, 13, 0, 0, tzinfo=timezone.utc) + + +def _forecast(values): + return Forecast( + zone="FR", + points=[ + IntensityPoint(at=BASE + timedelta(hours=i), g_co2e_per_kwh=v) + for i, v in enumerate(values) + ], + source="test", + fetched_at=BASE, + ) + + +def _payload(values, start=None): + start = start or datetime.now(timezone.utc) + return { + "zone": "FR", + "forecast": [ + { + "datetime": (start + timedelta(hours=i)) + .isoformat() + .replace("+00:00", "Z"), + "carbonIntensity": v, + } + for i, v in enumerate(values) + ], + } + + +class TestGetForecast(unittest.TestCase): + def setUp(self) -> None: + self._geo = GeoMetadata( + country_iso_code="FRA", + country_name="France", + region=None, + country_2letter_iso_code="FR", + ) + self._geo_latlon = GeoMetadata( + country_iso_code="FRA", + country_name="France", + region=None, + country_2letter_iso_code="FR", + latitude=48.85, + longitude=2.35, + ) + + def test_no_token_returns_none_without_calling_api(self): + assert get_forecast(self._geo, token=None) is None + + @responses.activate + def test_parses_forecast(self): + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json=_payload([100, 200, 50]), + status=200, + ) + forecast = get_forecast(self._geo, token="tok") + assert forecast is not None + assert forecast.zone == "FR" + assert forecast.source == "electricitymaps" + assert [p.g_co2e_per_kwh for p in forecast.points] == [100, 200, 50] + assert all(p.at.tzinfo is not None for p in forecast.points) + assert responses.calls[0].request.headers["auth-token"] == "tok" + assert "countryCode=FR" in responses.calls[0].request.url + + @responses.activate + def test_uses_lat_lon_when_available(self): + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json=_payload([100]), + status=200, + ) + get_forecast(self._geo_latlon, token="tok") + url = responses.calls[0].request.url + assert "lat=48.85" in url and "lon=2.35" in url + + @responses.activate + def test_naive_timestamps_are_treated_as_utc(self): + payload = _payload([100]) + payload["forecast"][0]["datetime"] = "2999-01-01T03:00:00" + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json=payload, + status=200, + ) + forecast = get_forecast(self._geo, token="tok", horizon_hours=24 * 365 * 1000) + assert forecast.points[0].at.tzinfo == timezone.utc + + @responses.activate + def test_horizon_truncates_points(self): + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json=_payload([100, 200, 300, 400]), + status=200, + ) + forecast = get_forecast(self._geo, token="tok", horizon_hours=2) + assert len(forecast.points) <= 3 + + @responses.activate + def test_error_status_returns_none(self): + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json={"error": "no access"}, + status=403, + ) + assert get_forecast(self._geo, token="tok") is None + + @responses.activate + def test_malformed_payload_returns_none(self): + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json={"unexpected": True}, + status=200, + ) + assert get_forecast(self._geo, token="tok") is None + + @responses.activate + def test_empty_forecast_returns_none(self): + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json={"zone": "FR", "forecast": []}, + status=200, + ) + assert get_forecast(self._geo, token="tok") is None + + +class TestBestWindow(unittest.TestCase): + def test_picks_the_trough(self): + forecast = _forecast([300, 250, 100, 90, 280, 300]) + start, mean = best_window(forecast, timedelta(hours=2)) + assert start == BASE + timedelta(hours=2) + assert mean == 95 + + def test_flat_series_picks_now(self): + forecast = _forecast([200] * 5) + start, mean = best_window(forecast, timedelta(hours=2)) + assert start == BASE + assert mean == 200 + + def test_decreasing_series_picks_last_complete_window(self): + forecast = _forecast([500, 400, 300, 200, 100]) + start, _ = best_window(forecast, timedelta(hours=2)) + assert start == BASE + timedelta(hours=3) + + def test_deadline_shorter_than_duration_falls_back_to_now(self): + forecast = _forecast([300, 100, 100]) + start, mean = best_window( + forecast, timedelta(hours=2), deadline=BASE + timedelta(minutes=30) + ) + assert start == BASE + assert mean == 300 + + def test_deadline_restricts_the_search(self): + forecast = _forecast([300, 200, 50, 50]) + start, _ = best_window( + forecast, timedelta(hours=1), deadline=BASE + timedelta(hours=2) + ) + assert start == BASE + timedelta(hours=1) + + def test_duration_longer_than_horizon_falls_back_to_now(self): + forecast = _forecast([300, 100]) + start, mean = best_window(forecast, timedelta(hours=10)) + assert start == BASE + assert mean == 300 From 255cd6479d931ba0203808dd7c7a5a901020278f Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 13 Aug 2026 07:11:26 +0200 Subject: [PATCH 02/12] docs: document the codecarbon wait command Add a CLI reference section for `codecarbon wait` covering every flag and its default, the forecast requirements, and the run-now degradation when no forecast is available, plus one cross-link from the CLI tutorial. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/cli.md | 64 +++++++++++++++++++++++++++++++++++++++++++ docs/tutorials/cli.md | 1 + 2 files changed, 65 insertions(+) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71ba05a85..de12a0e7d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -88,6 +88,70 @@ codecarbon monitor -- node app.js --port 8080 Same options as `codecarbon monitor` apply (see above). +### `codecarbon wait -- ` + +Wait for the greenest window in the carbon intensity forecast, then run a command under measurement. + +**Usage:** +```bash +codecarbon wait [OPTIONS] -- +``` + +CodeCarbon fetches an hourly carbon intensity forecast for your location from +[Electricity Maps](https://api.electricitymaps.com), picks the start time that minimises the +average intensity over the expected job length, sleeps until then, and finally hands the command +to `codecarbon monitor` — so measurement, CSV output and exit-code propagation are identical. + +This is a sleep, not a scheduler: the process stays in the foreground and does not fork, daemonise +or persist across a reboot. For deferral that must survive a reboot, use cron, systemd or Airflow. +Pressing `Ctrl+C` during the wait does not abort — it starts the job immediately. The emissions +tracker only starts after the sleep, so a waiting process holds no lock. + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `--duration` | string | 1h | Expected job length, e.g. `90m`, `2h`, `1h30m`, or a plain number of seconds | +| `--deadline` | string | 12h | Maximum delay before the job must start | +| `--threshold` | float | - | gCO2e/kWh at or below which the job starts immediately, without waiting | +| `--dry-run` | flag | false | Print the recommendation and exit without waiting or running | +| `--measure-power-secs` | int | 10 | Interval between two measures | +| `--log-level` | choice | error | Log level: critical, error, warning, info, debug | + +**Examples:** +```bash +# Print the recommendation and exit +codecarbon wait --dry-run --deadline 24h --duration 90m + +# Block until the greenest window, then run under measurement +codecarbon wait --deadline 12h --duration 2h -- python train.py + +# Start straight away if the grid is already below 100 gCO2e/kWh +codecarbon wait --threshold 100 --deadline 6h -- bash benchmark.sh +``` + +The dry run prints the chosen window, for example: + +```console +$ codecarbon wait --dry-run --deadline 24h --duration 90m +🌱 Best start: 2026-08-13 03:00 UTC (112 gCO2e/kWh, now: 341) -> saves ~67% +``` + +**Requirements:** + +A forecast is only available with an `electricitymaps_api_token` (the `co2_signal_api_token` key +is also accepted) — see [Electricity Maps API Token](../how-to/configuration.md#electricity-maps-api-token). +The location is detected automatically from your IP address; there is no offline or +`--country-iso-code` option for this command. + +**When no forecast is available:** + +A forecast is an optimisation, never a requirement — the job is never blocked on a missing +credential. If no token is configured, or the API returns an error, a malformed payload or an +empty forecast, CodeCarbon prints `no forecast available, running now.` and starts the command +straight away. The same applies when no complete window fits before the deadline, or when the +forecast says now is already the greenest moment. + ### `codecarbon detect` Detect and print hardware information. diff --git a/docs/tutorials/cli.md b/docs/tutorials/cli.md index 55f42ffb8..0e3528da8 100644 --- a/docs/tutorials/cli.md +++ b/docs/tutorials/cli.md @@ -134,6 +134,7 @@ You've now learned how to track emissions from the command line. Next steps: - **Track in Python**: Use the [Python API tutorial](python-api.md) for fine-grained tracking within your code. - **Send to Dashboard**: Learn how to [send data to the CodeCarbon dashboard](../how-to/cloud-api.md). - **Configure Details**: See the [configuration guide](../how-to/configuration.md) for advanced options like proxy setup. +- **Run When the Grid Is Green**: Defer a job to the cleanest hours with [`codecarbon wait`](../reference/cli.md#codecarbon-wait-command). ## See Also From cf9cad7293099b8c3906daaf39a66feac388da22 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 13 Aug 2026 07:28:11 +0200 Subject: [PATCH 03/12] refactor: route the forecast through the shared Electricity Maps client `codecarbon wait` had its own HTTP path to Electricity Maps. It now goes through `electricitymaps_api.request`, so the token lookup, the request plumbing and the exponential failure cooldown are shared with the current-intensity path: a failing API is backed off once, process-wide. The forecast response is deliberately not put in the intensity cache. That cache exists for a value refetched on every measurement tick with a 300 s TTL; a forecast is fetched once per `wait` invocation and has a completely different useful lifetime. `get_forecast` still never raises: a cooldown is just one more reason to return None and run the job now. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/wait.py | 7 ++--- codecarbon/core/intensity_forecast.py | 39 +++++++++++---------------- tests/test_intensity_forecast.py | 20 +++++++++++++- 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/codecarbon/cli/wait.py b/codecarbon/cli/wait.py index e5dbc83c4..50c789d62 100644 --- a/codecarbon/cli/wait.py +++ b/codecarbon/cli/wait.py @@ -71,7 +71,7 @@ def wait_for_green_window( codecarbon wait --deadline 12h --duration 2h -- python train.py """ from codecarbon.cli.monitor import run_and_monitor - from codecarbon.core.config import get_hierarchical_config + from codecarbon.core.electricitymaps_api import resolve_token from codecarbon.external.logger import set_logger_level set_logger_level(log_level) @@ -83,10 +83,7 @@ def wait_for_green_window( print(f"ERROR: {e}", file=sys.stderr) raise typer.Exit(1) - config = get_hierarchical_config() - token = config.get("electricitymaps_api_token") or config.get( - "co2_signal_api_token" - ) + token = resolve_token() window = find_green_window(job_duration, max_delay, token) delay_seconds = 0.0 diff --git a/codecarbon/core/intensity_forecast.py b/codecarbon/core/intensity_forecast.py index b1150f7ef..31a84476f 100644 --- a/codecarbon/core/intensity_forecast.py +++ b/codecarbon/core/intensity_forecast.py @@ -5,18 +5,25 @@ returns ``None`` and every caller must degrade to "run now" -- a job is never blocked on a missing credential. +HTTP goes through `codecarbon.core.electricitymaps_api.request`, so a failing +API backs off once for the whole process instead of once per caller. The +forecast response itself is not cached: it is fetched once per `codecarbon +wait` invocation, and its useful lifetime is nothing like the current +intensity's five-minute TTL. + Once pluggable intensity providers land (see issue #1356), `get_forecast` -should become an optional `forecast()` method on the provider protocol rather -than a second HTTP client. +should become an optional `forecast()` method on the provider protocol. """ from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Tuple - -import requests +from typing import List, Optional, Tuple -from codecarbon.core.electricitymaps_api import ELECTRICITYMAPS_API_TIMEOUT +from codecarbon.core.electricitymaps_api import ( + clear_cooldown, + location_params, + request, +) from codecarbon.external.geography import GeoMetadata from codecarbon.external.logger import logger @@ -37,13 +44,6 @@ class Forecast: fetched_at: datetime -def _location_params(geo: GeoMetadata) -> Dict[str, Any]: - """Build the Electricity Maps location query, as `get_emissions` does.""" - if geo.latitude: - return {"lat": geo.latitude, "lon": geo.longitude} - return {"countryCode": geo.country_2letter_iso_code} - - def _parse_datetime(value: str) -> datetime: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) if parsed.tzinfo is None: @@ -69,17 +69,7 @@ def get_forecast( return None try: - resp = requests.get( - FORECAST_URL, - params=_location_params(geo), - headers={"auth-token": token}, - timeout=ELECTRICITYMAPS_API_TIMEOUT, - ) - if resp.status_code != 200: - body = resp.json() - raise ValueError(body.get("error") or body.get("message") or resp.text) - - data = resp.json() + data = request(FORECAST_URL, location_params(geo), token) horizon_end = datetime.now(timezone.utc) + timedelta(hours=horizon_hours) points = [ IntensityPoint( @@ -96,6 +86,7 @@ def get_forecast( if not points: raise ValueError("No usable forecast points in response") + clear_cooldown() return Forecast( zone=data.get("zone", ""), points=points, diff --git a/tests/test_intensity_forecast.py b/tests/test_intensity_forecast.py index 849b8948f..f1ef46c68 100644 --- a/tests/test_intensity_forecast.py +++ b/tests/test_intensity_forecast.py @@ -3,7 +3,7 @@ import responses -from codecarbon.core import intensity_forecast +from codecarbon.core import electricitymaps_api, intensity_forecast from codecarbon.core.intensity_forecast import ( Forecast, IntensityPoint, @@ -45,6 +45,10 @@ def _payload(values, start=None): class TestGetForecast(unittest.TestCase): def setUp(self) -> None: + # The forecast shares the Electricity Maps failure cooldown with the + # current-intensity path, so a failing test must not starve the next. + electricitymaps_api.reset_cache() + self.addCleanup(electricitymaps_api.reset_cache) self._geo = GeoMetadata( country_iso_code="FRA", country_name="France", @@ -63,6 +67,20 @@ def setUp(self) -> None: def test_no_token_returns_none_without_calling_api(self): assert get_forecast(self._geo, token=None) is None + @responses.activate + def test_shared_cooldown_skips_the_request(self): + # A failure on the current-intensity path must back the forecast off + # too: no HTTP request, and still a None instead of a raise. + electricitymaps_api._start_cooldown() + responses.add( + responses.GET, + intensity_forecast.FORECAST_URL, + json=_payload([100]), + status=200, + ) + assert get_forecast(self._geo, token="tok") is None + assert len(responses.calls) == 0 + @responses.activate def test_parses_forecast(self): responses.add( From e72d6a5bb373781171c3cfb61a912560d4c69260 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Sun, 16 Aug 2026 10:26:18 +0200 Subject: [PATCH 04/12] fix(wait): deadline caps the start time, not the window end `--deadline` is documented as the maximum delay before the job must start, but `best_window` rejected any window finishing after it, so `--deadline 12h --duration 2h` only searched 10h of start times. The documented semantics win: the deadline now bounds the start, the horizon request is widened to deadline + duration, and the docs and tests say so. Also stop stripping every argument equal to "wait" (it mangled `codecarbon wait -- make wait`; only a leading one is ours), and compare `--threshold` against the live carbon intensity instead of the first forecast point. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/wait.py | 31 +++++++++++++++++----- codecarbon/core/intensity_forecast.py | 10 ++++--- docs/reference/cli.md | 9 ++++--- tests/cli/test_wait.py | 38 ++++++++++++++++++++++++++- tests/test_intensity_forecast.py | 16 ++++++++--- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/codecarbon/cli/wait.py b/codecarbon/cli/wait.py index 50c789d62..ada4a3676 100644 --- a/codecarbon/cli/wait.py +++ b/codecarbon/cli/wait.py @@ -4,11 +4,12 @@ import sys import time from datetime import datetime, timedelta, timezone -from typing import Optional import typer from rich import print +from codecarbon.external.logger import logger + _DURATION_RE = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") @@ -27,21 +28,33 @@ def parse_duration(value: str) -> timedelta: def find_green_window( duration: timedelta, deadline: timedelta, - token: Optional[str], + token: str | None, ): """Return (start, intensity, now_intensity) or None when we should run now.""" + from codecarbon.core import electricitymaps_api from codecarbon.core.intensity_forecast import best_window, get_forecast from codecarbon.external.geography import GeoMetadata from codecarbon.input import DataSource geo = GeoMetadata.from_geo_js(DataSource().geo_js_url) - forecast = get_forecast(geo, token=token, horizon_hours=_ceil_hours(deadline)) + # A window may start as late as the deadline, so the forecast must cover + # the deadline plus one job length. + forecast = get_forecast( + geo, token=token, horizon_hours=_ceil_hours(deadline + duration) + ) if forecast is None: return None + try: + now_intensity = electricitymaps_api.get_carbon_intensity(geo, token or "") + except Exception as e: + # The forecast's first point is a stand-in, not the live value. + logger.debug(f"wait: current intensity unavailable ({e}), using the forecast.") + now_intensity = forecast.points[0].g_co2e_per_kwh + now = datetime.now(timezone.utc) start, intensity = best_window(forecast, duration, deadline=now + deadline) - return start, intensity, forecast.points[0].g_co2e_per_kwh + return start, intensity, now_intensity def _ceil_hours(delta: timedelta) -> int: @@ -52,7 +65,7 @@ def wait_for_green_window( ctx: typer.Context, duration: str = "1h", deadline: str = "12h", - threshold: Optional[float] = None, + threshold: float | None = None, dry_run: bool = False, log_level: str = "error", **tracker_args, @@ -127,6 +140,10 @@ def wait_for_green_window( except KeyboardInterrupt: print("\nāš ļø Wait interrupted, starting now.", file=sys.stderr) - # Strip our own subcommand name so `run_and_monitor` sees only the command. - ctx.args = [arg for arg in getattr(ctx, "args", []) if arg != "wait"] + # Strip our own subcommand name -- only in first position, so a user + # command that legitimately contains the word "wait" survives intact. + args = list(getattr(ctx, "args", [])) + if args and args[0] == "wait": + args = args[1:] + ctx.args = args run_and_monitor(ctx, log_level=log_level, **tracker_args) diff --git a/codecarbon/core/intensity_forecast.py b/codecarbon/core/intensity_forecast.py index 31a84476f..b95a724a8 100644 --- a/codecarbon/core/intensity_forecast.py +++ b/codecarbon/core/intensity_forecast.py @@ -107,9 +107,11 @@ def best_window( ) -> Tuple[datetime, float]: """Start time minimising mean intensity over `duration`, and that mean. - Only windows that both start at or after the first forecast point and - finish before `deadline` are considered. Returns the earliest point and its - intensity when no complete window fits, so "just run it" is the default. + `deadline` is the latest acceptable *start* time -- the job may run past + it. Only windows starting at or after the first forecast point, at or + before `deadline`, and fully covered by the forecast are considered. + Returns the earliest point and its intensity when no complete window fits, + so "just run it" is the default. """ points = forecast.points fallback = (points[0].at, points[0].g_co2e_per_kwh) @@ -123,7 +125,7 @@ def best_window( window_end = start.at + duration if window_end > covered_until: break - if deadline is not None and window_end > deadline: + if deadline is not None and start.at > deadline: break # ponytail: linear rescan per start, fine for hourly points over a few # days; use a running sum if horizons ever grow by orders of magnitude. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index de12a0e7d..89600ea51 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -112,7 +112,7 @@ tracker only starts after the sleep, so a waiting process holds no lock. | Option | Type | Default | Description | |--------|------|---------|-------------| | `--duration` | string | 1h | Expected job length, e.g. `90m`, `2h`, `1h30m`, or a plain number of seconds | -| `--deadline` | string | 12h | Maximum delay before the job must start | +| `--deadline` | string | 12h | Maximum delay before the job must start; the job itself may finish after it | | `--threshold` | float | - | gCO2e/kWh at or below which the job starts immediately, without waiting | | `--dry-run` | flag | false | Print the recommendation and exit without waiting or running | | `--measure-power-secs` | int | 10 | Interval between two measures | @@ -149,8 +149,11 @@ The location is detected automatically from your IP address; there is no offline A forecast is an optimisation, never a requirement — the job is never blocked on a missing credential. If no token is configured, or the API returns an error, a malformed payload or an empty forecast, CodeCarbon prints `no forecast available, running now.` and starts the command -straight away. The same applies when no complete window fits before the deadline, or when the -forecast says now is already the greenest moment. +straight away. The same applies when the forecast covers no complete window, or when it says now is already +the greenest moment. + +`--deadline` bounds the *start* time, not the end: `--deadline 12h --duration 2h` considers every +start in the next 12 hours, so the job may still be running 14 hours from now. ### `codecarbon detect` diff --git a/tests/cli/test_wait.py b/tests/cli/test_wait.py index ddc532a81..aa1322520 100644 --- a/tests/cli/test_wait.py +++ b/tests/cli/test_wait.py @@ -33,12 +33,16 @@ def no_network(monkeypatch): ) -def _patch_forecast(monkeypatch, values): +def _patch_forecast(monkeypatch, values, now_intensity=None): now = datetime.now(timezone.utc) monkeypatch.setattr( "codecarbon.core.intensity_forecast.get_forecast", lambda geo, **kwargs: _forecast(values, now), ) + monkeypatch.setattr( + "codecarbon.core.electricitymaps_api.get_carbon_intensity", + lambda geo, token="": values[0] if now_intensity is None else now_intensity, + ) @pytest.mark.parametrize( @@ -126,6 +130,38 @@ def test_threshold_short_circuits_the_wait(monkeypatch, capsys, no_network): assert "running now" in capsys.readouterr().out +def test_threshold_uses_the_live_intensity_not_the_forecast( + monkeypatch, capsys, no_network +): + # The first forecast point is above the threshold, the live grid is below. + _patch_forecast(monkeypatch, [300, 300, 100, 100], now_intensity=120) + monkeypatch.setattr(wait_module.time, "sleep", lambda s: pytest.fail("slept")) + monkeypatch.setattr( + "codecarbon.cli.monitor.run_and_monitor", lambda ctx, **kwargs: None + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=[]), duration="1h", deadline="6h", threshold=150 + ) + + assert "running now" in capsys.readouterr().out + + +def test_only_the_leading_subcommand_name_is_stripped(monkeypatch, no_network): + _patch_forecast(monkeypatch, [100, 300, 300]) + called = {} + monkeypatch.setattr( + "codecarbon.cli.monitor.run_and_monitor", + lambda ctx, **kwargs: called.setdefault("args", list(ctx.args)), + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=["wait", "--", "make", "wait"]), duration="1h" + ) + + assert called["args"] == ["--", "make", "wait"] + + def test_sleeps_until_the_green_window_then_delegates(monkeypatch, no_network): _patch_forecast(monkeypatch, [300, 300, 100, 100, 300]) slept = [] diff --git a/tests/test_intensity_forecast.py b/tests/test_intensity_forecast.py index f1ef46c68..1b0151ee6 100644 --- a/tests/test_intensity_forecast.py +++ b/tests/test_intensity_forecast.py @@ -183,18 +183,28 @@ def test_decreasing_series_picks_last_complete_window(self): start, _ = best_window(forecast, timedelta(hours=2)) assert start == BASE + timedelta(hours=3) - def test_deadline_shorter_than_duration_falls_back_to_now(self): + def test_deadline_before_the_next_point_leaves_only_now(self): forecast = _forecast([300, 100, 100]) start, mean = best_window( forecast, timedelta(hours=2), deadline=BASE + timedelta(minutes=30) ) assert start == BASE - assert mean == 300 + assert mean == 200 + + def test_deadline_caps_the_start_time_not_the_end(self): + # The deadline is the latest acceptable start: a window starting at it + # is allowed even though it finishes afterwards. + forecast = _forecast([300, 200, 50, 50]) + start, mean = best_window( + forecast, timedelta(hours=2), deadline=BASE + timedelta(hours=2) + ) + assert start == BASE + timedelta(hours=2) + assert mean == 50 def test_deadline_restricts_the_search(self): forecast = _forecast([300, 200, 50, 50]) start, _ = best_window( - forecast, timedelta(hours=1), deadline=BASE + timedelta(hours=2) + forecast, timedelta(hours=1), deadline=BASE + timedelta(hours=1) ) assert start == BASE + timedelta(hours=1) From 7a17560100953b6d46734d986e2b92437bb9612d Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Sun, 16 Aug 2026 10:46:34 +0200 Subject: [PATCH 05/12] feat(wait): one API call, and --finish-by `find_green_window` fetched the forecast and then asked /latest for the current intensity, a second HTTP call whose value only fed a "saves ~X%" line and the --threshold short-circuit. The forecast's first point is that same period, so use it and drop the call, the fallback and the try/except with it. Add --finish-by as the complement to --deadline: --deadline bounds the start, --finish-by bounds the end and is what most people mean. It is a subtraction, not a second search path. The Electricity Maps request extraction this branch used to carry now lives in its base branch (#1358) where it belongs, so `clear_cooldown` is gone: request() clears its own location's cooldown on a usable response. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/main.py | 7 +++++ codecarbon/cli/wait.py | 33 ++++++++++++-------- codecarbon/core/intensity_forecast.py | 9 ++---- docs/reference/cli.md | 11 ++++++- tests/cli/test_wait.py | 44 +++++++++++++++++++++------ tests/test_intensity_forecast.py | 6 +++- 6 files changed, 79 insertions(+), 31 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index a5c7758e9..2f27b131d 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -480,6 +480,12 @@ def wait( str, typer.Option(help="Maximum delay before the job must start."), ] = "12h", + finish_by: Annotated[ + Optional[str], + typer.Option( + help="Latest acceptable finish time, e.g. '8h'. Overrides --deadline." + ), + ] = None, threshold: Annotated[ Optional[float], typer.Option(help="gCO2e/kWh at or below which we start immediately."), @@ -509,6 +515,7 @@ def wait( ctx, duration=duration, deadline=deadline, + finish_by=finish_by, threshold=threshold, dry_run=dry_run, log_level=log_level, diff --git a/codecarbon/cli/wait.py b/codecarbon/cli/wait.py index ada4a3676..5417b317d 100644 --- a/codecarbon/cli/wait.py +++ b/codecarbon/cli/wait.py @@ -8,8 +8,6 @@ import typer from rich import print -from codecarbon.external.logger import logger - _DURATION_RE = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") @@ -30,8 +28,12 @@ def find_green_window( deadline: timedelta, token: str | None, ): - """Return (start, intensity, now_intensity) or None when we should run now.""" - from codecarbon.core import electricitymaps_api + """Return (start, intensity, now_intensity) or None when we should run now. + + `now_intensity` is the first forecast point, i.e. the current period as the + forecast sees it. Asking the /latest endpoint for a live value instead + would be a second HTTP call for a number only used to print a percentage. + """ from codecarbon.core.intensity_forecast import best_window, get_forecast from codecarbon.external.geography import GeoMetadata from codecarbon.input import DataSource @@ -45,13 +47,7 @@ def find_green_window( if forecast is None: return None - try: - now_intensity = electricitymaps_api.get_carbon_intensity(geo, token or "") - except Exception as e: - # The forecast's first point is a stand-in, not the live value. - logger.debug(f"wait: current intensity unavailable ({e}), using the forecast.") - now_intensity = forecast.points[0].g_co2e_per_kwh - + now_intensity = forecast.points[0].g_co2e_per_kwh now = datetime.now(timezone.utc) start, intensity = best_window(forecast, duration, deadline=now + deadline) return start, intensity, now_intensity @@ -65,6 +61,7 @@ def wait_for_green_window( ctx: typer.Context, duration: str = "1h", deadline: str = "12h", + finish_by: str | None = None, threshold: float | None = None, dry_run: bool = False, log_level: str = "error", @@ -82,6 +79,9 @@ def wait_for_green_window( # Block until the greenest window, then run under measurement codecarbon wait --deadline 12h --duration 2h -- python train.py + + # Bound the finish time instead of the start + codecarbon wait --finish-by 8h --duration 2h -- python train.py """ from codecarbon.cli.monitor import run_and_monitor from codecarbon.core.electricitymaps_api import resolve_token @@ -91,7 +91,16 @@ def wait_for_green_window( try: job_duration = parse_duration(duration) - max_delay = parse_duration(deadline) + # --deadline bounds the start, --finish-by bounds the end; the search + # only ever needs the latest acceptable start. + if finish_by is not None: + max_delay = parse_duration(finish_by) - job_duration + if max_delay < timedelta(0): + raise ValueError( + f"--finish-by {finish_by} is sooner than --duration {duration}." + ) + else: + max_delay = parse_duration(deadline) except ValueError as e: print(f"ERROR: {e}", file=sys.stderr) raise typer.Exit(1) diff --git a/codecarbon/core/intensity_forecast.py b/codecarbon/core/intensity_forecast.py index b95a724a8..3c6e7acc0 100644 --- a/codecarbon/core/intensity_forecast.py +++ b/codecarbon/core/intensity_forecast.py @@ -9,7 +9,7 @@ API backs off once for the whole process instead of once per caller. The forecast response itself is not cached: it is fetched once per `codecarbon wait` invocation, and its useful lifetime is nothing like the current -intensity's five-minute TTL. +intensity's short TTL. Once pluggable intensity providers land (see issue #1356), `get_forecast` should become an optional `forecast()` method on the provider protocol. @@ -19,11 +19,7 @@ from datetime import datetime, timedelta, timezone from typing import List, Optional, Tuple -from codecarbon.core.electricitymaps_api import ( - clear_cooldown, - location_params, - request, -) +from codecarbon.core.electricitymaps_api import location_params, request from codecarbon.external.geography import GeoMetadata from codecarbon.external.logger import logger @@ -86,7 +82,6 @@ def get_forecast( if not points: raise ValueError("No usable forecast points in response") - clear_cooldown() return Forecast( zone=data.get("zone", ""), points=points, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 89600ea51..27c0c5bbe 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -113,6 +113,7 @@ tracker only starts after the sleep, so a waiting process holds no lock. |--------|------|---------|-------------| | `--duration` | string | 1h | Expected job length, e.g. `90m`, `2h`, `1h30m`, or a plain number of seconds | | `--deadline` | string | 12h | Maximum delay before the job must start; the job itself may finish after it | +| `--finish-by` | string | - | Latest acceptable *finish* time. Overrides `--deadline` with `--finish-by` minus `--duration` | | `--threshold` | float | - | gCO2e/kWh at or below which the job starts immediately, without waiting | | `--dry-run` | flag | false | Print the recommendation and exit without waiting or running | | `--measure-power-secs` | int | 10 | Interval between two measures | @@ -128,6 +129,9 @@ codecarbon wait --deadline 12h --duration 2h -- python train.py # Start straight away if the grid is already below 100 gCO2e/kWh codecarbon wait --threshold 100 --deadline 6h -- bash benchmark.sh + +# The job must be finished within 8 hours, and takes about 2 +codecarbon wait --finish-by 8h --duration 2h -- python train.py ``` The dry run prints the chosen window, for example: @@ -153,7 +157,12 @@ straight away. The same applies when the forecast covers no complete window, or the greenest moment. `--deadline` bounds the *start* time, not the end: `--deadline 12h --duration 2h` considers every -start in the next 12 hours, so the job may still be running 14 hours from now. +start in the next 12 hours, so the job may still be running 14 hours from now. When you mean "this +must be **done** by then", use `--finish-by` instead: `--finish-by 12h --duration 2h` searches +starts in the next 10 hours. Passing a `--finish-by` shorter than `--duration` is an error. + +The `now:` figure in the output is the first point of the forecast, i.e. the current period as the +forecast sees it, not a separate reading of the live grid — `wait` makes exactly one API call. ### `codecarbon detect` diff --git a/tests/cli/test_wait.py b/tests/cli/test_wait.py index aa1322520..9c44d2d5a 100644 --- a/tests/cli/test_wait.py +++ b/tests/cli/test_wait.py @@ -33,16 +33,12 @@ def no_network(monkeypatch): ) -def _patch_forecast(monkeypatch, values, now_intensity=None): +def _patch_forecast(monkeypatch, values): now = datetime.now(timezone.utc) monkeypatch.setattr( "codecarbon.core.intensity_forecast.get_forecast", lambda geo, **kwargs: _forecast(values, now), ) - monkeypatch.setattr( - "codecarbon.core.electricitymaps_api.get_carbon_intensity", - lambda geo, token="": values[0] if now_intensity is None else now_intensity, - ) @pytest.mark.parametrize( @@ -130,11 +126,14 @@ def test_threshold_short_circuits_the_wait(monkeypatch, capsys, no_network): assert "running now" in capsys.readouterr().out -def test_threshold_uses_the_live_intensity_not_the_forecast( - monkeypatch, capsys, no_network -): - # The first forecast point is above the threshold, the live grid is below. - _patch_forecast(monkeypatch, [300, 300, 100, 100], now_intensity=120) +def test_no_second_call_for_the_current_intensity(monkeypatch, capsys, no_network): + # The first forecast point is the "now" value: fetching /latest as well + # would be a second HTTP call just to print a percentage. + _patch_forecast(monkeypatch, [120, 300, 50, 50]) + monkeypatch.setattr( + "codecarbon.core.electricitymaps_api.get_carbon_intensity", + lambda *a, **k: pytest.fail("second live call"), + ) monkeypatch.setattr(wait_module.time, "sleep", lambda s: pytest.fail("slept")) monkeypatch.setattr( "codecarbon.cli.monitor.run_and_monitor", lambda ctx, **kwargs: None @@ -147,6 +146,31 @@ def test_threshold_uses_the_live_intensity_not_the_forecast( assert "running now" in capsys.readouterr().out +def test_finish_by_bounds_the_end_not_the_start(monkeypatch, no_network): + # Trough at +4h, but the job must be done by +3h, so only a start at or + # before +2h is acceptable: the cheapest of those is +1h. + _patch_forecast(monkeypatch, [300, 100, 200, 200, 10, 10]) + slept = [] + monkeypatch.setattr(wait_module.time, "sleep", lambda s: slept.append(s)) + monkeypatch.setattr( + "codecarbon.cli.monitor.run_and_monitor", lambda ctx, **kwargs: None + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=[]), duration="1h", finish_by="3h" + ) + + assert len(slept) == 1 + assert 3600 - 60 < slept[0] <= 3600 + + +def test_finish_by_shorter_than_duration_is_rejected(monkeypatch, capsys, no_network): + with pytest.raises(typer.Exit): + wait_module.wait_for_green_window( + SimpleNamespace(args=[]), duration="4h", finish_by="1h" + ) + + def test_only_the_leading_subcommand_name_is_stripped(monkeypatch, no_network): _patch_forecast(monkeypatch, [100, 300, 300]) called = {} diff --git a/tests/test_intensity_forecast.py b/tests/test_intensity_forecast.py index 1b0151ee6..e39e4cdac 100644 --- a/tests/test_intensity_forecast.py +++ b/tests/test_intensity_forecast.py @@ -71,7 +71,11 @@ def test_no_token_returns_none_without_calling_api(self): def test_shared_cooldown_skips_the_request(self): # A failure on the current-intensity path must back the forecast off # too: no HTTP request, and still a None instead of a raise. - electricitymaps_api._start_cooldown() + electricitymaps_api._start_cooldown( + electricitymaps_api._cache_key( + electricitymaps_api.location_params(self._geo), "tok" + ) + ) responses.add( responses.GET, intensity_forecast.FORECAST_URL, From 5f516b5b55d91a076ad7e7e0776a1791bfdd1a67 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Sun, 16 Aug 2026 14:01:58 +0200 Subject: [PATCH 06/12] docs: trim the codecarbon wait prose Drop the scheduler and forecast aphorisms, keep the Ctrl+C and no-forecast behaviour as plain statements, and remove the em-dashes the CLI reference does not use. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/cli.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 27c0c5bbe..194b9e997 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -100,12 +100,11 @@ codecarbon wait [OPTIONS] -- CodeCarbon fetches an hourly carbon intensity forecast for your location from [Electricity Maps](https://api.electricitymaps.com), picks the start time that minimises the average intensity over the expected job length, sleeps until then, and finally hands the command -to `codecarbon monitor` — so measurement, CSV output and exit-code propagation are identical. +to `codecarbon monitor`, so measurement, CSV output and exit-code propagation are identical. -This is a sleep, not a scheduler: the process stays in the foreground and does not fork, daemonise -or persist across a reboot. For deferral that must survive a reboot, use cron, systemd or Airflow. -Pressing `Ctrl+C` during the wait does not abort — it starts the job immediately. The emissions -tracker only starts after the sleep, so a waiting process holds no lock. +The process stays in the foreground for the whole wait. Pressing `Ctrl+C` during the wait does not +abort: it starts the job immediately. The emissions tracker only starts after the sleep, so a +waiting process holds no lock. **Options:** @@ -144,14 +143,11 @@ $ codecarbon wait --dry-run --deadline 24h --duration 90m **Requirements:** A forecast is only available with an `electricitymaps_api_token` (the `co2_signal_api_token` key -is also accepted) — see [Electricity Maps API Token](../how-to/configuration.md#electricity-maps-api-token). +is also accepted), see [Electricity Maps API Token](../how-to/configuration.md#electricity-maps-api-token). The location is detected automatically from your IP address; there is no offline or `--country-iso-code` option for this command. -**When no forecast is available:** - -A forecast is an optimisation, never a requirement — the job is never blocked on a missing -credential. If no token is configured, or the API returns an error, a malformed payload or an +If no token is configured, or the API returns an error, a malformed payload or an empty forecast, CodeCarbon prints `no forecast available, running now.` and starts the command straight away. The same applies when the forecast covers no complete window, or when it says now is already the greenest moment. @@ -162,7 +158,7 @@ must be **done** by then", use `--finish-by` instead: `--finish-by 12h --duratio starts in the next 10 hours. Passing a `--finish-by` shorter than `--duration` is an error. The `now:` figure in the output is the first point of the forecast, i.e. the current period as the -forecast sees it, not a separate reading of the live grid — `wait` makes exactly one API call. +forecast sees it, not a separate reading of the live grid: `wait` makes exactly one API call. ### `codecarbon detect` From f233bfe8a5f10375a817da0e5d3467c76616e735 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 19 Aug 2026 11:12:43 +0200 Subject: [PATCH 07/12] fix(wait): abort on Ctrl-C instead of starting the job --- codecarbon/cli/wait.py | 6 ++++-- docs/reference/cli.md | 6 +++--- tests/cli/test_wait.py | 12 +++++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/codecarbon/cli/wait.py b/codecarbon/cli/wait.py index 5417b317d..015e5937d 100644 --- a/codecarbon/cli/wait.py +++ b/codecarbon/cli/wait.py @@ -142,12 +142,14 @@ def wait_for_green_window( if delay_seconds > 0: print( - f" Waiting {delay_seconds / 3600:.1f}h before starting. Ctrl-C to run now." + f" Waiting {delay_seconds / 3600:.1f}h before starting. " + "Ctrl-C to abort." ) try: time.sleep(delay_seconds) except KeyboardInterrupt: - print("\nāš ļø Wait interrupted, starting now.", file=sys.stderr) + print("\nāš ļø Wait aborted, the command was not run.", file=sys.stderr) + raise typer.Exit(130) # Strip our own subcommand name -- only in first position, so a user # command that legitimately contains the word "wait" survives intact. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 194b9e997..eca3da65e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -102,9 +102,9 @@ CodeCarbon fetches an hourly carbon intensity forecast for your location from average intensity over the expected job length, sleeps until then, and finally hands the command to `codecarbon monitor`, so measurement, CSV output and exit-code propagation are identical. -The process stays in the foreground for the whole wait. Pressing `Ctrl+C` during the wait does not -abort: it starts the job immediately. The emissions tracker only starts after the sleep, so a -waiting process holds no lock. +The process stays in the foreground for the whole wait. Pressing `Ctrl+C` during the wait aborts: +the command is not run and the exit code is 130. The emissions tracker only starts after the +sleep, so a waiting process holds no lock. **Options:** diff --git a/tests/cli/test_wait.py b/tests/cli/test_wait.py index 9c44d2d5a..bdbcfb893 100644 --- a/tests/cli/test_wait.py +++ b/tests/cli/test_wait.py @@ -209,7 +209,7 @@ def test_sleeps_until_the_green_window_then_delegates(monkeypatch, no_network): assert called["measure_power_secs"] == 15 -def test_keyboard_interrupt_during_wait_runs_immediately(monkeypatch, no_network): +def test_keyboard_interrupt_during_wait_aborts(monkeypatch, no_network): _patch_forecast(monkeypatch, [300, 300, 100, 100, 300]) def _interrupt(seconds): @@ -222,8 +222,10 @@ def _interrupt(seconds): lambda ctx, **kwargs: called.setdefault("ran", True), ) - wait_module.wait_for_green_window( - SimpleNamespace(args=["python", "train.py"]), duration="2h", deadline="6h" - ) + with pytest.raises(typer.Exit) as exc_info: + wait_module.wait_for_green_window( + SimpleNamespace(args=["python", "train.py"]), duration="2h", deadline="6h" + ) - assert called["ran"] is True + assert exc_info.value.exit_code == 130 + assert called == {} From 2649487e7a856898bc6ea2282620ddab821d8243 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 19 Aug 2026 11:12:57 +0200 Subject: [PATCH 08/12] fix(wait): use the configured country before geolocating by IP --- codecarbon/cli/wait.py | 20 +++++++++++++++++++- docs/reference/cli.md | 4 ++-- tests/cli/test_wait.py | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/codecarbon/cli/wait.py b/codecarbon/cli/wait.py index 015e5937d..df6370a97 100644 --- a/codecarbon/cli/wait.py +++ b/codecarbon/cli/wait.py @@ -34,11 +34,29 @@ def find_green_window( forecast sees it. Asking the /latest endpoint for a live value instead would be a second HTTP call for a number only used to print a percentage. """ + import pycountry + + from codecarbon.core.config import get_hierarchical_config from codecarbon.core.intensity_forecast import best_window, get_forecast from codecarbon.external.geography import GeoMetadata from codecarbon.input import DataSource - geo = GeoMetadata.from_geo_js(DataSource().geo_js_url) + # The tracker honours a configured country, so the forecast must too: + # geolocating by IP would silently forecast the wrong grid. + config = get_hierarchical_config() + configured = config.get("country_iso_code") + country = ( + pycountry.countries.get(alpha_3=configured.upper()) if configured else None + ) + if country: + geo = GeoMetadata( + country_iso_code=country.alpha_3, + country_name=country.name, + region=config.get("region"), + country_2letter_iso_code=country.alpha_2, + ) + else: + geo = GeoMetadata.from_geo_js(DataSource().geo_js_url) # A window may start as late as the deadline, so the forecast must cover # the deadline plus one job length. forecast = get_forecast( diff --git a/docs/reference/cli.md b/docs/reference/cli.md index eca3da65e..54001a784 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -144,8 +144,8 @@ $ codecarbon wait --dry-run --deadline 24h --duration 90m A forecast is only available with an `electricitymaps_api_token` (the `co2_signal_api_token` key is also accepted), see [Electricity Maps API Token](../how-to/configuration.md#electricity-maps-api-token). -The location is detected automatically from your IP address; there is no offline or -`--country-iso-code` option for this command. +The location comes from `country_iso_code` in your configuration when it is set, and is otherwise +detected from your IP address. If no token is configured, or the API returns an error, a malformed payload or an empty forecast, CodeCarbon prints `no forecast available, running now.` and starts the command diff --git a/tests/cli/test_wait.py b/tests/cli/test_wait.py index bdbcfb893..73e61eed7 100644 --- a/tests/cli/test_wait.py +++ b/tests/cli/test_wait.py @@ -229,3 +229,25 @@ def _interrupt(seconds): assert exc_info.value.exit_code == 130 assert called == {} + + +def test_configured_country_is_preferred_over_geolocation(monkeypatch): + monkeypatch.setattr( + "codecarbon.external.geography.GeoMetadata.from_geo_js", + classmethod(lambda cls, url: pytest.fail("geolocated despite a config")), + ) + monkeypatch.setattr( + "codecarbon.core.config.get_hierarchical_config", + lambda: {"country_iso_code": "fra", "region": "ile-de-france"}, + ) + seen = {} + monkeypatch.setattr( + "codecarbon.core.intensity_forecast.get_forecast", + lambda geo, **kwargs: seen.update(geo=geo), + ) + + wait_module.find_green_window(timedelta(hours=1), timedelta(hours=2), "tok") + + assert seen["geo"].country_iso_code == "FRA" + assert seen["geo"].country_2letter_iso_code == "FR" + assert seen["geo"].region == "ile-de-france" From 3576d229bbee9fd0ba5dad48d12566590f1c0bb3 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 19 Aug 2026 11:13:03 +0200 Subject: [PATCH 09/12] refactor(wait): let run_and_monitor strip the subcommand prefix --- codecarbon/cli/monitor.py | 2 +- codecarbon/cli/wait.py | 6 ------ tests/cli/test_monitor.py | 2 +- tests/cli/test_wait.py | 11 +++++------ 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/codecarbon/cli/monitor.py b/codecarbon/cli/monitor.py index 41b3ca353..3bcb12857 100644 --- a/codecarbon/cli/monitor.py +++ b/codecarbon/cli/monitor.py @@ -55,7 +55,7 @@ def run_and_monitor( # Get the command from remaining args (strip nested subcommand / `--` leftovers) command = list(getattr(ctx, "args", None) or []) - while command and command[0] in ("monitor", "--"): + while command and command[0] in ("monitor", "wait", "--"): command.pop(0) if not command: diff --git a/codecarbon/cli/wait.py b/codecarbon/cli/wait.py index df6370a97..ab63d129e 100644 --- a/codecarbon/cli/wait.py +++ b/codecarbon/cli/wait.py @@ -169,10 +169,4 @@ def wait_for_green_window( print("\nāš ļø Wait aborted, the command was not run.", file=sys.stderr) raise typer.Exit(130) - # Strip our own subcommand name -- only in first position, so a user - # command that legitimately contains the word "wait" survives intact. - args = list(getattr(ctx, "args", [])) - if args and args[0] == "wait": - args = args[1:] - ctx.args = args run_and_monitor(ctx, log_level=log_level, **tracker_args) diff --git a/tests/cli/test_monitor.py b/tests/cli/test_monitor.py index 0a9bda365..2e8a36287 100644 --- a/tests/cli/test_monitor.py +++ b/tests/cli/test_monitor.py @@ -53,7 +53,7 @@ def wait(self): with pytest.raises(typer.Exit) as exc_info: monitor_module.run_and_monitor( - SimpleNamespace(args=["monitor", "--", "echo", "hi"]) + SimpleNamespace(args=["wait", "monitor", "--", "echo", "hi"]) ) assert exc_info.value.exit_code == 0 diff --git a/tests/cli/test_wait.py b/tests/cli/test_wait.py index 73e61eed7..452258b13 100644 --- a/tests/cli/test_wait.py +++ b/tests/cli/test_wait.py @@ -15,8 +15,6 @@ def _forecast(values, start): IntensityPoint(at=start + timedelta(hours=i), g_co2e_per_kwh=v) for i, v in enumerate(values) ], - source="test", - fetched_at=start, ) @@ -103,7 +101,7 @@ def test_no_forecast_runs_now(monkeypatch, capsys, no_network): ) assert slept == [] - assert called["args"] == ["--", "python", "train.py"] + assert called["args"] == ["wait", "--", "python", "train.py"] assert "no forecast available" in capsys.readouterr().out @@ -171,7 +169,8 @@ def test_finish_by_shorter_than_duration_is_rejected(monkeypatch, capsys, no_net ) -def test_only_the_leading_subcommand_name_is_stripped(monkeypatch, no_network): +def test_the_context_args_reach_the_monitor_untouched(monkeypatch, no_network): + # run_and_monitor strips its own subcommand prefixes, so wait must not. _patch_forecast(monkeypatch, [100, 300, 300]) called = {} monkeypatch.setattr( @@ -183,7 +182,7 @@ def test_only_the_leading_subcommand_name_is_stripped(monkeypatch, no_network): SimpleNamespace(args=["wait", "--", "make", "wait"]), duration="1h" ) - assert called["args"] == ["--", "make", "wait"] + assert called["args"] == ["wait", "--", "make", "wait"] def test_sleeps_until_the_green_window_then_delegates(monkeypatch, no_network): @@ -205,7 +204,7 @@ def test_sleeps_until_the_green_window_then_delegates(monkeypatch, no_network): assert len(slept) == 1 assert 2 * 3600 - 60 < slept[0] <= 2 * 3600 - assert called["args"] == ["python", "train.py"] + assert called["args"] == ["wait", "python", "train.py"] assert called["measure_power_secs"] == 15 From e413d7bb9a4d344a0fa7d9aa6b9fc3367f1ec292 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 19 Aug 2026 11:13:27 +0200 Subject: [PATCH 10/12] fix: weight forecast points by their overlap with the window --- codecarbon/core/intensity_forecast.py | 25 ++++++++++++++++++------- tests/test_intensity_forecast.py | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/codecarbon/core/intensity_forecast.py b/codecarbon/core/intensity_forecast.py index 3c6e7acc0..6ef333461 100644 --- a/codecarbon/core/intensity_forecast.py +++ b/codecarbon/core/intensity_forecast.py @@ -111,8 +111,13 @@ def best_window( points = forecast.points fallback = (points[0].at, points[0].g_co2e_per_kwh) - # The forecast covers up to one step past its last point. - step = points[1].at - points[0].at if len(points) > 1 else duration + # The forecast covers up to one step past its last point. Gaps are not + # guaranteed uniform, so the smallest one is the safe assumption. + step = ( + min(b.at - a.at for a, b in zip(points, points[1:])) + if len(points) > 1 + else duration + ) covered_until = points[-1].at + step best: Optional[Tuple[datetime, float]] = None @@ -124,12 +129,18 @@ def best_window( break # ponytail: linear rescan per start, fine for hourly points over a few # days; use a running sum if horizons ever grow by orders of magnitude. - covered = [ - point.g_co2e_per_kwh - for point in points[start_index:] - if point.at < window_end + # Each point holds until the next one, so weight it by how much of its + # period falls inside the window: an hourly point half-covered by the + # window's end must not count as a full hour. + covered = [point for point in points[start_index:] if point.at < window_end] + ends = [point.at for point in covered[1:]] + [window_end] + weights = [ + (min(end, window_end) - point.at).total_seconds() + for point, end in zip(covered, ends) ] - mean = sum(covered) / len(covered) + mean = sum( + point.g_co2e_per_kwh * weight for point, weight in zip(covered, weights) + ) / sum(weights) if best is None or mean < best[1]: best = (start.at, mean) diff --git a/tests/test_intensity_forecast.py b/tests/test_intensity_forecast.py index e39e4cdac..c21334129 100644 --- a/tests/test_intensity_forecast.py +++ b/tests/test_intensity_forecast.py @@ -217,3 +217,22 @@ def test_duration_longer_than_horizon_falls_back_to_now(self): start, mean = best_window(forecast, timedelta(hours=10)) assert start == BASE assert mean == 300 + + def test_partial_last_hour_is_weighted_by_overlap(self): + # 90 minutes over hourly points: the second hour only counts for half. + forecast = _forecast([100, 300, 300]) + start, mean = best_window(forecast, timedelta(minutes=90)) + assert start == BASE + assert mean == (100 * 60 + 300 * 30) / 90 + + def test_irregular_gaps_do_not_stretch_the_coverage(self): + points = [ + IntensityPoint(at=BASE, g_co2e_per_kwh=300), + IntensityPoint(at=BASE + timedelta(hours=3), g_co2e_per_kwh=100), + IntensityPoint(at=BASE + timedelta(hours=4), g_co2e_per_kwh=50), + ] + forecast = Forecast(zone="FR", points=points, source="test", fetched_at=BASE) + # The forecast covers one hour, not three, past its last point, so the + # cheapest-looking window (starting at the last point) is not complete. + start, _ = best_window(forecast, timedelta(hours=2)) + assert start == BASE + timedelta(hours=3) From 917c1e073a27e3f954624a4456930f7dc81796d9 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 19 Aug 2026 11:13:36 +0200 Subject: [PATCH 11/12] fix: log the exception type when a forecast fetch fails --- codecarbon/core/intensity_forecast.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codecarbon/core/intensity_forecast.py b/codecarbon/core/intensity_forecast.py index 6ef333461..ebf1d57f5 100644 --- a/codecarbon/core/intensity_forecast.py +++ b/codecarbon/core/intensity_forecast.py @@ -90,7 +90,8 @@ def get_forecast( ) except Exception as e: logger.error( - f"intensity_forecast.get_forecast: {e} >>> Falling back to running now." + f"intensity_forecast.get_forecast: {type(e).__name__}: {e} " + ">>> Falling back to running now." ) return None From 098130e6b3f3faf58f33f8d27462a7d7a6a73d28 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 19 Aug 2026 11:13:51 +0200 Subject: [PATCH 12/12] refactor: drop unused forecast fields and move FORECAST_URL next to URL --- codecarbon/core/electricitymaps_api.py | 1 + codecarbon/core/intensity_forecast.py | 11 +---------- tests/test_intensity_forecast.py | 23 ++++++++++------------- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/codecarbon/core/electricitymaps_api.py b/codecarbon/core/electricitymaps_api.py index d9a8c1d2d..a248d523b 100644 --- a/codecarbon/core/electricitymaps_api.py +++ b/codecarbon/core/electricitymaps_api.py @@ -9,6 +9,7 @@ from codecarbon.external.logger import logger URL: str = "https://api.electricitymaps.com/v3/carbon-intensity/latest" +FORECAST_URL: str = "https://api.electricitymaps.com/v3/carbon-intensity/forecast" ELECTRICITYMAPS_API_TIMEOUT: int = 30 # Grid carbon intensity is published hourly at best, while emissions are computed diff --git a/codecarbon/core/intensity_forecast.py b/codecarbon/core/intensity_forecast.py index ebf1d57f5..caf874c20 100644 --- a/codecarbon/core/intensity_forecast.py +++ b/codecarbon/core/intensity_forecast.py @@ -10,21 +10,16 @@ forecast response itself is not cached: it is fetched once per `codecarbon wait` invocation, and its useful lifetime is nothing like the current intensity's short TTL. - -Once pluggable intensity providers land (see issue #1356), `get_forecast` -should become an optional `forecast()` method on the provider protocol. """ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import List, Optional, Tuple -from codecarbon.core.electricitymaps_api import location_params, request +from codecarbon.core.electricitymaps_api import FORECAST_URL, location_params, request from codecarbon.external.geography import GeoMetadata from codecarbon.external.logger import logger -FORECAST_URL: str = "https://api.electricitymaps.com/v3/carbon-intensity/forecast" - @dataclass(frozen=True) class IntensityPoint: @@ -36,8 +31,6 @@ class IntensityPoint: class Forecast: zone: str points: List[IntensityPoint] # ordered, typically hourly - source: str - fetched_at: datetime def _parse_datetime(value: str) -> datetime: @@ -85,8 +78,6 @@ def get_forecast( return Forecast( zone=data.get("zone", ""), points=points, - source="electricitymaps", - fetched_at=datetime.now(timezone.utc), ) except Exception as e: logger.error( diff --git a/tests/test_intensity_forecast.py b/tests/test_intensity_forecast.py index c21334129..c1aa555b5 100644 --- a/tests/test_intensity_forecast.py +++ b/tests/test_intensity_forecast.py @@ -3,7 +3,7 @@ import responses -from codecarbon.core import electricitymaps_api, intensity_forecast +from codecarbon.core import electricitymaps_api from codecarbon.core.intensity_forecast import ( Forecast, IntensityPoint, @@ -22,8 +22,6 @@ def _forecast(values): IntensityPoint(at=BASE + timedelta(hours=i), g_co2e_per_kwh=v) for i, v in enumerate(values) ], - source="test", - fetched_at=BASE, ) @@ -78,7 +76,7 @@ def test_shared_cooldown_skips_the_request(self): ) responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json=_payload([100]), status=200, ) @@ -89,14 +87,13 @@ def test_shared_cooldown_skips_the_request(self): def test_parses_forecast(self): responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json=_payload([100, 200, 50]), status=200, ) forecast = get_forecast(self._geo, token="tok") assert forecast is not None assert forecast.zone == "FR" - assert forecast.source == "electricitymaps" assert [p.g_co2e_per_kwh for p in forecast.points] == [100, 200, 50] assert all(p.at.tzinfo is not None for p in forecast.points) assert responses.calls[0].request.headers["auth-token"] == "tok" @@ -106,7 +103,7 @@ def test_parses_forecast(self): def test_uses_lat_lon_when_available(self): responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json=_payload([100]), status=200, ) @@ -120,7 +117,7 @@ def test_naive_timestamps_are_treated_as_utc(self): payload["forecast"][0]["datetime"] = "2999-01-01T03:00:00" responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json=payload, status=200, ) @@ -131,7 +128,7 @@ def test_naive_timestamps_are_treated_as_utc(self): def test_horizon_truncates_points(self): responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json=_payload([100, 200, 300, 400]), status=200, ) @@ -142,7 +139,7 @@ def test_horizon_truncates_points(self): def test_error_status_returns_none(self): responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json={"error": "no access"}, status=403, ) @@ -152,7 +149,7 @@ def test_error_status_returns_none(self): def test_malformed_payload_returns_none(self): responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json={"unexpected": True}, status=200, ) @@ -162,7 +159,7 @@ def test_malformed_payload_returns_none(self): def test_empty_forecast_returns_none(self): responses.add( responses.GET, - intensity_forecast.FORECAST_URL, + electricitymaps_api.FORECAST_URL, json={"zone": "FR", "forecast": []}, status=200, ) @@ -231,7 +228,7 @@ def test_irregular_gaps_do_not_stretch_the_coverage(self): IntensityPoint(at=BASE + timedelta(hours=3), g_co2e_per_kwh=100), IntensityPoint(at=BASE + timedelta(hours=4), g_co2e_per_kwh=50), ] - forecast = Forecast(zone="FR", points=points, source="test", fetched_at=BASE) + forecast = Forecast(zone="FR", points=points) # The forecast covers one hour, not three, past its last point, so the # cheapest-looking window (starting at the last point) is not complete. start, _ = best_window(forecast, timedelta(hours=2))