Run your CI/CD only when the energy grid is clean. One file, no API keys, no configuration.
# .github/workflows/carbon-aware-build.yml
name: Carbon-Aware Build
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
- if: steps.carbon.outputs.grid_clean == 'true'
uses: actions/checkout@v5
- if: steps.carbon.outputs.grid_clean == 'true'
run: |
echo "Running on clean energy in ${{ steps.carbon.outputs.grid_zone }}!"
# your build/test/deploy commands hereThe action auto-detects your cloud region (AWS, GCP, Azure) or checks zones across free providers worldwide. Replace the echo with your build commands.
- Runs on a schedule (e.g. hourly)
- Fetches real-time fuel mix and computes carbon intensity (gCO2eq/kWh)
- Below your threshold: sets
grid_clean=true, the build runs - Above it: sets
grid_clean=false, skips the build, reports the next green window
Best for non-urgent jobs that can wait for clean energy: ML training, batch processing, media rendering, database migrations.
Not ready to gate your builds yet? Add the action with dry_run: 'true' and it
changes nothing about your workflow: it measures the grid, reports what it
would have done, and estimates the CO2 you'd save, all in the job summary. Run
it for a week, see the numbers, then turn enforcement on.
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
grid_zones: 'auto:green'
dry_run: 'true' # report-only: never blocks the buildIn report-only mode grid_clean is always true (so existing gates keep
passing); read the would_defer output to see the real verdict.
Use a preset instead of looking up zone codes:
| Preset | What It Does |
|---|---|
| (no input) | Auto-detects cloud region, falls back to checking all free zones worldwide |
auto:detect |
Detects AWS/GCP/Azure region from environment variables |
auto:nearest |
Picks zones closest to your timezone |
auto:green |
10 curated green zones across 5 continents (free providers only) |
auto:cleanest |
Checks all free-provider zones, picks the single cleanest |
auto:green:full |
21 zones including EU/Canada/NZ (requires API tokens) |
auto:escape-coal |
Routes jobs away from coal-heavy grids to clean alternatives |
auto:escape-coal:IN |
Escape from a specific dirty zone (IN, CN, PL, ZA, DE...) |
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
grid_zones: 'auto:green' # or any preset above
max_carbon_intensity: '200' # gCO2eq/kWh threshold (default: 250)None required. US, UK, Australia, India, Brazil, South Africa, and the
worldwide Open-Meteo fallback work with no setup, which covers the auto:*
presets. Optional free tokens add coverage: entsoe_token (EU, 44 zones), electricity_maps_token
(one registered zone on the free tier), gridstatus_api_key (US forecasts). eia_api_key
is optional too, only to raise the built-in US demo key's rate limit. See Inputs.
curl -fsSL https://raw.githubusercontent.com/peterklingelhofer/carbon-aware-dispatcher/main/setup.sh | bashOptions: --threshold 200, --zones "auto:green", --strategy queue, --cron "0 6 * * *". Run with --help for details.
Call the carbon check directly from another workflow:
jobs:
green-check:
uses: peterklingelhofer/carbon-aware-dispatcher/.github/workflows/carbon-check.yml@v1
with:
max_carbon_intensity: '200'
build:
needs: green-check
if: needs.green-check.outputs.grid_clean == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: echo "Building on clean energy!"- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
grid_zone: 'CISO' # California ISO (see Supported Zones below)
max_carbon_intensity: '200'US, UK, Australia, India, Brazil, and South Africa need no keys. EU zones use a free entsoe_token; other global zones use a free electricity_maps_token.
A gatekeeper pattern that triggers a separate heavy workflow when green:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
grid_zone: 'CISO'
max_carbon_intensity: '200'
workflow_id: 'heavy-batch.yml' # triggers this workflow when green
github_token: ${{ secrets.GITHUB_TOKEN }}The target workflow needs a workflow_dispatch trigger. Inline mode (the default, shown at the top) is simpler for most users.
The most common case: keep CI on a standard GitHub-hosted runner, but send the
deployment or workload to whichever region is cleanest. The action always
outputs cloud_region / gcp_region / azure_region, so feed them straight
into your deploy step:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
grid_zones: 'CISO,PJM,GB'
max_carbon_intensity: '200'
- if: steps.carbon.outputs.grid_clean == 'true'
run: |
aws s3 sync ./dist s3://my-bucket --region ${{ steps.carbon.outputs.cloud_region }}
# or: terraform apply -var region=${{ steps.carbon.outputs.cloud_region }}
# or: gcloud run deploy --region ${{ steps.carbon.outputs.gcp_region }}| Output | Example |
|---|---|
cloud_region |
us-west-1 (AWS) |
gcp_region |
us-west1 (GCP) |
azure_region |
westus2 (Azure) |
This is usually what matters most: the CI runner is a short-lived machine, while the deployed service or batch job is where the real energy is spent.
To run the build job in a specific region, set runs-on from the action's
runner_label. This needs a two-job pattern, since runs-on is fixed at
job-definition time:
jobs:
pick-region:
runs-on: ubuntu-latest
outputs:
runner: ${{ steps.carbon.outputs.runner_label }}
clean: ${{ steps.carbon.outputs.grid_clean }}
steps:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
grid_zones: 'CISO:us-west-runner,PJM:us-east-runner,GB:uk-runner'
max_carbon_intensity: '200'
build:
needs: pick-region
if: needs.pick-region.outputs.clean == 'true'
runs-on: ${{ needs.pick-region.outputs.runner }}
steps:
- uses: actions/checkout@v5
- run: echo "Building in ${{ needs.pick-region.outputs.runner }}"The zone:label syntax maps each zone to a runner label. This only works if
the label matches a runner that exists. GitHub-hosted runners (ubuntu-latest
etc.) have no region concept, so use self-hosted runners registered with those
labels, or RunsOn. With GitHub-hosted runners only, prefer
the deploy-region pattern above.
RunsOn supports per-job AWS region selection. Set runner_provider: 'runson' for automatic region-aware labels:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
grid_zones: 'CISO,BPAT,PJM,GB'
runner_provider: 'runson'
runner_spec: '2cpu-linux-x64' # optional, this is the defaultThe runner_label output will be a RunsOn-compatible label like runs-on=12345/runner=2cpu-linux-x64/region=us-west-1.
Standard GitHub-hosted runners (ubuntu-latest etc.) run on Azure in a region you cannot choose. GitHub's renewable commitment is annual REC matching, not real-time — so gating and scheduling are your only levers on standard runners. To actually execute on clean electrons consistently, use a runner provider that lets you pick the region.
Greenest regions by provider:
| Provider | Region | Grid zone | Why |
|---|---|---|---|
| AWS (RunsOn) | eu-north-1 — Stockholm |
SE-SE3 |
Nordic hydro + wind; AWS 100% renewable committed; cleanest year-round |
| AWS (RunsOn) | eu-west-1 — Ireland |
IE |
Wind-heavy; AWS 100% renewable committed |
| AWS (RunsOn) | us-west-2 — Oregon |
BPAT |
Pacific NW hydro; AWS 100% renewable committed |
| Azure (GitHub larger runners) | swedencentral |
SE-SE3 |
Sweden ~95%+ renewable grid |
| Azure (GitHub larger runners) | norwayeast |
NO-NO1 |
Norway ~98% hydro |
For RunsOn, pass the grid zone codes above to grid_zones so the action picks the cleanest of the candidates and routes the job there:
grid_zones: 'SE-SE3,IE,BPAT' # Stockholm, Ireland, Oregon — all AWS 100% renewable
runner_provider: 'runson'For GitHub larger runners (Teams/Enterprise), create a runner group targeting swedencentral or norwayeast in your organization settings, then set runs-on to that group's label.
Among multiple candidate zones, pick one that is both clean and cheap. Set
cost_weight (0–1) to blend each zone's carbon intensity with a representative
cloud price from the public Azure Retail Prices API (no key, keyed off each
zone's nearest Azure region):
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
grid_zones: 'CISO,GB,FR,AU-NSW'
cost_weight: '0.5' # 0 = cleanest only, 1 = cheapest only, 0.5 = balanceThe chosen zone minimizes cost_weight x price + (1 - cost_weight) x carbon
(both min-max normalized across the candidates). selected_cost_usd_hr reports
the winner's price. If pricing is unavailable it falls back to carbon-only. No
effect in single-zone mode. See examples/cost-aware-routing.yml.
Other clouds (AWS/GCP/on-prem): only Azure has a free live pricing API, so
to price any other cloud, supply your own rates via cost_price_map — a JSON
object of zone -> USD/hour. Mapped zones use your prices; the rest fall back to
live Azure:
with:
grid_zones: 'CISO,GB,FR'
cost_weight: '0.5'
cost_price_map: '{"CISO":"0.096","GB":"0.101","FR":"0.088"}'Route jobs from a coal-dependent region to the nearest clean alternative:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
grid_zones: 'auto:escape-coal' # global clean routing
# Or escape from a specific zone:
# grid_zones: 'auto:escape-coal:IN' # India → Iceland, Norway, France
# grid_zones: 'auto:escape-coal:CN' # China → NZ, Tasmania, Pacific NW
# grid_zones: 'auto:escape-coal:PL' # Poland → Nordic clean
# grid_zones: 'auto:escape-coal:ZA' # South Africa → Iceland, Norway
max_carbon_intensity: '150'Well-known dirty grids use curated routes (tuned for latency/region). Any other
zone with known coordinates is routed automatically to its geographically
nearest clean grids — no hand-curated mapping needed, so coverage extends to any
locatable origin (e.g. auto:escape-coal:VN).
Wait up to N minutes for the grid to become clean:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
grid_zone: 'CISO'
max_wait: '120' # wait up to 2 hours
enable_forecast: 'true'Forecast data is used to sleep efficiently. Note: GitHub Actions bills for wait time.
strategy: queue searches forecasts across all zones for the best time within your deadline:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
grid_zones: 'auto:cleanest'
strategy: 'queue'
deadline_hours: '24' # find best window in next 24h
max_wait: '120' # actually wait if window is within 2hOutputs optimal_dispatch_at (ISO 8601) and optimal_zone. Good for nightly ML training or weekly reports.
Skipping builds is a blunt instrument — teams want their CI to run. Instead of a
binary gate, the action classifies the grid into a carbon_tier so downstream
jobs can right-size their work: full matrix when green, critical-path when
amber, smoke test when red. CI always makes progress; the heaviest compute
shifts to the cleanest hours.
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
tier_thresholds: '120,280' # green <=120, amber <=280, red abovecarbon_tier is green, amber, or red (and carbon_tier_reason explains
why). Use it to drive matrix includes, conditional steps, or job-level if:.
See examples/adaptive-ci.yml for a full matrix that
scales test suites to the tier.
The three tiers are coarse; carbon_scale is the smooth version — a factor in
[scale_min, scale_max] that ramps linearly from scale_max at or below the
green boundary down to scale_min at or above the amber boundary. Multiply your
replica count, batch parallelism, or test-shard count by it so the heaviest
compute concentrates in the cleanest hours instead of toggling fully on/off
(CarbonScaler-style). It fails open to scale_max when intensity is unknown, so
missing data never throttles you.
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
tier_thresholds: '150,300' # ramp endpoints
scale_min: '0.25' # floor: still make progress when dirtyFrom the CLI (for KEDA/HPA, a Ray driver, or a cron that resizes a fleet):
carbon-aware scale --zones GB # -> 0.625
carbon-aware scale --zones GB --max-replicas 16 # -> 10 (ceil(0.625 x 16))For divisible batch or inference, the cleanest single region isn't enough if it
can't hold the whole load. split water-fills N shards into the cleanest
reachable zones first, respecting optional per-zone capacity — the
emissions-optimal allocation for linear per-shard cost:
# Place 100 shards, cleanest-first, capped per region; show the saving vs even
carbon-aware split --zones CISO,GB,FR --shards 100 \
--capacity '{"FR":40,"GB":40,"CISO":40}' --energy-kwh 0.5
# -> FR: 40 GB: 40 CISO: 20 emits ~X g vs ~Y g even split (saves ~Z g)Commit to a carbon target and prove it. Set green_sla_target to the percent of
runs that must run on a clean grid; with the ledger enabled,
the action tracks the green-run share and exposes sla_status
(compliant / warning / breached / unknown), sla_compliance_pct, and
sla_breached:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
ledger: 'gist:${{ vars.CARBON_LEDGER_GIST }}'
gist_token: ${{ secrets.GIST_TOKEN }}
green_sla_target: '95' # 95% of runs must run clean this monthGate releases on it, or just report:
release:
needs: carbon
if: needs.carbon.outputs.sla_breached != 'true'Check compliance anytime from the CLI: carbon-aware sla --target 95 (reads the
same ledger; exit 0 compliant, 1 breached, 2 not enough data). It's an
uptime-style SLA for carbon: the share of your compute that ran clean, attested
over time.
Cap how much CO2 your CI is allowed to emit per month. With the ledger
enabled, the action tracks month-to-date emissions and exposes budget_exceeded,
so non-essential builds pause once you hit the cap and resume next month.
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
ledger: 'gist:${{ vars.CARBON_LEDGER_GIST }}'
gist_token: ${{ secrets.GIST_TOKEN }}
monthly_budget_grams: '2000' # 2 kg CO2eq/monthThen gate downstream work on it:
build:
needs: carbon
if: needs.carbon.outputs.budget_exceeded != 'true'Outputs: budget_used_pct, budget_remaining_grams, budget_exceeded, and
budget_state (ok / warning at 80% / exceeded). Budgeting needs the
ledger input — that is where month-to-date spend is tracked. See
examples/carbon-budget.yml.
Not sure a zone works, or whether your token is wired up? Run the action in
mode: doctor for a one-click health check. It probes each configured zone,
shows which provider handles it, whether a required token is set or missing,
and whether live data actually came back — plus which optional features are on.
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
mode: 'doctor'
grid_zones: 'GB,FR,CISO' # blank probes a keyless sampleOutput (job summary):
| Zone | Provider | Token | Status | Detail |
|---|---|---|---|---|
GB |
uk_carbon_intensity | n/a | OK | 203 gCO2eq/kWh |
FR |
open_meteo | n/a | OK | 56 gCO2eq/kWh (estimated) |
See examples/doctor.yml.
Average grid intensity tells you how clean the grid is overall; marginal
emissions (MOER) tell you the emissions of the generator that responds to your
added load — the signal that actually matters for deciding when to shift
flexible compute. With free WattTime credentials, the action emits a
marginal_percentile (0–100, lower = cleaner) and a marginal_clean flag:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
id: carbon
with:
watttime_username: ${{ secrets.WATTTIME_USERNAME }}
watttime_password: ${{ secrets.WATTTIME_PASSWORD }}
marginal_max_percentile: '33' # clean = cleanest third of the last 2 weeksGate deferrable work on marginal_clean == 'true'. WattTime's free tier covers
CAISO_NORTH; other regions need WattTime Pro. See
examples/marginal-timing.yml.
From the CLI, carbon-aware marginal reports the same signal and composes via
exit codes, so a batch job can run on the marginal metric (the one that
reflects real avoided emissions) rather than average intensity:
carbon-aware marginal --region CAISO_NORTH --max-percentile 33 && ./train.shWattTime's free tier only covers CAISO_NORTH. For any EIA (US) zone you can
estimate the marginal rate for free: marginal-estimate regresses the change
in emissions on the change in generation across recent hours (the marginal
generator is the one that moves to meet a load change), so you get a marginal
number without a key. The r_squared says how much the load change explains —
i.e. how much to trust it.
carbon-aware marginal-estimate --zones PJM --json
# -> {"marginal": 510, "average": 360, "r_squared": 0.78, "n": 96}It's an estimate, not a measurement: the honest, free middle ground between the average (which overstates avoided emissions) and WattTime's measured marginal.
Run the action in mode: digest on a schedule to post (and keep updating) a
single GitHub issue summarizing your CI's carbon impact — builds, CO2
saved/emitted over the last 7 and 30 days, a daily-savings sparkline, lifetime
total, and budget status. It reads the same ledger your build workflow writes.
permissions:
issues: write
jobs:
digest:
runs-on: ubuntu-latest
steps:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
mode: 'digest'
ledger: 'gist:${{ vars.CARBON_LEDGER_GIST }}'
gist_token: ${{ secrets.GIST_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}See examples/weekly-digest.yml.
Get pinged when something actionable happens — the grid goes clean, a build is
deferred, or your carbon budget is blown. Point notify_webhook at a Slack,
Discord, or generic webhook (the payload shape is auto-detected from the URL):
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
notify_webhook: ${{ secrets.SLACK_WEBHOOK }}
notify_on: 'green,exceeded' # green | dirty | exceeded | alwaysNotifications never fail the build — a webhook error degrades to a warning.
Drop a .github/carbon-policy.yml in your repo:
# .github/carbon-policy.yml
max_carbon_intensity: 150
grid_zones: auto:cleanest
enable_forecast: true
strategy: queue
deadline_hours: 24Action inputs override policy values, letting platform teams set green CI defaults across all workflows.
The core Python script runs on any CI platform. Templates in ci-templates/:
| Platform | Template | How It Works |
|---|---|---|
| GitLab CI | gitlab-ci.yml |
Extend .carbon-aware-job in your jobs |
| Bitbucket | bitbucket-pipelines.yml |
Artifact-based result passing |
| CircleCI | circleci-config.yml |
Workspace persistence between jobs |
Set GRID_ZONE, MAX_CARBON, and optional API tokens as environment variables.
CI is a small load. The real carbon wins are large, deferrable workloads — ML training, batch inference, ETL, HPC jobs. The same engine plugs into the tools that orchestrate them, so a long run lands on clean energy with no manual timing.
Each adapter uses a lazy/optional import and runs against the framework you
already have installed — you don't install this package to get Airflow or Ray,
you add a carbon gate to your existing one. So the base install stays tiny (just
requests) and there are no heavy extras to manage.
| Framework | Import | Example |
|---|---|---|
| PyTorch Lightning | integrations.lightning_carbon.CarbonAwareCallback |
lightning_carbon_training.py |
| Hugging Face Trainer | integrations.huggingface_carbon.CarbonAwareTrainerCallback |
huggingface_carbon_training.py |
| Airflow | integrations.airflow_carbon.CarbonAwareSensor |
airflow_carbon_dag.py |
| Prefect | integrations.prefect_carbon.carbon_gate |
prefect_carbon_flow.py |
| Dagster | integrations.dagster_carbon.carbon_gate |
dagster_carbon_job.py |
| Ray | integrations.ray_carbon.run_when_clean |
ray_carbon_job.py |
| Inference routing | integrations.inference_router.cleanest_endpoint |
inference_routing.py |
| KEDA (k8s) | wait-for-green initContainer |
keda-scaledjob.yaml |
| Slurm (HPC) | wait-for-green submit wrapper |
slurm-carbon-submit.sh |
Sections below cover each in more detail.
A training run lasts hours or days, so shifting it onto clean-grid windows saves far more real carbon than gating CI. Drop in the Lightning callback to pause at epoch boundaries while the grid is dirty:
from integrations.lightning_carbon import CarbonAwareCallback
trainer = Trainer(callbacks=[CarbonAwareCallback(zones="auto:green", max_carbon=200)])No Lightning? Use the gate in any loop: from integrations.lightning_carbon import wait_until_clean; wait_until_clean(zones="auto:green", max_carbon=200). See
examples/standalone/lightning_carbon_training.py.
Hugging Face Trainer has the same gate as a TrainerCallback:
from integrations.huggingface_carbon import CarbonAwareTrainerCallback
trainer = Trainer(..., callbacks=[CarbonAwareTrainerCallback(zones="auto:green")])See examples/standalone/huggingface_carbon_training.py.
Batch ETL, retrains, and report jobs are the deferrable loads Airflow already orchestrates. Gate any DAG on grid cleanliness with a sensor:
from integrations.airflow_carbon import CarbonAwareSensor
gate = CarbonAwareSensor(
task_id="wait_for_green", zones="auto:green", max_carbon=200,
mode="reschedule", poke_interval=900, timeout=6 * 3600,
)
gate >> heavy_training_taskmode="reschedule" frees the worker slot between pokes. See
examples/standalone/airflow_carbon_dag.py.
Gate a Prefect flow with carbon_gate, which blocks until a target zone is clean:
from prefect import flow, task
from integrations.prefect_carbon import carbon_gate
@flow
def pipeline():
task(carbon_gate)(zones="auto:green", max_carbon=200)
retrain()See examples/standalone/prefect_carbon_flow.py.
Gate a Dagster op with carbon_gate, or only launch runs when clean with a
sensor on grid_is_clean:
from integrations.dagster_carbon import carbon_gate, grid_is_cleanSee examples/standalone/dagster_carbon_job.py.
Pair event-driven autoscaling with clean-energy timing: a KEDA ScaledJob
scales on your queue, and an initContainer running carbon-aware wait-for-green
holds each job until the grid is clean. See
examples/standalone/keda-scaledjob.yaml.
Inference is a fast-growing, often latency-tolerant load. Route async/batch requests to the cleanest available region in real time:
from integrations.inference_router import cleanest_endpoint
endpoints = [
{"name": "us-west", "zone": "CISO", "url": "https://us-west/infer"},
{"name": "norway", "zone": "NO-NO1", "url": "https://no/infer"},
]
target = cleanest_endpoint(endpoints) # re-rank hourly, not per requestSee examples/standalone/inference_routing.py.
CI is a small load. The real carbon wins are large, deferrable workloads —
nightly ML training, ETL, batch inference. The same engine ships as a standalone
carbon-aware CLI so any scheduler (cron, systemd timers, Kubernetes CronJobs,
Airflow, Nomad) can gate or time that work. It composes through exit codes, so no
glue code is needed:
pipx install carbon-aware-dispatcher # or use the container (below)
# Run a batch job only if the grid is clean right now
carbon-aware check --zones auto:green --max-carbon 200 && ./train.sh
# Or block until a green window opens (up to 6h), then run
carbon-aware wait-for-green --zones GB,CISO --max-carbon 200 --max-wait 6h && ./train.sh
# Add --energy-kwh for optimal stopping: it runs now instead of blocking when
# idling for the cleaner forecast window would emit more carbon than it saves
carbon-aware wait-for-green --zones GB --max-wait 6h --energy-kwh 0.2 && ./job.sh
# Plan ahead: print the cleanest upcoming window from forecasts
carbon-aware best-window --zones GB --hours 24 --json
# Emit an SCI carbon report for sustainability reporting (energy/PUE/embodied)
carbon-aware report --zones GB --energy-kwh 12 --pue 1.12 --embodied-grams 40 > sci.json
# Best of all: shift a recurring job to its cleanest hour, once
carbon-aware suggest-cron --zones GB --energy-kwh 12
# -> Suggested schedule: 0 12 * * * (~150 kg CO2/yr cleaner than your average run time)
# For a multi-hour batch job, target the cleanest contiguous window
carbon-aware suggest-cron --zones GB --duration-hours 4 --energy-kwh 20
# -> start a 4h job at 11:00 UTC (cleanest 4h window)
# For a weekly job, also pick the cleanest day of week (weekends often cleaner)
carbon-aware suggest-cron --zones GB --weekly
# -> 0 12 * * 6 (weekly on Sat at 12:00 UTC)
# WHERE often beats WHEN: move a flexible workload to the cleanest region
carbon-aware suggest-region --zones CISO,PJM,GB,FR --current PJM --energy-kwh 12
# -> Run in CISO instead of PJM: ~N kg CO2/yr saved (mind latency/egress)
# Both at once: the cleanest (region, hour) across candidates
carbon-aware plan --zones CISO,GB,FR --current PJM --energy-kwh 12
# -> Run your job in CISO at 03:00 UTC (cron: 0 3 * * *)
# Audit a whole repo: rank every shiftable schedule by savings
carbon-aware audit --zones GB --dir .github/workflows --energy-kwh 5
# -> ranked list of crons to shift + total potential kg CO2/yr
# Use less: rank scheduled jobs by annual emissions (frequency x per-run)
carbon-aware schedule-cost --zones GB --dir .github/workflows --energy-kwh 5
# -> hourly job: 24x/day ~5 t/yr — throttle the heaviest
# Grade your repo's carbon posture (A-F) and write a README badge
carbon-aware score --zones GB --badge-file carbon-posture.json
# -> Carbon posture: B (84% of schedulable savings captured)
# One command, the whole plan: ranked actions across every lever
carbon-aware advise --zones GB --dir .github/workflows --energy-kwh 5
# -> 1. [shift] ... 2. [throttle] ... with kg/yr each
# Grade our own forecasts over time: run on a schedule; it resolves past
# predictions against reality and reports the bias (the offset to trust less)
carbon-aware forecast-accuracy --zones GB --store .carbon/forecast-log.json
# -> n=42, MAE 18.4, bias +6.1 gCO2eq/kWh; subtract 6.1 from forecasts
# Inspect the hour-of-day curve the recommendation is based on
carbon-aware curve --zones GB
# Honest gut-check: is scheduling even worth it here? (flat grids: no)
# Where raw history exists (GB), this runs a one-way ANOVA F-test, so a curve
# that only looks bumpy from a few noisy samples is called out as not worth it.
# `curve` adds a robust (median) cleanest hour and a 95% confidence band per hour.
carbon-aware worth-it --zones GB # exit 0 worth it, 1 not, 2 can't assessreport writes a machine-readable SCI
record per run — energy, intensity, PUE, embodied, and total emitted — that
aggregates for CSRD / GHG-Protocol reporting.
Where no free historical API exists, the curve self-accumulates per zone in your
ledger. Pool it: carbon-aware export-curves --output mine.json writes your
zones' hour-of-day aggregates (anonymous sum/count cells only), and
carbon-aware merge-curves a.json b.json --output pool.json sums many
contributors' files into one volume-weighted pool. Point COMMUNITY_CURVE at a
pool — a local file or a published URL — and suggest-cron/worth-it/curve
gain a profile for any zone others have sampled, even with no local history:
export COMMUNITY_CURVE="https://raw.githubusercontent.com/peterklingelhofer/carbon-aware-dispatcher/community-data/community-curve.json"
carbon-aware worth-it --zones FRThe pool carries both axes — hour of day and day of week — so a zone gains a
weekend-vs-weekday profile (often a real second saving, since weekend grids run
cleaner) as well as a diurnal one. Contribute via PR to
community-curves/; the
publish workflow pools every
contribution with the self-sampled
zones and publishes the result to the community-data branch automatically.
Coverage compounds with adoption.
For recurring jobs, blocking with wait-for-green keeps the machine powered on
while it polls — that idle energy is itself carbon. The higher-impact move is to
shift the schedule once to the grid's cleanest hour: it saves on every future
run with zero idle waste. suggest-cron recommends that cron expression from the
best signal available — a multi-day historical hour-of-day curve, else the
live forecast, else a per-zone heuristic. The curve comes from a free historical
API where one exists (GB today), and otherwise builds itself: with a
ledger configured, each run records its (hour, intensity)
into a tiny per-zone aggregate, so curve / worth-it / suggest-cron start
working for any zone once ~6 different hours have been sampled. (A job that only
ever runs at one fixed hour won't fill the curve — the hourly self-check pattern
samples across the day.) Inspect it with carbon-aware curve. Reserve
wait-for-green for one-off, deadline-bound work.
Or let it open the PR for you. Run the action in mode: suggest (on a
schedule) pointed at a workflow file, and it opens a pull request moving that
workflow's daily cron to the cleanest hour — you just review and merge:
permissions:
contents: write
pull-requests: write
jobs:
suggest:
runs-on: ubuntu-latest
steps:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
mode: 'suggest'
grid_zones: 'GB'
suggest_target: '.github/workflows/nightly-batch.yml'
github_token: ${{ secrets.GITHUB_TOKEN }}Only simple daily crons are rewritten (cadence and minute preserved). See
examples/suggest-cron-pr.yml.
And before adding any of this, ask carbon-aware worth-it: on a flat,
baseload-dominated grid the intensity barely moves across the day, so shifting
saves little — the tool will say so plainly rather than have you add complexity
for nothing.
Exit codes: 0 green/clean, 1 dirty or timed out, 2 no data. Info logs go to
stderr; stdout carries only the result (add --json for machine output).
Grid feeds only refresh every 5-30 min, so composed runs can reuse a recent
reading instead of re-fetching: pass --cache-ttl 300 (or set
CARBON_CACHE_TTL=300) to cache reads for 5 minutes on the host. The container
and GitHub Action enable a 5-minute cache by default (set 0 to disable). It
caches only the public reading, never the token-bearing URL. When a cached entry
expires, it revalidates with If-None-Match / If-Modified-Since, so a provider
that supports ETags answers with a tiny 304 Not Modified instead of resending
the whole payload — saving bandwidth and energy on both ends.
Container (no Python needed) — pull the published image or build locally:
docker run --rm ghcr.io/peterklingelhofer/carbon-aware-dispatcher:latest \
check --zones GB,CISO --max-carbon 200
# or build it yourself
docker build -t carbon-aware . && docker run --rm carbon-aware check --zones GBReady-to-copy schedulers: a Kubernetes CronJob (carbon-gated via an initContainer), a cron/systemd wrapper, and a Slurm submit wrapper for HPC jobs.
For distributed training/batch on Ray, gate the driver before submitting work:
from integrations.ray_carbon import run_when_clean
run_when_clean(run_batch, zones="auto:green", max_carbon=200) # waits, then submitsSee examples/standalone/ray_carbon_job.py.
Ready-to-copy files in examples/:
| Example | Description |
|---|---|
zero-config.yml |
Simplest setup, no inputs needed |
multi-cloud-routing.yml |
Route to greenest AWS/GCP/Azure region |
queue-strategy.yml |
Find optimal green window within a deadline |
escape-coal.yml |
Escape dirty grids (India, China, Poland, SA) |
track-impact.yml |
All-in-one: lifetime ledger, live badge, and sticky PR comment |
adaptive-ci.yml |
Scale the test matrix to the carbon tier (green/amber/red) |
carbon-budget.yml |
Cap monthly CO2 and pause non-essential builds over budget |
cost-aware-routing.yml |
Pick a zone that is both clean and cheap |
weekly-digest.yml |
Weekly impact issue from the ledger |
marginal-timing.yml |
Gate flexible compute on WattTime marginal emissions |
doctor.yml |
One-click diagnostic of zones, tokens, and live data |
suggest-cron-pr.yml |
Auto-open a PR shifting a workflow to the cleanest hour |
| Input | Default | Description |
|---|---|---|
grid_zone |
auto:detect |
Single zone or preset. See Presets and Supported zones. |
grid_zones |
auto:detect |
Comma-separated zones with optional runner labels: CISO:runner-cal,GB:runner-uk. Or a preset. |
max_carbon_intensity |
250 |
Maximum gCO2eq/kWh to allow dispatch. |
workflow_id |
— | Workflow to dispatch when green. Omit for inline mode (recommended). |
github_token |
— | Required when workflow_id is set. |
eia_api_key |
— | Higher rate limits for US zones. Free registration. Built-in demo key works for basic use. |
electricity_maps_token |
— | One zone per free token (chosen at registration), 50 req/hr. Paid plans cover 200+ zones. Register. |
entsoe_token |
— | EU coverage (36 countries). Free registration, 400 req/min. |
gridstatus_api_key |
— | US forecasts (7 ISOs). Free registration, 1M rows/month. |
max_wait |
0 |
Minutes to wait for green energy. Max 360. Billable time. |
enable_forecast |
false |
Fetch forecast when dirty. Free for UK, India, Brazil, SA, Open-Meteo. US needs GridStatus key. |
strategy |
check |
check: dispatch if green now. queue: find optimal window within deadline_hours. |
deadline_hours |
24 |
Hours to search ahead for green windows (queue strategy). |
runner_provider |
— | Set to runson for automatic AWS region-based runner labels. |
runner_spec |
2cpu-linux-x64 |
Machine spec for RunsOn. |
target_ref |
main |
Git ref for dispatched workflows. |
fail_on_api_error |
false |
Fail the action on API errors instead of skipping silently. |
carbon_policy_path |
.github/carbon-policy.yml |
Path to org-wide carbon policy. |
dry_run |
false |
Report-only mode. Measures and reports but never gates the build (grid_clean stays true). See Try it risk-free. |
consumption_based |
false |
Use flow-traced consumption intensity for EU zones (single-zone, needs entsoe_token). See Consumption-based intensity. |
ledger |
— | Persist cumulative savings: gist:<id> (live badge + dashboard, needs gist_token) or file:<path>. See Watch your impact. |
gist_token |
— | Token with gist scope for the gist: ledger backend. Store as a secret. |
pr_comment |
false |
Post a sticky carbon-verdict comment on pull requests. Needs pull-requests: write. |
tier_thresholds |
150,300 |
Two gCO2eq/kWh boundaries green,amber for the carbon_tier dial. See Carbon-adaptive CI. |
monthly_budget_grams |
— | Monthly carbon cap in gCO2eq. Needs ledger. See Carbon budgets. |
cost_weight |
0 |
Blend cloud cost with carbon when choosing among zones (0 = clean only, 1 = cheap only). See Cost + carbon routing. |
notify_webhook |
— | Webhook URL for carbon-event notifications (Slack/Discord/generic). See Notifications. |
notify_on |
green,exceeded |
Events to notify on: green, dirty, exceeded, or always. |
| Output | Description |
|---|---|
grid_clean |
true if a zone was clean enough, false otherwise. |
carbon_intensity |
Intensity in gCO2eq/kWh, or unknown on error. |
carbon_tier |
Adaptive-CI dial: green / amber / red (plus carbon_tier_reason). |
carbon_scale |
Continuous autoscaling factor in [scale_min, scale_max] — multiply your replica/parallelism count by it. See Carbon-aware autoscaling. |
budget_used_pct / budget_remaining_grams / budget_exceeded / budget_state |
Monthly carbon budget status (needs monthly_budget_grams + ledger). |
selected_cost_usd_hr |
Representative price of the selected zone when cost_weight > 0. |
grid_zone |
Selected zone. |
runner_label |
Runner label for the selected zone. |
cloud_region / gcp_region / azure_region |
Nearest region for each cloud provider. Always set. |
intensity_trend |
decreasing, increasing, or stable. |
forecast_green_at |
ISO 8601 timestamp of next predicted green window. |
forecast_intensity |
Predicted intensity at the green window. |
co2_saved_grams |
Estimated grams CO2 saved vs. global average (450 gCO2eq/kWh). |
co2_saved_equivalent |
Human-relatable phrase for this run's saving, e.g. ~1.8 km not driven. |
carbon_badge_url |
Shields.io badge URL for READMEs:  |
co2_saved_total_grams |
Cumulative grams saved across all runs (requires the ledger input). |
co2_saved_total_equivalent |
Human-relatable phrase for the lifetime saving (requires ledger). |
lifetime_badge_url |
Live shields.io badge URL for lifetime CO2 saved (requires ledger: gist:<id>). |
status_badge_url |
Live shields.io badge URL for the current grid zone/intensity/tier (requires ledger: gist:<id>). |
optimal_dispatch_at |
Best green window (queue strategy). now if already green. |
optimal_zone |
Zone for the optimal window (queue strategy). |
suggested_cron |
Suggested cron schedule for green builds based on zone energy type. |
dry_run |
true when the action ran in report-only mode. |
would_defer |
In dry_run mode, true if the grid was dirty and the build would have been deferred under enforcement. |
Carbon claims are easy to inflate, so here is exactly what the numbers mean:
co2_emitted_grams(trust this one). What the run actually produced: carbon intensity × estimated energy. This is the Green Software Foundation SCI operational term (embodied hardware excluded) and maps to GHG Protocol Scope 2 (location-based). It is the figure to report.co2_saved_grams(a benchmark, not a reduction). A comparison against a fixed global-average grid (450 gCO2eq/kWh) — "how much cleaner than a world-average grid was this run". It is not marginal or additional avoided emissions: on a grid where shifted load just rides baseload, the real avoided emissions can be far lower. The basis is stated in theco2_saved_basisoutput so it travels with the number.- Why not marginal everywhere? The metric that reflects true avoided
emissions from shifting load is marginal intensity, which is free only for
CAISO_NORTH(via WattTime — see Marginal emissions). We don't fake it for other regions.
Bottom line: use co2_emitted_grams for reporting and co2_saved_grams as a
directional benchmark, not an offset claim.
Provenance on every reading. The action emits data_source (the provider
that actually produced the number, e.g. uk_carbon_intensity, energy_charts,
rte) and data_confidence (measured for grid-operator data, estimated for
weather-modeled Open-Meteo readings). It honors the Open-Meteo fallback, so a
number is never labeled measured when it was really an estimate. Gate on it if
you only trust measured data (if: steps.X.outputs.data_confidence == 'measured').
co2_avoided_total_grams — verifiable avoided emissions. With a ledger,
each run is compared against this zone's own typical hour (from the
accumulated curve), and the difference accrues into a lifetime total. Unlike the
global-average benchmark, this is a real, self-referential measure of how much
cleaner you ran by scheduling well — it starts accruing once the curve has
enough hours.
Make the energy figure real. By default emitted assumes a typical CI job (50 W for 15 min). For actual workloads — a GPU training run, an ETL batch — set your real energy so the number means something:
with:
job_energy_kwh: '12' # measured kWh (best); overrides the estimate
# or describe it:
# job_power_watts: '300'
# job_duration_minutes: '120'For a fuller SCI total, add datacenter overhead and embodied hardware carbon (both opt-in, default off):
with:
pue: '1.12' # facility energy / IT energy (cloud DCs run ~1.1-1.2)
embodied_grams: '40' # amortized manufacturing CO2 for this runco2_emitted_grams then equals energy x intensity x PUE + embodied.
Every run estimates the CO2 it saved, but a number that vanishes after one build is easy to ignore. These features make the impact meaningful, persistent, and visible to everyone, not just whoever opens the Actions tab.
The job summary, the co2_saved_equivalent output, and the PR comment translate
grams into things people feel: ~1.8 km not driven, ~14 phone charges. Factors
come from the US EPA Greenhouse Gas Equivalencies Calculator. Nothing to enable.
Set the ledger input to accumulate savings across every run into a lifetime
total, exposed via co2_saved_total_grams and a live, self-updating badge.
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
ledger: gist:YOUR_GIST_ID # or file:.carbon/ledger.json
gist_token: ${{ secrets.GIST_TOKEN }}One-time setup for the gist backend:
- Create a public gist (any placeholder content) and copy its id from the URL.
- Create a personal access token with the
gistscope and store it as a secret namedGIST_TOKEN. (The built-inGITHUB_TOKENcannot write gists.) - The action writes
carbon-ledger.json(full data) andcarbon-badge.json(a shields.io endpoint badge) to the gist on every run.
Embed the live lifetime badge in your README:
The file: backend needs no token and writes a local JSON file, handy for
self-hosted runners or if you commit the ledger yourself.
A second, live current-grid badge is published alongside it (carbon-now.json),
showing the latest zone, intensity, and tier color, and exposed via the
status_badge_url output:
dashboard/index.html is a self-contained, no-build,
no-CDN page that reads your ledger gist and renders the lifetime total, real-world
equivalents, and a savings-over-time chart. Drop the dashboard/ folder on
GitHub Pages and open it with ?gist=<id>:
https://YOUR_USER.github.io/YOUR_REPO/?gist=YOUR_GIST_ID
It reads the gist through the CORS-enabled GitHub REST API, so it works for any public ledger gist with zero server-side code.
Set pr_comment: 'true' to post the carbon verdict as a single comment on the
pull request, updated in place on each run, so reviewers see whether the build
ran on clean energy (and how much it saved) without opening the Actions tab.
permissions:
pull-requests: write
# ...
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
pr_comment: 'true'The action picks the best provider per zone, checking free providers first.
| Provider | Coverage | API Key | Zones |
|---|---|---|---|
| EIA | US (60+ regions) | Free built-in | CISO, ERCO, PJM, BPAT, NYIS, MISO, ISNE, SWPP... |
| UK Carbon Intensity | UK (18 regions) | None | GB, GB-1..GB-17 |
| AEMO | Australia (5 states) | None | AU-NSW, AU-QLD, AU-VIC, AU-SA, AU-TAS |
| Grid India | India (5 regions) | None | IN-NO, IN-SO, IN-EA, IN-WE, IN-NE (geo-restricted, see note) |
| ONS Brazil | Brazil (5 regions) | None | BR-S, BR-SE, BR-CS, BR-NE, BR-N |
| Eskom | South Africa | None | ZA |
| IESO / AESO / Hydro-Quebec | Canada (ON, AB, QC) | None | CA-ON, CA-AB, CA-QC |
| CAMMESA | Argentina (national) | None | AR |
| Taipower | Taiwan | None | TW |
| EirGrid | Ireland (ROI, NI, all-island) | None | IE, IE-ROI, IE-NI, IE-ALL |
| Energinet | Denmark (DK1, DK2) | None | DK-DK1, DK-DK2 |
| RTE eco2mix | France | None | FR |
| Energy-Charts (Fraunhofer ISE) | EU (DE, ES, IT, NL, BE, AT, CH, PL, PT, CZ, FI, GR, HU, RO, and more) | None | DE, ES, IT, NL, ... |
| ENTSO-E | EU (36 countries) | Free token | DE, FR, ES, NL, NO-NO1, SE-SE1..SE-SE4, DK-DK1... |
| Electricity Maps | 1 zone (free tier) / 200+ (paid) | Token | The single zone registered to your token; see their map |
| Open-Meteo | Worldwide (90+) | None | Auto-fallback for any zone with known coordinates |
| GridStatus | US forecasts (7 ISOs) | Free token | CISO, ERCO, ISNE, MISO, NYIS, PJM, SWPP |
Provider priority: UK > EIA > AEMO > Grid India > ONS Brazil > Eskom > Canada > Taiwan > ENTSO-E (with token) > Open-Meteo (with coordinates) > Electricity Maps (last resort; free tier is one registered zone). If a primary provider fails, the action automatically falls back to Open-Meteo weather-based estimation.
Reliability notes:
- Grid India is reachable only from Indian IPs, so it always fails from GitHub-hosted (US/EU) runners. India zones are therefore left out of the curated
auto:*presets. They still work if you passgrid_zones: 'IN-SO'explicitly from a runner inside India. auto:detectneeds a cloud-region environment variable, which GitHub-hosted runners don't provide. On those runners it falls back toauto:cleanest(greenest free zone worldwide) and says so in the log. Setgrid_zonesexplicitly to pin a region.
| Region | Source | Details |
|---|---|---|
| UK | Carbon Intensity API | 48h free forecast, automatic |
| US | GridStatus.io | Solar/wind/load forecasts. Requires gridstatus_api_key. |
| EU | ENTSO-E | Real day-ahead forecast: wind+solar (A69) and load (A65) forecasts give the hourly renewable share. Requires entsoe_token. |
| India | Heuristic | Solar peak 10am–4pm IST. Southern grid (IN-SO) cleanest. Automatic. |
| Brazil | Heuristic | Hydro off-peak cleanest. Evening peak 17–21h BRT dirtier. Automatic. |
| South Africa | Heuristic | Coal-dominant, rarely < 650 gCO2eq/kWh. Recommends escape-coal. |
| Other | Open-Meteo | 48h solar/wind weather forecast. Automatic for 90+ zones. |
Heuristic and Open-Meteo forecasts are time-of-day or weather estimates, not measured day-ahead forecasts. They are labeled "(estimated)" in the job summary so they are not confused with the measured UK / ENTSO-E / GridStatus forecasts.
| Region | Typical Range (gCO2eq/kWh) | Suggested Threshold |
|---|---|---|
| Norway, Iceland, Quebec, Paraguay | 10–30 | 50 |
| France, Sweden, Ontario, Brazil (hydro) | 30–80 | 100 |
| California (midday), Costa Rica | 0–150 | 150–200 |
| UK, New Zealand | 100–300 | 200 |
| Germany, US average | 200–500 | 300 |
| Poland, Australia (coal states) | 400–800 | 500 or use auto:escape-coal |
| India, South Africa | 600–900 | Use auto:escape-coal:IN / auto:escape-coal:ZA |
Fuel-mix providers (EIA, AEMO, ENTSO-E, Grid India, ONS Brazil, Canada, Taipower) weight each source by its IPCC AR5 lifecycle factor in gCO2eq/kWh: coal 820, lignite 1050, gas 490, oil 650, biomass 230, solar 45, geothermal 38, hydro 24, wind 12, nuclear 12. Storage (battery, pumped hydro) is excluded. The UK API returns a pre-calculated value; Electricity Maps returns intensity directly; Open-Meteo modulates each zone's approximate annual-average intensity (a per-zone prior from public yearly data) by real-time solar irradiance and wind speed, so a structurally clean grid (e.g. nuclear France, hydro Norway) reads clean rather than defaulting to a fossil average.
By default the action reports production-based intensity (a zone's own
generation mix). Set consumption_based: 'true' (single-zone mode, with an
entsoe_token) to instead get consumption-based intensity, which flow-traces
imports and exports across the European network so a zone importing clean French
nuclear reads cleaner, and one importing German coal reads dirtier:
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
with:
grid_zone: 'IT-NO' # Italy North, a heavy importer
consumption_based: 'true'
entsoe_token: ${{ secrets.ENTSOE_TOKEN }}It uses ENTSO-E physical cross-border flows (documentType A11) and solves the flow-tracing linear system (Tranberg et al., 2019) with Gauss-Seidel iteration, no extra dependencies. Covered zones: FR, DE, NL, BE, CH, AT, ES, PT, IT-NO, PL, CZ, GB, IE, DK-DK1. Zones outside this traced network fall back to production intensity. Note: this costs extra ENTSO-E calls (one per traced zone plus its borders), so enable it only when the import/export correction matters.
- Coverage is best where a free grid-operator API exists. US, UK, EU (with a free ENTSO-E token), Australia, Canada, Taiwan, Brazil, India, and South Africa use real grid data. Other zones fall back to an Open-Meteo weather estimate, or to Electricity Maps if a token is set. Some regions (e.g. Japan, South Korea, Singapore) have no clean free real-time feed, so measured data there requires an Electricity Maps token.
- Consumption-based intensity is EU-only and opt-in (see above). Other regions report production-based intensity. For global consumption-based data, use a commercial source such as Electricity Maps.
- Some forecasts are heuristic (see Forecasts), labeled as estimates in the job summary.
Validate configuration before deploying:
# Test common zones
uv run setup_wizard.py
# Test specific zones
uv run setup_wizard.py --zone CISO
uv run setup_wizard.py --zones "CISO,GB,DE,AU-NSW"
# With API keys
uv run setup_wizard.py --zones "DE,FR" --electricity-maps-token YOUR_TOKENGitHub Actions alone produced an estimated ~457 metric tons of CO2e in 2024 (Saavedra et al., 2025). Grid intensity swings widely: California ranges from 400+ gCO2eq/kWh (evening gas) to near-zero (midday solar). Shifting when and where batch jobs run yields 20-50% carbon reductions with no code changes.
| Study | Key Finding |
|---|---|
| Claßen et al., 2023 | Analyzed 7,392 GitHub Actions workflows. Scheduling CI/CD based on grid intensity effectively reduces emissions. |
| Saavedra et al., 2025 | 2.2M runs across 18K repos. Recommends deploying runners in cleaner regions (France, UK). |
| CarbonScaler (Hanafy et al., 2023) | Up to 51% carbon savings by adjusting compute based on real-time grid intensity. |
| Sukprasert et al., 2023 | Even simple scheduling policies capture most achievable carbon reductions. |
| Yang et al., 2025 | Survey of 50+ works reports 10–51% emission reductions from carbon-aware scheduling. |
| Problem | Solution |
|---|---|
carbon_intensity = unknown |
API unreachable. Check API key/network. Set fail_on_api_error: 'true' to surface errors. |
forecast_green_at = none_in_forecast |
Grid won't go below threshold in forecast horizon. Raise threshold or use multi-zone / auto:green. |
EIA 429 errors |
Hitting demo key limit (~30 req/hr). Register free for 1,000 req/hr. |
| Zones silently skipped | Zone needs API token that isn't set. Check logs for "Skipping zone" messages. |
| Zone not found (Electricity Maps) | Zone codes are case-sensitive. Check app.electricitymaps.com/map. |
In multi-zone mode, the job summary lists any skipped zones with a reason so you know whether to act:
| Reason | Meaning | What to do |
|---|---|---|
auth failed |
The provider rejected the API key/token (HTTP 401/403). | Check the secret is set and valid. |
rate limited |
Hit the provider's rate limit (HTTP 429), even after retries. | Transient; add a paid/registered key, or it clears on its own. |
network error |
Could not reach the provider after retries. | Usually transient; the zone is retried next run. |
HTTP <code> |
An unexpected non-retryable response. | Check the provider's status; the zone code may be wrong. |
no electricity_maps_token |
Zone needs an Electricity Maps token and none was set. | Add electricity_maps_token, or use a keyless zone. |
A clean run never blocks on a skipped zone: it routes to the cleanest zone that did respond.
All timestamps are UTC (ISO 8601).
Install the pre-commit hooks once so a commit can never fail CI's linter:
uv run --extra dev pre-commit install
uv run --extra dev pre-commit install --hook-type pre-pushOn commit they run ruff check --fix and ruff format; on push they run mypy
and the test suite — the same checks as CI, via uv run so the tool versions
match exactly. Run them manually anytime with uv run --extra dev pre-commit run --all-files.