Skip to content

Pluggable live carbon-intensity providers (with caching and backoff) #1354

Description

@davidberenstein1957

Feature proposal.

The problem

Carbon intensity is half of CodeCarbon's output — emissions are energy × intensity. We measure energy carefully, but intensity is a yearly national average from a bundled JSON file for everyone except users who hold an Electricity Maps token. In a country like France or Germany real grid intensity moves by a factor of three within a day, so a run at 03:00 and the same run at 19:00 report identical numbers. That is a systematic error in the headline figure.

For the users who do have a token, the current live path has concrete defects. codecarbon/core/electricitymaps_api.py is the whole of live-intensity support, and its single caller is EmissionsCalculator.get_private_infra_emissions in codecarbon/core/emissions.py:

  • No caching. get_emissions() is called on every emissions computation. A long run with a short measure_power_secs issues thousands of HTTP requests for a value the grid publishes hourly.
  • No backoff. A wrong or expired token produces one failed request and one logger.error line per measurement tick for the entire run. The call site does catch Exception and fall through to bundled data, so the run survives — but it degrades silently and noisily.
  • No visibility. Nothing in the output says whether a number came from a live API or from a yearly average, so silent fallback is undetectable after the fact.
  • One hardcoded vendor. The URL and auth scheme are baked into core. There is no way to use ENTSO-E (free with registration, all of Europe), WattTime, or a utility's own feed.

Proposed design

A small provider seam plus a resolver, sitting exactly where the existing "give me an intensity in gCO2e/kWh" seam already is — get_private_infra_emissions already honours self._force_carbon_intensity_g_co2e_kwh before anything else, which is proof the abstraction belongs at that level.

@dataclass(frozen=True)
class CarbonIntensity:
    g_co2e_per_kwh: float
    source: str            # "electricitymaps" | "entsoe" | "watttime" | "static"
    zone: str | None
    measured_at: datetime | None
    is_live: bool

class IntensityProvider(Protocol):
    name: str
    def available(self) -> bool: ...       # pure config check, no network
    def latest(self, geo: GeoMetadata) -> CarbonIntensity: ...

def resolve_intensity(geo, conf) -> CarbonIntensity:
    """Never raises. Falls back to bundled static data."""

Resolution order: force_carbon_intensity_g_co2e_kwh, then each configured live provider (skipping unavailable ones, honouring a TTL cache and a per-provider failure cooldown), then the bundled codecarbon/data/private_infra/ logic, which has no network and no failure mode.

Why it fits existing extension points

  • Configuration needs no new mechanism: codecarbon/core/config.py (parse_env_config) and BaseEmissionsTracker._set_from_conf already merge constructor → .codecarbon.configCODECARBON_* → default. New keys are carbon_intensity_providers, carbon_intensity_cache_ttl, and per-provider credentials.
  • Backward compatibility rule: if electricitymaps_api_token is set and carbon_intensity_providers is not, the provider list defaults to ["electricitymaps"]. The three existing test files that pin token resolution (including the deprecated co2_signal_api_token alias) must keep passing untouched.
  • On-disk caching follows codecarbon/core/hardware_cache.py rather than inventing a second convention.
  • No new dependencies. requests is already required; ENTSO-E's XML is stdlib xml.etree.ElementTree. We deliberately do not add entsoe-py, which drags in beautifulsoup4 and a pinned pandas range for one endpoint.

Suggested landing sequence

  1. Caching and backoff for the existing Electricity Maps path — no new abstraction, no config, no behaviour change beyond far fewer HTTP requests and quiet failures. This is the smallest change that fixes the two live defects.
  2. CarbonIntensity / IntensityProvider in codecarbon/core/intensity/, with today's bundled-data branches lifted out of emissions.py unchanged as StaticProvider. tests/test_emissions.py and tests/test_geography.py are the regression harness; not a single number moves.
  3. Electricity Maps as a provider behind the protocol, with codecarbon/core/electricitymaps_api.py kept as a deprecation shim.
  4. resolve_intensity wired into get_private_infra_emissions, plus the new config keys.
  5. carbon_intensity_g_co2e_kwh and carbon_intensity_source on EmissionsData / TaskEmissionsData (both defaulted, so every output backend keeps working), populated in _prepare_emissions_data, exposed as a Prometheus gauge. This is what makes silent fallback visible.
  6. ENTSO-E (widest free coverage; needs a country → bidding-zone map and per-source factors) and WattTime (token refresh, and marginal rather than average intensity, which must be labelled because the two are not comparable) — separate PRs with separate data-maintenance commitments.

Scope boundary

Steps 1–5 are the feature. Steps 6 and beyond are follow-ups and should not block it.

Explicitly out of scope for now, and worth arguing about before anyone builds them:

  • Entry-point registry for third-party providers. Speculative until someone asks. Ship the plain dict; add the codecarbon.intensity_providers entry-point group when a real third party turns up.
  • Time-weighted intensity (integrating intensity per measurement tick instead of applying the latest value to the run total). A genuine accuracy improvement, only possible once caching exists, but a separate change with its own correctness discussion.
  • Forecasting. If carbon-aware scheduling lands, it should extend this protocol with forecast(geo, horizon) rather than growing a parallel module with its own HTTP handling.

Open questions

  • Should users with no credentials stay on yearly averages by default? Static-by-default is honest, but it leaves the majority of users on the inaccurate path forever.
  • Should stale-serve (returning an expired cached value when a provider errors) be on by default? It trades a small silent inaccuracy for continuity.
  • Mixing marginal and average providers in one chain — allow it, or force the user to pick a mode?
  • Who owns the ENTSO-E bidding-zone map long term?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions