Skip to content

Commit ca960ea

Browse files
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) <noreply@anthropic.com>
1 parent ec886cb commit ca960ea

6 files changed

Lines changed: 76 additions & 29 deletions

File tree

codecarbon/cli/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,12 @@ def wait(
480480
str,
481481
typer.Option(help="Maximum delay before the job must start."),
482482
] = "12h",
483+
finish_by: Annotated[
484+
Optional[str],
485+
typer.Option(
486+
help="Latest acceptable finish time, e.g. '8h'. Overrides --deadline."
487+
),
488+
] = None,
483489
threshold: Annotated[
484490
Optional[float],
485491
typer.Option(help="gCO2e/kWh at or below which we start immediately."),
@@ -509,6 +515,7 @@ def wait(
509515
ctx,
510516
duration=duration,
511517
deadline=deadline,
518+
finish_by=finish_by,
512519
threshold=threshold,
513520
dry_run=dry_run,
514521
log_level=log_level,

codecarbon/cli/wait.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ def find_green_window(
3030
deadline: timedelta,
3131
token: str | None,
3232
):
33-
"""Return (start, intensity, now_intensity) or None when we should run now."""
34-
from codecarbon.core import electricitymaps_api
33+
"""Return (start, intensity, now_intensity) or None when we should run now.
34+
35+
`now_intensity` is the first forecast point, i.e. the current period as the
36+
forecast sees it. Asking the /latest endpoint for a live value instead
37+
would be a second HTTP call for a number only used to print a percentage.
38+
"""
3539
from codecarbon.core.intensity_forecast import best_window, get_forecast
3640
from codecarbon.external.geography import GeoMetadata
3741
from codecarbon.input import DataSource
@@ -45,13 +49,7 @@ def find_green_window(
4549
if forecast is None:
4650
return None
4751

48-
try:
49-
now_intensity = electricitymaps_api.get_carbon_intensity(geo, token or "")
50-
except Exception as e:
51-
# The forecast's first point is a stand-in, not the live value.
52-
logger.debug(f"wait: current intensity unavailable ({e}), using the forecast.")
53-
now_intensity = forecast.points[0].g_co2e_per_kwh
54-
52+
now_intensity = forecast.points[0].g_co2e_per_kwh
5553
now = datetime.now(timezone.utc)
5654
start, intensity = best_window(forecast, duration, deadline=now + deadline)
5755
return start, intensity, now_intensity
@@ -65,6 +63,7 @@ def wait_for_green_window(
6563
ctx: typer.Context,
6664
duration: str = "1h",
6765
deadline: str = "12h",
66+
finish_by: str | None = None,
6867
threshold: float | None = None,
6968
dry_run: bool = False,
7069
log_level: str = "error",
@@ -91,7 +90,16 @@ def wait_for_green_window(
9190

9291
try:
9392
job_duration = parse_duration(duration)
94-
max_delay = parse_duration(deadline)
93+
# --deadline bounds the start, --finish-by bounds the end; the search
94+
# only ever needs the latest acceptable start.
95+
if finish_by is not None:
96+
max_delay = parse_duration(finish_by) - job_duration
97+
if max_delay < timedelta(0):
98+
raise ValueError(
99+
f"--finish-by {finish_by} is sooner than --duration {duration}."
100+
)
101+
else:
102+
max_delay = parse_duration(deadline)
95103
except ValueError as e:
96104
print(f"ERROR: {e}", file=sys.stderr)
97105
raise typer.Exit(1)

codecarbon/core/intensity_forecast.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
API backs off once for the whole process instead of once per caller. The
1010
forecast response itself is not cached: it is fetched once per `codecarbon
1111
wait` invocation, and its useful lifetime is nothing like the current
12-
intensity's five-minute TTL.
12+
intensity's short TTL.
1313
1414
Once pluggable intensity providers land (see issue #1356), `get_forecast`
1515
should become an optional `forecast()` method on the provider protocol.
@@ -19,11 +19,7 @@
1919
from datetime import datetime, timedelta, timezone
2020
from typing import List, Optional, Tuple
2121

22-
from codecarbon.core.electricitymaps_api import (
23-
clear_cooldown,
24-
location_params,
25-
request,
26-
)
22+
from codecarbon.core.electricitymaps_api import location_params, request
2723
from codecarbon.external.geography import GeoMetadata
2824
from codecarbon.external.logger import logger
2925

@@ -86,7 +82,6 @@ def get_forecast(
8682
if not points:
8783
raise ValueError("No usable forecast points in response")
8884

89-
clear_cooldown()
9085
return Forecast(
9186
zone=data.get("zone", ""),
9287
points=points,

docs/reference/cli.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ tracker only starts after the sleep, so a waiting process holds no lock.
113113
|--------|------|---------|-------------|
114114
| `--duration` | string | 1h | Expected job length, e.g. `90m`, `2h`, `1h30m`, or a plain number of seconds |
115115
| `--deadline` | string | 12h | Maximum delay before the job must start; the job itself may finish after it |
116+
| `--finish-by` | string | - | Latest acceptable *finish* time. Overrides `--deadline` with `--finish-by` minus `--duration` |
116117
| `--threshold` | float | - | gCO2e/kWh at or below which the job starts immediately, without waiting |
117118
| `--dry-run` | flag | false | Print the recommendation and exit without waiting or running |
118119
| `--measure-power-secs` | int | 10 | Interval between two measures |
@@ -128,6 +129,9 @@ codecarbon wait --deadline 12h --duration 2h -- python train.py
128129

129130
# Start straight away if the grid is already below 100 gCO2e/kWh
130131
codecarbon wait --threshold 100 --deadline 6h -- bash benchmark.sh
132+
133+
# The job must be finished within 8 hours, and takes about 2
134+
codecarbon wait --finish-by 8h --duration 2h -- python train.py
131135
```
132136

133137
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
153157
the greenest moment.
154158

155159
`--deadline` bounds the *start* time, not the end: `--deadline 12h --duration 2h` considers every
156-
start in the next 12 hours, so the job may still be running 14 hours from now.
160+
start in the next 12 hours, so the job may still be running 14 hours from now. When you mean "this
161+
must be **done** by then", use `--finish-by` instead: `--finish-by 12h --duration 2h` searches
162+
starts in the next 10 hours. Passing a `--finish-by` shorter than `--duration` is an error.
163+
164+
The `now:` figure in the output is the first point of the forecast, i.e. the current period as the
165+
forecast sees it, not a separate reading of the live grid — `wait` makes exactly one API call.
157166

158167
### `codecarbon detect`
159168

tests/cli/test_wait.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,12 @@ def no_network(monkeypatch):
3333
)
3434

3535

36-
def _patch_forecast(monkeypatch, values, now_intensity=None):
36+
def _patch_forecast(monkeypatch, values):
3737
now = datetime.now(timezone.utc)
3838
monkeypatch.setattr(
3939
"codecarbon.core.intensity_forecast.get_forecast",
4040
lambda geo, **kwargs: _forecast(values, now),
4141
)
42-
monkeypatch.setattr(
43-
"codecarbon.core.electricitymaps_api.get_carbon_intensity",
44-
lambda geo, token="": values[0] if now_intensity is None else now_intensity,
45-
)
4642

4743

4844
@pytest.mark.parametrize(
@@ -130,11 +126,14 @@ def test_threshold_short_circuits_the_wait(monkeypatch, capsys, no_network):
130126
assert "running now" in capsys.readouterr().out
131127

132128

133-
def test_threshold_uses_the_live_intensity_not_the_forecast(
134-
monkeypatch, capsys, no_network
135-
):
136-
# The first forecast point is above the threshold, the live grid is below.
137-
_patch_forecast(monkeypatch, [300, 300, 100, 100], now_intensity=120)
129+
def test_no_second_call_for_the_current_intensity(monkeypatch, capsys, no_network):
130+
# The first forecast point is the "now" value: fetching /latest as well
131+
# would be a second HTTP call just to print a percentage.
132+
_patch_forecast(monkeypatch, [120, 300, 50, 50])
133+
monkeypatch.setattr(
134+
"codecarbon.core.electricitymaps_api.get_carbon_intensity",
135+
lambda *a, **k: pytest.fail("second live call"),
136+
)
138137
monkeypatch.setattr(wait_module.time, "sleep", lambda s: pytest.fail("slept"))
139138
monkeypatch.setattr(
140139
"codecarbon.cli.monitor.run_and_monitor", lambda ctx, **kwargs: None
@@ -147,6 +146,31 @@ def test_threshold_uses_the_live_intensity_not_the_forecast(
147146
assert "running now" in capsys.readouterr().out
148147

149148

149+
def test_finish_by_bounds_the_end_not_the_start(monkeypatch, no_network):
150+
# Trough at +4h, but the job must be done by +3h, so only a start at or
151+
# before +2h is acceptable: the cheapest of those is +1h.
152+
_patch_forecast(monkeypatch, [300, 100, 200, 200, 10, 10])
153+
slept = []
154+
monkeypatch.setattr(wait_module.time, "sleep", lambda s: slept.append(s))
155+
monkeypatch.setattr(
156+
"codecarbon.cli.monitor.run_and_monitor", lambda ctx, **kwargs: None
157+
)
158+
159+
wait_module.wait_for_green_window(
160+
SimpleNamespace(args=[]), duration="1h", finish_by="3h"
161+
)
162+
163+
assert len(slept) == 1
164+
assert 3600 - 60 < slept[0] <= 3600
165+
166+
167+
def test_finish_by_shorter_than_duration_is_rejected(monkeypatch, capsys, no_network):
168+
with pytest.raises(typer.Exit):
169+
wait_module.wait_for_green_window(
170+
SimpleNamespace(args=[]), duration="4h", finish_by="1h"
171+
)
172+
173+
150174
def test_only_the_leading_subcommand_name_is_stripped(monkeypatch, no_network):
151175
_patch_forecast(monkeypatch, [100, 300, 300])
152176
called = {}

tests/test_intensity_forecast.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ def test_no_token_returns_none_without_calling_api(self):
7171
def test_shared_cooldown_skips_the_request(self):
7272
# A failure on the current-intensity path must back the forecast off
7373
# too: no HTTP request, and still a None instead of a raise.
74-
electricitymaps_api._start_cooldown()
74+
electricitymaps_api._start_cooldown(
75+
electricitymaps_api._cache_key(
76+
electricitymaps_api.location_params(self._geo), "tok"
77+
)
78+
)
7579
responses.add(
7680
responses.GET,
7781
intensity_forecast.FORECAST_URL,

0 commit comments

Comments
 (0)