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.config → CODECARBON_* → 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
- 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.
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.
- Electricity Maps as a provider behind the protocol, with
codecarbon/core/electricitymaps_api.py kept as a deprecation shim.
resolve_intensity wired into get_private_infra_emissions, plus the new config keys.
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.
- 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?
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.pyis the whole of live-intensity support, and its single caller isEmissionsCalculator.get_private_infra_emissionsincodecarbon/core/emissions.py:get_emissions()is called on every emissions computation. A long run with a shortmeasure_power_secsissues thousands of HTTP requests for a value the grid publishes hourly.logger.errorline per measurement tick for the entire run. The call site does catchExceptionand fall through to bundled data, so the run survives — but it degrades silently and noisily.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_emissionsalready honoursself._force_carbon_intensity_g_co2e_kwhbefore anything else, which is proof the abstraction belongs at that level.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 bundledcodecarbon/data/private_infra/logic, which has no network and no failure mode.Why it fits existing extension points
codecarbon/core/config.py(parse_env_config) andBaseEmissionsTracker._set_from_confalready merge constructor →.codecarbon.config→CODECARBON_*→ default. New keys arecarbon_intensity_providers,carbon_intensity_cache_ttl, and per-provider credentials.electricitymaps_api_tokenis set andcarbon_intensity_providersis not, the provider list defaults to["electricitymaps"]. The three existing test files that pin token resolution (including the deprecatedco2_signal_api_tokenalias) must keep passing untouched.codecarbon/core/hardware_cache.pyrather than inventing a second convention.requestsis already required; ENTSO-E's XML is stdlibxml.etree.ElementTree. We deliberately do not addentsoe-py, which drags inbeautifulsoup4and a pinnedpandasrange for one endpoint.Suggested landing sequence
CarbonIntensity/IntensityProviderincodecarbon/core/intensity/, with today's bundled-data branches lifted out ofemissions.pyunchanged asStaticProvider.tests/test_emissions.pyandtests/test_geography.pyare the regression harness; not a single number moves.codecarbon/core/electricitymaps_api.pykept as a deprecation shim.resolve_intensitywired intoget_private_infra_emissions, plus the new config keys.carbon_intensity_g_co2e_kwhandcarbon_intensity_sourceonEmissionsData/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.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:
codecarbon.intensity_providersentry-point group when a real third party turns up.forecast(geo, horizon)rather than growing a parallel module with its own HTTP handling.Open questions