diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..2f27b131d 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -465,6 +465,64 @@ 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", + 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."), + ] = 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, + finish_by=finish_by, + 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/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 new file mode 100644 index 000000000..ab63d129e --- /dev/null +++ b/codecarbon/cli/wait.py @@ -0,0 +1,172 @@ +"""CodeCarbon CLI - Wait Command""" + +import re +import sys +import time +from datetime import datetime, timedelta, timezone + +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: str | None, +): + """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. + """ + 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 + + # 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( + geo, token=token, horizon_hours=_ceil_hours(deadline + duration) + ) + if forecast is None: + return None + + 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 + + +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", + finish_by: str | None = None, + threshold: float | None = 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 + + # 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 + from codecarbon.external.logger import set_logger_level + + set_logger_level(log_level) + + try: + job_duration = parse_duration(duration) + # --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) + + token = resolve_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 abort." + ) + try: + time.sleep(delay_seconds) + except KeyboardInterrupt: + print("\nāš ļø Wait aborted, the command was not run.", file=sys.stderr) + raise typer.Exit(130) + + run_and_monitor(ctx, log_level=log_level, **tracker_args) 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 new file mode 100644 index 000000000..caf874c20 --- /dev/null +++ b/codecarbon/core/intensity_forecast.py @@ -0,0 +1,139 @@ +"""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. + +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 short TTL. +""" + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import List, Optional, Tuple + +from codecarbon.core.electricitymaps_api import FORECAST_URL, location_params, request +from codecarbon.external.geography import GeoMetadata +from codecarbon.external.logger import logger + + +@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 + + +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: + data = request(FORECAST_URL, location_params(geo), token) + 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, + ) + except Exception as e: + logger.error( + f"intensity_forecast.get_forecast: {type(e).__name__}: {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. + + `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) + + # 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 + for start_index, start in enumerate(points): + window_end = start.at + duration + if window_end > covered_until: + break + 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. + # 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( + 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) + + return best or fallback diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71ba05a85..54001a784 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -88,6 +88,78 @@ 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. + +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:** + +| 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; 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 | +| `--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 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: + +```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 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 +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. 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` 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 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 new file mode 100644 index 000000000..452258b13 --- /dev/null +++ b/tests/cli/test_wait.py @@ -0,0 +1,252 @@ +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) + ], + ) + + +@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"] == ["wait", "--", "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_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 + ) + + wait_module.wait_for_green_window( + SimpleNamespace(args=[]), duration="1h", deadline="6h", threshold=150 + ) + + 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_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( + "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"] == ["wait", "--", "make", "wait"] + + +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"] == ["wait", "python", "train.py"] + assert called["measure_power_secs"] == 15 + + +def test_keyboard_interrupt_during_wait_aborts(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), + ) + + with pytest.raises(typer.Exit) as exc_info: + wait_module.wait_for_green_window( + SimpleNamespace(args=["python", "train.py"]), duration="2h", deadline="6h" + ) + + 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" diff --git a/tests/test_intensity_forecast.py b/tests/test_intensity_forecast.py new file mode 100644 index 000000000..c1aa555b5 --- /dev/null +++ b/tests/test_intensity_forecast.py @@ -0,0 +1,235 @@ +import unittest +from datetime import datetime, timedelta, timezone + +import responses + +from codecarbon.core import electricitymaps_api +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) + ], + ) + + +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: + # 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", + 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_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._cache_key( + electricitymaps_api.location_params(self._geo), "tok" + ) + ) + responses.add( + responses.GET, + electricitymaps_api.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( + responses.GET, + 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 [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, + electricitymaps_api.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, + electricitymaps_api.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, + electricitymaps_api.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, + electricitymaps_api.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, + electricitymaps_api.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, + electricitymaps_api.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_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 == 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=1) + ) + 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 + + 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) + # 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)