Skip to content

feat: cache and back off Electricity Maps carbon intensity - #1358

Open
davidberenstein1957 wants to merge 1 commit into
masterfrom
feat/intensity-providers
Open

feat: cache and back off Electricity Maps carbon intensity#1358
davidberenstein1957 wants to merge 1 commit into
masterfrom
feat/intensity-providers

Conversation

@davidberenstein1957

@davidberenstein1957 davidberenstein1957 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Slice 1 of 5 of the pluggable carbon-intensity provider work described in #1354. It stands on its own: it fixes the two live defects in the current Electricity Maps path without introducing any new abstraction, config key, or output field.

What is in this slice

codecarbon/core/electricitymaps_api.py fetched the grid carbon intensity on every emissions computation. On a long run that is a lot of HTTP requests for a value the grid publishes hourly at best, and when the token is wrong or the network is down, every one of them is doomed and logs an error.

To be precise about the frequency, since the earlier version of this description overstated it: during a run, _prepare_emissions_data()_update_emissions() is called once every api_call_interval measures (default 8), not on every measurement tick — see codecarbon/emissions_tracker.py:1278-1284. It is once per call for code that uses tasks, where start_task() / stop_task() each prepare emissions data (codecarbon/emissions_tracker.py:778, :810), and once more at stop(). So the wasted-request and repeated-error-line count scales with the run length divided by api_call_interval, or with the number of tasks — not with the tick count.

  • Extract get_carbon_intensity(geo, token) -> float (gCO2e/kWh) out of get_emissions(). Asking for the intensity rather than for emissions-given-energy is the shape the provider layer needs, and it is useful on its own.
  • Cache the intensity for 60 seconds, keyed by location (lat/lon or countryCode) and token, so a long run makes far fewer requests.
  • After a failure, put that location+token in an exponential cooldown doubling from 30 s to a 1 hour ceiling, during which no request is issued. A successful call resets it.
  • Skipped requests raise ElectricityMapsAPICooldownError, which emissions.py logs at debug level, so a bad token produces one error line instead of one per API call.
  • reset_cache() for tests.

get_emissions() keeps its exact signature and return value and now delegates, so the three existing Electricity Maps test files are untouched. codecarbon/core/emissions.py gains one except clause for the cooldown error.

Behaviour change, not a pure optimisation: the 60 s TTL means measurements inside that window convert energy with the same intensity value rather than a freshly fetched one. The TTL is deliberately shorter than the default api_call_interval × measure_power_secs (~2 minutes), so the cadence the maintainers configured is what actually drives API calls — the cache only collapses the extra calls that tasks and stop() add on top. The cache is keyed by location and token, so two trackers in one process with different tokens neither share a value nor share a failure cooldown. Documented in docs/how-to/configuration.md.

What is deferred

  • Slice 2CarbonIntensity / IntensityProvider in codecarbon/core/intensity/, with today's bundled-data branches lifted out of emissions.py unchanged as StaticProvider. No number moves.
  • Slice 3 — Electricity Maps behind the protocol, this module kept as a deprecation shim.
  • Slice 4resolve_intensity() with the fallback chain, wired into get_private_infra_emissions(), plus the carbon_intensity_providers config key and its backward-compatibility default.
  • Slice 5carbon_intensity_g_co2e_kwh and carbon_intensity_source on EmissionsData, so silent fallback to yearly averages becomes visible in the CSV and in Prometheus.
  • Further out, and out of scope of Pluggable live carbon-intensity providers (with caching and backoff) #1354's first version: ENTSO-E and WattTime providers, and time-weighted intensity.

Also deliberately not in this slice: stale-serve (returning an expired cached value when the API errors). It trades a silent inaccuracy for continuity and should land together with the is_live / carbon_intensity_source reporting in slice 5, not before there is any way to see it happened.

Tests

tests/test_electricitymaps_cache.py, all network mocked with responses following tests/test_electricitymaps_api.py: cache hit within TTL, a long run bounded to one request, refetch after expiry, per-location keying, no request while in cooldown, cooldown doubling to the ceiling, cooldown reset after success, and cooldown isolation between tokens.

uv run pytest tests/test_electricitymaps_cache.py tests/test_electricitymaps_api.py \
  tests/test_emissions.py -q
37 passed, 1 skipped

Draft pending review of the overall direction in #1354.

Refs #1354

🤖 Generated with Claude Code

@davidberenstein1957

Copy link
Copy Markdown
Collaborator Author

CI fix pushed.

The failing test (test_cumulative_emissions_with_varying_intensity) was written to protect a different contract than the one it appeared to break. Its job is to prove that emissions are accumulated as per-tick deltas (delta_energy x intensity at that tick) rather than recomputed as total_energy x latest_intensity; varying the intensity across three measurements was only the mechanism for telling those two behaviours apart. The 5-minute TTL cache is correct behaviour on its own terms - grid intensity is published hourly at best, and refetching on every measurement tick was the request storm this PR set out to remove - so I kept the caching design exactly as it is and did not change any implementation code.

What changed is the test only: it now patches ELECTRICITYMAPS_CACHE_TTL to a negative value for the duration of the test, so each measurement expires the entry and sees a fresh mocked intensity, and it resets the module-level cache before and after so the global state cannot leak in from or out to other tests. The cumulation assertions (0.1 / 0.3 / 0.6, and the < 0.8 guard against the non-cumulative 0.9) are untouched, and no network calls are made - the responses are mocked as before.

The caching behaviour itself remains covered by tests/test_electricitymaps_cache.py, including TTL expiry, per-location keying and the failure cooldown.

Verified locally: tests/test_emissions_tracker.py, tests/test_electricitymaps_cache.py, tests/test_electricitymaps_api.py and tests/test_emissions.py all pass, and pre-commit is clean on the changed file.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.11321% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.58%. Comparing base (3ec31a0) to head (de92ca8).

Files with missing lines Patch % Lines
codecarbon/core/electricitymaps_api.py 98.03% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1358      +/-   ##
==========================================
+ Coverage   91.43%   91.58%   +0.14%     
==========================================
  Files          49       49              
  Lines        5057     5098      +41     
==========================================
+ Hits         4624     4669      +45     
+ Misses        433      429       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread codecarbon/core/electricitymaps_api.py Fixed
Comment thread codecarbon/core/electricitymaps_api.py Fixed
@davidberenstein1957
davidberenstein1957 marked this pull request as ready for review August 13, 2026 05:23
@davidberenstein1957
davidberenstein1957 requested a review from a team as a code owner August 13, 2026 05:23
davidberenstein1957 added a commit that referenced this pull request Aug 16, 2026
`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>
davidberenstein1957 added a commit that referenced this pull request Aug 16, 2026
`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>
davidberenstein1957 added a commit that referenced this pull request Aug 19, 2026
`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>
Carbon intensity was fetched from the Electricity Maps API on every emissions
computation, so a long run with a short `measure_power_secs` issued thousands
of requests for a value the grid publishes hourly. A failing token produced
one doomed request per measurement tick for the whole run.

`get_carbon_intensity()` is extracted from `get_emissions()`, its result is
cached for 60 s per location and token, and the API goes into a flat 60 s
cooldown after a failure. `get_emissions()` is unchanged for callers.

A 60 s TTL is deliberate: 5 minutes silently overrode the ~2 minute
`api_call_interval` cadence and halved the intensity resolution. Both the
cache and the cooldown are keyed by location and token, so trackers with
different tokens do not share a value and one tracker's bad token or
unreachable network cannot block another tracker's good one. The token is used
directly as part of the in-process dict key and is never rendered into a log
line. Cooldown raises a dedicated error logged at debug, so a bad token no
longer produces one error line per tick.

Cache and cooldown state are read-modify-written from the background
measurement thread, so they are guarded by one module-level lock, never held
across the HTTP request.

Behaviour change worth calling out: a non-200 whose body is not the expected
JSON error object now surfaces `resp.text` instead of raising a
`JSONDecodeError` (or `ElectricityMapsAPIError(None)` when the body is JSON
without `error`/`message`). Covered by a test on a 502 HTML body.

Refs #1354

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants