Skip to content

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

Open
davidberenstein1957 wants to merge 5 commits into
masterfrom
feat/intensity-providers
Open

feat: cache and back off Electricity Maps carbon intensity#1358
davidberenstein1957 wants to merge 5 commits 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 with a short measure_power_secs that is thousands of HTTP requests for a value the grid publishes hourly at best. And when the token is wrong or the network is down, it is thousands of doomed requests plus one logger.error line per measurement tick for the entire run.

  • 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 5 minutes, keyed by location (lat/lon or countryCode), so a 1000-tick run makes one request instead of 1000.
  • After a failure, put the API in an exponential cooldown doubling from 30s 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 tick.
  • 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 5 minute TTL means measurements inside that window convert energy with the same intensity value rather than a freshly fetched one. A run shorter than the TTL now uses a single reading. tests/test_emissions_tracker.py::test_cumulative_emissions_with_varying_intensity patches the TTL to -1 because it deliberately feeds a different intensity per tick. The cache is keyed by location and token, so two trackers in one process with different tokens do not share a value. 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 1000-tick run bounded to one request, refetch after expiry, per-location keying, no request while in cooldown, cooldown doubling to the ceiling, and cooldown reset after success.

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

Draft pending review of the overall direction in #1354.

Refs #1354

🤖 Generated with Claude Code

davidberenstein1957 and others added 2 commits August 12, 2026 17:51
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.

Extract get_carbon_intensity() from get_emissions(), cache its result
for 5 minutes per location, and put the API in an exponential cooldown
(30s to 1h) after a failure. get_emissions() is unchanged for callers.

Refs #1354

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test varies carbon intensity per measurement to prove emissions are
accumulated as deltas rather than recomputed from the latest intensity.
The new 5-minute TTL cache served the first value for all three ticks.
Disable the TTL for this test so it still exercises the cumulation
contract, and reset the module-level cache to avoid cross-test leakage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@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.46154% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.60%. Comparing base (065d0e6) to head (3319a94).

Files with missing lines Patch % Lines
codecarbon/core/electricitymaps_api.py 98.41% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1358      +/-   ##
==========================================
+ Coverage   91.39%   91.60%   +0.20%     
==========================================
  Files          49       49              
  Lines        5056     5109      +53     
==========================================
+ Hits         4621     4680      +59     
+ Misses        435      429       -6     

☔ 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.

- key the cache by token so trackers with different tokens do not share a value
- raise a dedicated cooldown error and log it at debug, so a bad token no
  longer produces one error line per measurement tick
- document the 5 minute cache TTL as a behaviour change

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread codecarbon/core/electricitymaps_api.py Fixed
The raw API token was part of the in-memory cache key, so it was stored in
the module-level cache and rendered by the debug log that reports a cache
hit. Key on a sha256 prefix instead: tokens still get distinct cache
entries, but the secret is never held nor logged.

The cache and cooldown state are read-modify-written from the background
measurement thread. Guard them with one module-level lock, never held
across the HTTP request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread codecarbon/core/electricitymaps_api.py Fixed
CodeQL flags the SHA256 digest as py/weak-sensitive-data-hashing: the
parameter name marks it as a credential, and any hashlib digest of a
credential reads as an insecure password hash. The key only has to tell
two tokens apart inside one process, so use the builtin randomly-seeded
hash() instead. No caching, backoff or public API change.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants