kodebeat / papers / greenops

Acting on grid carbon intensity: the data and the place

Carbon-intensity-api and carbon-region-picker.

The same kilowatt-hour varies by an order of magnitude in carbon depending on which grid supplies it. carbon-intensity-api publishes last-hour intensity for 213 countries as static JSON, with a basis field that never lets an annual average pass as a measured hour; carbon-region-picker ranks cloud regions under a latency constraint and states the consequence rather than leaving the arithmetic to the reader.

The problem

A team decides to reduce the carbon footprint of its compute. The single largest lever is usually unused, and the reason is that the input is missing.

Where you run decides most of it. On the yearly averages carbon-region-picker bundles, which derive from Ember's country-level dataset, eu-north-1 runs at roughly 25 gCO2e/kWh and eu-central-1 at around 380. Identical workloads, identical code, identical cost structure, fifteen times the carbon. It is plausibly the largest lever available to most teams, achievable by changing a string in a Terraform variable, and it goes unused because the two numbers that decide it live in different worlds. Latency is measured with tools every engineer already has. Grid intensity is published by energy data providers per country or bidding zone, and mapping a bidding zone to eu-west-3 is a step nobody maintains.

So the conversation never happens. When sustainability is raised, the proposal is usually to optimise code: real work, weeks of it, for a few per cent. The fifteen-fold difference sits unexamined in a variable definition. For most services no plausible algorithmic improvement comes close to a region change, and the region change is a one-line diff at creation time.

Underneath that: getting the number is harder than it should be. Commercial APIs are authoritative and require a key, and beyond a small free tier a contract and a procurement conversation, before a prototype can print anything. National APIs exist for some countries, each with its own shape, so a tool covering twenty countries integrates twenty schemas. Static tables in a blog post are free, and often undated and unattributed.

And a definitional trap sits under all of it. "Carbon intensity" is at least four different numbers: direct (operational), lifecycle (adding IPCC upstream factors), consumption-direct (trade-adjusted for imports and exports), and consumption-lifecycle (both). They differ substantially. Publishing one without saying which produces downstream figures that cannot be compared or reconciled. Two teams quoting "the carbon intensity of the Swiss grid" can be quoting 39 or 179, both correct, both unlabelled.

The method

The signal: carbon-intensity-api

Serve the data as static objects, with no application in the request path. The pipeline computes once, in sync.sh, writes data/ (a combined latest.json plus one file per country) and syncs it to an S3-compatible bucket that only serves. The hosted deployment runs sync.sh hourly from a separate, private repository and commits each run's data/ there for audit. The consequences: one source of truth, no double computation, no runtime to operate, and CDN caching for free. It is also why the API can be free: there is nothing to scale. The costs are that a figure is only as fresh as the last sync, that nothing can be computed per request, and that the only rate control is an edge rule (10 requests per 10 s per IP, 429 beyond it).

Publish every intensity figure a reading supports, plus a basis, a data_source and a generated_at. Country readings carry all four figures; zone readings carry direct and lifecycle only, because the import adjustment is national. basis is measured, annual-average, or estimated for an hour the provider has not published yet. Consumers pick the figure their methodology requires rather than inheriting whichever one the publisher happened to choose, which is what makes downstream figures comparable between organisations.

404 rather than lie. The coverage rules, as the README states them:

A live provider is necessary and not sufficient: a country whose feed publishes late answers on /latest only. The US (EIA, about a day behind) is the main case. /v2/countries.json lists, per country, which routes answered on the last run.

Separate the completed hour from the hour in progress. /past-hour is the last completed clock hour and is immutable once published; /current-hour moves between runs; /latest is the newest hour the provider has published, however old, and is what carries a reading across a provider outage. A caller can cache the first indefinitely and knows the second is provisional.

Make the path grammar legible. In the README's words, "an UPPERCASE segment is a code, a lowercase-hyphenated one is a resource", so /v2/IT/SICI/past-hour reads as country, zone, resource, and a zone is the next code down with no zones/ marker.

The decision: carbon-region-picker

Rank under a constraint rather than optimising one axis. The picker takes the latency requirement as an input and ranks what remains by intensity. "Which region is greenest" has an answer nobody can use, because the greenest region is frequently too far away; "which of the regions that meet my latency requirement is greenest" is answerable and actionable. Filtering on one axis and ranking on the other also avoids inventing an exchange rate between milliseconds and grams, which nobody could defend.

State the consequence in the output. The summary line does the arithmetic a reader would otherwise do badly or not at all: picking eu-north-1 over eu-central-1 cuts compute carbon ~15x. That sentence is what an architecture decision record quotes.

Default to yearly averages, because the decision is permanent. The bundled per-region figures are annual, hand-transcribed from Ember's yearly dataset (with Electricity Maps zone pages for sub-national zones Ember does not cover), and the source says to treat them as approximate. That is the right basis for a choice that will run through every season. --live with an Electricity Maps token swaps in current readings; --marginal asks for marginal rather than average intensity, and --forecast names the cleanest hour in the next 24 hours for the top region. All three need the token, so the default path stays usable with no account.

Emit --json for the pipeline. A region choice that lands in Terraform should be able to arrive there without a human retyping it, and the same output feeds a scheduler or a dashboard.

The headline results

  • Completeness is modelled per provider.
  • Estimated hours have a measured error bound.
  • What the tests assert.

The full results, with the tables and the reasoning behind each figure, are in the PDF.

What is in the PDF

  • The problem — what goes unanswered without it, and who notices first.
  • Why the obvious alternative falls short — stated plainly, including where it is the better choice.
  • How it works — the method, not a feature list.
  • Concrete use cases — with console output quoted from the repository, never reconstructed.
  • The methodology behind any number it emits — every term shown, so the figure survives a question.
  • What it deliberately does not do — the section most papers leave out.

Part of the GreenOps theme.

Get the PDF

This paper already selected; the download starts when you send the form. No follow-up sequence.

Get the PDF