← Back to Learning Hub

Carbon-Aware Scheduling of Flexible Loads

Carbon-aware schedulingTradeoffsAdvanced24 min

By: Anacodic Team

TL;DR — The electricity grid is dirtier at some hours and cleaner at others (windy 2am vs a still evening peak). Many jobs don't care exactly when they run — an ML training batch, EV charging, a water heater, thermal storage, industrial pre-cooling. Carbon-aware scheduling measures the grid's carbon intensity CI(t) each hour from the live fuel mix, then shifts each deferrable job into its lowest-carbon feasible window (still finishing before its deadline). Same energy, same work — just a cleaner hour. We prove savings without deploying anything by a historical backtest: run a greedy carbon-aware schedule against a carbon-blind FIFO baseline on real ISO-New England data and count grams of CO₂ saved. In our worked EV example the greedy schedule cuts emissions 81.5%. This is a foundation piece — it needs only public data + published factors, and a separate load-forecasting paper reuses this exact code and cites down to it.


1. Simple explanation (plain English + analogy)

Some jobs must run right now (your video call, a card payment). Other jobs just need to be done by a deadline — it does not matter which hour they run. We call those deferrable or flexible loads:

  • an ML training batch that must finish by 9am,
  • an EV that must be charged by 7am,
  • a water heater that must be hot by morning,
  • thermal storage / industrial pre-cooling that must reach a setpoint by a deadline.

The trick: the electricity behind the plug is not equally clean all day. At 2am with lots of wind, most power comes from wind + nuclear → clean. At 6pm everyone gets home, demand spikes, and the grid fires up gas and oil peaker plants → dirty. On ISO-New England the grid swings from about 59 grams of CO₂ per kWh (clean) to about 350 (dirty) — roughly a 6× difference for the same kilowatt-hour.

Analogy — grocery shopping at off-peak hours. You need groceries today, but you can pick when to go. If you go at rush hour the store is packed, checkout is slow, parking is a nightmare (dirty, expensive). Go at 7am and it's calm and fast (clean, cheap). You buy the same groceries either way — you just chose a better time. Carbon-aware scheduling is exactly that: same job, same energy, greener hour.

The one rule: you can only move a job inside its allowed window (after it is released, before its deadline). The room you have to move is called slack. Big slack → lots of freedom to hit a clean hour → big savings. Zero slack → the job must run now → no savings possible.


2. Diagram (ASCII)

                CARBON-AWARE SCHEDULING PIPELINE  (backtest on real grid data)

  ┌─────────────────────┐   1) DATA: hourly generation by fuel  (EIA Open Data API)
  │  EIA fuel mix (MWh)  │      gas=6760  nuclear=3000  wind=...  solar=...  oil=240 ...
  └──────────┬──────────┘
             ▼
  ┌─────────────────────┐   2) CARBON INTENSITY  CI(t) = Σ gen_f·EF_f / Σ gen_f
  │  CI(t)  [gCO2/kWh]   │      apply IPCC AR5 emission factors  (coal 820 ... wind 11)
  │  hour → how dirty    │      → a 24-value curve: clean at night, dirty at peak
  └──────────┬──────────┘
             ▼
  ┌─────────────────────┐   3) JOBS: deferrable loads
  │  job = (P, D,        │      P = power kW · D = duration h · release · deadline
  │   release, deadline) │      slack = deadline − release − D
  └──────────┬──────────┘
             ▼
  ┌─────────────────────┐   4a) GREEDY  → for each job pick feasible start s minimizing
  │  SCHEDULER           │        cost(s) = Σ_{h=0..D-1} P·CI(s+h)      (cleanest window)
  │  greedy vs FIFO      │   4b) FIFO baseline → earliest feasible start (carbon-blind)
  └──────────┬──────────┘
             ▼
  ┌─────────────────────┐   5) MEASURE
  │  Savings %           │      Savings% = (Total_FIFO − Total_greedy) / Total_FIFO × 100
  └─────────────────────┘      same energy P·D either way — only WHEN differs

3. How it works

3.1 Step 1 — get the fuel mix

Every balancing authority publishes how much power came from each fuel each hour. The EIA Open Data API exposes an hourly fuel-mix feed; ISO New England (ISO-NE) is our headline region, and the same method generalizes to PJM, MISO, CAISO, NYISO, ERCOT, SPP. A row looks like: gas = 6,760 MW, nuclear = 3,000 MW, wind = 400 MW, oil = 240 MW, … for a given hour.

3.2 Step 2 — turn fuel mix into carbon intensity

Each fuel has an emission factor EF_f — grams of CO₂ per kWh generated by that fuel. We use the IPCC AR5 lifecycle factors (see the table in §4). The grid's carbon intensity is the generation-weighted average of those factors. Clean fuels (wind 11, nuclear 12, hydro 24) pull CI down; fossil fuels (coal 820, oil 650, gas 490) pull it up.

3.3 Step 3 — model the jobs

A deferrable job is four numbers:

FieldMeaningExample (EV)
Ppower draw while running (kW)7 kW
Dhow many hours it must run3 h
releaseearliest hour it may start6pm (plugged in)
deadlinehour by which it must be finished7am
slackdeadline − release − D (spare hours)13 − 0 − 3 = 10 h

Key invariant: energy is fixed. Whenever it runs, the job uses P·D kWh. Scheduling changes only when those kWh are drawn, hence which CI they see.

3.4 Step 4 — schedule: greedy vs FIFO

SchedulerRuleCarbon-aware?
FIFO (baseline)start at the earliest feasible hour (= release)No — blind to CI
Greedystart at the feasible s ∈ [release, deadline−D] that minimizes cost(s)Yes

cost(s) = Σ_{h=0}^{D−1} P·CI(s+h) is the total emissions of running the job in the D-hour window starting at s. Greedy scans all feasible starts and picks the cleanest.

3.5 Step 5 — measure savings

Run both schedulers over the same jobs and the same real CI curve, sum emissions, and report:

Savings% = (Total_FIFO − Total_greedy) / Total_FIFO × 100

Because this uses historical CI data, no forecasting and no live deployment are needed — it is a clean backtest.

3.6 Extension — a per-hour capacity cap

So far each job is scheduled independently. In a real datacenter or feeder there is a power cap M: the total power of all jobs running in any hour h must satisfy

Σ_{jobs running in h} P_j ≤ M          for every hour h

Now jobs contend for the greenest hours — they cannot all pile into 2am. The clean per-job greedy is no longer globally optimal, so we use a heuristic: ascending-slack greedy — schedule the tightest-deadline (least slack) job first into its cleanest still-available window, subtract its power from each hour's remaining capacity, then move to the next-tightest job. This is a genuine heuristic (not provably optimal), which is exactly why capacity makes the problem interesting.


4. The math (plain notation + worked example)

4.1 Carbon intensity

             Σ_f  gen_f(t) · EF_f
   CI(t) =  ─────────────────────      [ gCO2 / kWh ]
                 Σ_f  gen_f(t)

gen_f(t) = MW (or MWh) from fuel f at time t; EF_f = emission factor. IPCC AR5 lifecycle emission factors (gCO₂/kWh):

FuelEFFuelEF
COAL820NUCLEAR12
OIL650HYDRO24
GAS490WIND11
OTHER≈230SOLAR48

Worked CI (one hour). Suppose an hour has (MW): WIND 4000, GAS 1000, NUCLEAR 2000, COAL 500, SOLAR 500.

  numerator   = 4000·11 + 1000·490 + 2000·12 + 500·820 + 500·48
              = 44000  + 490000   + 24000   + 410000  + 24000   = 992,000
  denominator = 4000 + 1000 + 2000 + 500 + 500                  = 8,000
  CI          = 992000 / 8000 = 124.0  gCO2/kWh

Real ISO-NE anchor. A windy overnight mix lands around CI ≈ 59.4 (clean); a still evening peak — gas + oil on the margin — lands around CI ≈ 350.4 (dirty). That is a ~6× swing on the same kWh. That swing is the entire opportunity.

4.2 Cost of a job at start s

   cost(s) = Σ_{h=0}^{D-1}  P · CI(s + h)          [ gCO2 ]
   feasible starts:  s ∈ [ release,  deadline − D ]

Energy is invariant: Σ_{h} P = P·D kWh regardless of s. Only the CI(s+h) terms change.

4.3 Greedy is optimal per-job (exchange argument)

With no shared capacity, jobs are independent, so minimizing each job's own cost(s) minimizes the global total. Proof sketch (exchange argument): suppose an optimal schedule ran some job at start s' while a feasible start s* had cost(s*) < cost(s'). Swap that job to s*. No other job is affected (independence), and the total strictly drops — contradicting optimality. Hence picking the min-cost feasible window per job is globally optimal. (This is a classic greedy-scheduling result — see Greedy Scheduling & Interval Selection.)

4.4 Savings

   Savings% = (Total_FIFO − Total_greedy) / Total_FIFO × 100

Savings depend on three things: (a) how much CI varies over the window, (b) how much slack the job has, and (c) how much shared capacity M is available (§3.6). A flat CI curve → nothing to gain. Zero slack → nowhere to move → 0% savings. And a tight M caps the gain even when slack is plentiful, because the fleet cannot all occupy the greenest hour: with total energy E and cap M you must occupy at least E/M hours of the window, so you can only ever dodge the W − E/M dirtiest of its W hours.

4.5 Full worked scheduling example (toy curve, FIFO vs greedy)

An EV is plugged in at 6pm and must be full by 7am. P = 7 kW, D = 3 h, release = 0 (6pm), deadline = 13 (7am → last start is hour 13 − 3 = 10). Here is a realistic ISO-NE-shaped overnight CI curve (index 0 = 6pm):

 idx  0    1    2    3    4    5    6    7    8    9   10   11   12
 hr  18   19   20   21   22   23   00   01   02   03   04   05   06
 CI 350  330  300  250  200  150  100   80   60   59   62   90  150   gCO2/kWh

 350 |█████████████████            evening peak — DIRTY
 330 |█████████████
 300 |████████████
 250 |██████████
 200 |████████
 150 |██████                                        ▲ FIFO starts here (idx 0)
 100 |████
  80 |███
  60 |██                 ← cleanest 3-hr window (idx 8,9,10)  ▲ GREEDY starts here
  59 |██

cost(s) = 7 · (CI[s] + CI[s+1] + CI[s+2]):

start swindow CI sumcost (gCO₂)start swindow CI sumcost (gCO₂)
0 (FIFO)350+330+300 = 9806,8606100+80+60 = 2401,680
1330+300+250 = 8806,160780+60+59 = 1991,393
2300+250+200 = 7505,2508 (greedy)60+59+62 = 1811,267
3250+200+150 = 6004,200959+62+90 = 2111,477
4200+150+100 = 4503,1501062+90+150 = 3022,114
5150+100+80 = 3302,310
  • FIFO (earliest feasible) starts at s = 06,860 gCO₂.
  • Greedy picks s = 8 (2am–5am) → 1,267 gCO₂.
  • Energy both ways: P·D = 7 × 3 = 21 kWhidentical.
   Savings% = (6860 − 1267) / 6860 × 100 = 5593 / 6860 × 100 = 81.5 %

Same charge, same 21 kWh, 81.5% less CO₂ — purely by choosing a cleaner window inside the deadline.


5. Real code (runnable Python)

Clean, generic modules mirroring a real repo, then an end-to-end run. The main block runs with no API key using the worked EV curve, so you can paste and execute it directly.

# ============================================================================
# carbon/factors.py  — IPCC AR5 lifecycle emission factors (gCO2 per kWh)
# ============================================================================
EMISSION_FACTORS = {
    "COAL": 820, "OIL": 650, "GAS": 490, "OTHER": 230,
    "SOLAR": 48, "HYDRO": 24, "NUCLEAR": 12, "WIND": 11,
}


# ============================================================================
# carbon/intensity.py  — carbon intensity from a fuel mix
# ============================================================================
def carbon_intensity(fuel_mix, factors=EMISSION_FACTORS):
    """CI(t) = sum(gen_f * EF_f) / sum(gen_f)  ->  gCO2/kWh.

    fuel_mix: {"GAS": MW, "WIND": MW, ...} generation for ONE hour.
    Returns the generation-weighted average emission factor.
    """
    total_gen = sum(fuel_mix.values())
    if total_gen == 0:
        return 0.0
    weighted = sum(mw * factors[fuel] for fuel, mw in fuel_mix.items())
    return weighted / total_gen


def ci_curve(hourly_fuel_mix):
    """Map a list of hourly fuel-mix dicts -> list of CI values (the curve)."""
    return [carbon_intensity(mix) for mix in hourly_fuel_mix]


# ============================================================================
# data/sources/eia_fuel_mix.py  — pull hourly fuel mix from EIA Open Data API
# ============================================================================
import os
import urllib.request
import json

# EIA fuel-type codes -> our factor keys (ISO-NE respondent "ISNE").
_EIA_FUEL_MAP = {
    "COL": "COAL", "NG": "GAS", "OIL": "OIL", "NUC": "NUCLEAR",
    "SUN": "SOLAR", "WAT": "HYDRO", "WND": "WIND", "OTH": "OTHER",
}

def fetch_eia_fuel_mix(start, end, respondent="ISNE", api_key=None):
    """Return {hour_iso: {FUEL: MW}} from the EIA hourly fuel-mix endpoint.

    Requires a free EIA API key (env EIA_API_KEY). Network call — the demo
    below does NOT need this; it runs on the embedded curve.
    """
    api_key = api_key or os.environ.get("EIA_API_KEY")
    url = (
        "https://api.eia.gov/v2/electricity/rto/fuel-type-data/data/"
        f"?api_key={api_key}&frequency=hourly"
        f"&data[0]=value&facets[respondent][]={respondent}"
        f"&start={start}&end={end}&sort[0][column]=period&sort[0][direction]=asc"
    )
    with urllib.request.urlopen(url) as resp:            # pragma: no cover
        rows = json.load(resp)["response"]["data"]
    out = {}
    for r in rows:
        fuel = _EIA_FUEL_MAP.get(r["fueltype"])
        if fuel is None:
            continue
        out.setdefault(r["period"], {})[fuel] = float(r["value"])
    return out


# ============================================================================
# scheduler/jobs.py  — the deferrable-job model
# ============================================================================
from dataclasses import dataclass

@dataclass(frozen=True)
class Job:
    name: str
    power_kw: float   # P  — draw while running
    duration_h: int   # D  — hours it must run
    release: int      # earliest start hour (index into the CI curve)
    deadline: int     # must be FINISHED by this hour index

    @property
    def slack(self):
        return self.deadline - self.release - self.duration_h

    def feasible_starts(self):
        # s in [release, deadline - D]
        return range(self.release, self.deadline - self.duration_h + 1)


# ============================================================================
# scheduler/greedy.py  — greedy carbon-aware scheduler + FIFO baseline
# ============================================================================
def window_cost(job, start, ci):
    """cost(s) = sum_{h=0..D-1} P * CI(s+h)  -> gCO2 for this window."""
    return sum(job.power_kw * ci[start + h] for h in range(job.duration_h))

def greedy_start(job, ci):
    """Feasible start minimizing carbon cost (per-job optimal, no capacity)."""
    return min(job.feasible_starts(), key=lambda s: window_cost(job, s, ci))

def fifo_start(job, ci):
    """Carbon-blind baseline: earliest feasible start = release."""
    return job.release

def schedule(jobs, ci, chooser):
    """Return (starts, total_gco2) for a chooser (greedy_start / fifo_start)."""
    starts, total = {}, 0.0
    for job in jobs:
        s = chooser(job, ci)
        starts[job.name] = s
        total += window_cost(job, s, ci)
    return starts, total

def savings_percent(total_fifo, total_greedy):
    return (total_fifo - total_greedy) / total_fifo * 100.0


# ============================================================================
# main  — end-to-end backtest on the worked EV curve (runs, no API key)
# ============================================================================
if __name__ == "__main__":
    # Realistic ISO-NE overnight curve, index 0 = 6pm ... index 12 = 6am.
    ci = [350, 330, 300, 250, 200, 150, 100, 80, 60, 59, 62, 90, 150]

    ev = Job("EV", power_kw=7, duration_h=3, release=0, deadline=13)
    jobs = [ev]

    fifo_starts, total_fifo = schedule(jobs, ci, fifo_start)
    greedy_starts, total_greedy = schedule(jobs, ci, greedy_start)

    print(f"slack = {ev.slack} h   energy = {ev.power_kw*ev.duration_h:.0f} kWh (fixed)")
    print(f"FIFO   start={fifo_starts['EV']:>2}  ->  {total_fifo:8.0f} gCO2")
    print(f"greedy start={greedy_starts['EV']:>2}  ->  {total_greedy:8.0f} gCO2")
    print(f"savings = {savings_percent(total_fifo, total_greedy):.1f} %")

# Expected output:
#   slack = 10 h   energy = 21 kWh (fixed)
#   FIFO   start= 0  ->      6860 gCO2
#   greedy start= 8  ->      1267 gCO2
#   savings = 81.5 %

Capacity extension (ascending-slack greedy under a per-hour cap M):

def greedy_with_capacity(jobs, ci, cap_kw):
    """Tightest-deadline-first greedy under a per-hour power cap M.
    Real heuristic (not provably optimal) — jobs contend for clean hours.
    """
    remaining = [cap_kw] * len(ci)          # kW still available each hour
    starts, total = {}, 0.0
    for job in sorted(jobs, key=lambda j: j.slack):     # ascending slack
        def fits(s):
            return all(remaining[s + h] >= job.power_kw for h in range(job.duration_h))
        feasible = [s for s in job.feasible_starts() if fits(s)]
        if not feasible:
            raise RuntimeError(f"{job.name}: no feasible window under cap")
        s = min(feasible, key=lambda s: window_cost(job, s, ci))
        for h in range(job.duration_h):
            remaining[s + h] -= job.power_kw            # book the capacity
        starts[job.name] = s
        total += window_cost(job, s, ci)
    return starts, total

6. Real-world example (concrete, with numbers)

Overnight EV depot — 3 vehicles, per-hour cap. A small fleet plugs in at 6pm, all due by 7am, on the same 13-hour ISO-NE curve above. Each charger draws P = 7 kW. The site feeder is capped at M = 15 kW (so at most two chargers run at once).

VehiclePDslackgreedy (no cap)with cap M=15
EV-A7310start 8 (2–5am)start 8 (2–5am)
EV-B7310start 8 (2–5am)start 8 (2–5am)
EV-C7211start 8 (2–4am)pushed to start 6 (12–2am)

Without a cap all three pile into the cleanest hours. With M = 15, hours 8–10 already carry 7 + 7 = 14 kW; a third 7 kW charger would hit 21 > 15, so EV-C is pushed to the next-cleanest open window (midnight–2am, CI 100/80). EV-C still beats its FIFO start (6pm, CI 350/330) by a wide margin, but it does not get the greenest hour — that is capacity contention in one picture.

Fleet result. Against a FIFO baseline where all three charge starting 6pm, the capacity-aware greedy still cuts total emissions by roughly 75–80% — a little less than the single-job 81.5% precisely because the cap forces one vehicle off the cleanest window. That gap is the tradeoff (covered in depth in Green Computing Tradeoffs (No Free Lever)).

Beyond EVs, the identical model schedules ML training batches, water heating, thermal / battery storage, and industrial pre-cooling — any load with a deadline and slack.


Relation to CCI and the energy (load-forecasting) paper — reuse & independence

Talking points you can use to explain this project in a meeting:

This is NOT the same as CCI (Carbon Cost of Intelligence).

  • CCI (published, DOI 10.3390/en19030642) measures how much energy/carbon one AI task costs — the A/E metric is the job's own footprint.
  • This work measures how dirty the grid is each hour (CI(t)) and shifts deferrable jobs into clean windows.
  • Analogy: CCI = how much fuel a car burns per trip; this = knowing when the electricity itself is cleanest and running then.
  • They are complementary, not competing: you could take a CCI-measured AI job and schedule it with this scheduler. One tells you the size of the job's footprint; the other tells you the best hour to incur it.

This work is fully INDEPENDENT — it can be built and published first, alone. It needs only:

  1. Public EIA fuel-mix data (already open),
  2. Published IPCC AR5 emission factors (already public),
  3. A historical backtest — greedy vs FIFO on past data.

A pure historical backtest requires zero forecasting, so it is completely standalone. (Scheduling into the future needs some carbon-intensity forecast, but a simple one — or day-ahead published values — suffices; it still does not require a full demand-forecasting system.)

Reuse & dependency direction — this is the FOUNDATION.

  • A separate energy load-forecasting paper reuses the same carbon + scheduler code, adapted to that scope, and cites down to this standalone work as a downstream application: "our forecasts can feed a carbon-aware scheduler [cite]."
  • The arrow points energy-paper → this work, not the other way. So this piece is the foundation; the forecasting paper references it. That is why this can ship first, on its own.

7. Interview questions companies actually ask

 Q [easy]  "What is grid carbon intensity and why does it change hour to hour?"
   A CI(t) = sum(gen_f * EF_f) / sum(gen_f), the generation-weighted average of per-fuel
     emission factors, in gCO2/kWh. It changes because the FUEL MIX changes: windy nights
     are mostly wind/nuclear (clean, ~59), evening peaks fire gas/oil (dirty, ~350) — a ~6x
     swing on ISO-NE for the same kWh. (Google's carbon-intelligent compute is built on this.)

 Q [easy]  "What makes a load 'deferrable'?"
   A It has a deadline but slack: it only needs to finish by some time, so WHEN it runs is
     free. ML training, EV charging, water heating, thermal storage, industrial pre-cooling.
     slack = deadline - release - duration; slack>0 is what makes shifting possible.

 Q [medium] "Walk me through scheduling one deferrable job for minimum carbon."
   A cost(s) = sum_{h=0..D-1} P*CI(s+h). Scan feasible starts s in [release, deadline-D] and
     pick the min-cost window. Energy P*D is fixed; only the CI terms change. With no shared
     capacity this per-job greedy is globally optimal (exchange argument).

 Q [medium] "Why is greedy provably optimal here, and when does that break?"
   A Independence: minimizing each job's own cost minimizes the total, provable by an exchange
     argument (swap any job to a cheaper feasible window; total strictly drops). It BREAKS
     under a shared per-hour capacity cap M — jobs contend, so greedy becomes a heuristic
     (schedule tightest-slack first).

 Q [medium] "How do you prove carbon savings WITHOUT deploying anything?"
   A A historical BACKTEST: take real past fuel-mix data, compute CI(t), run greedy vs a
     carbon-blind FIFO baseline over the same jobs, and report Savings% = (FIFO-greedy)/FIFO.
     No forecasting, no live system — just replay. This is how you de-risk before rollout.

 Q [medium] "What is your baseline and why FIFO?"
   A FIFO = earliest feasible start (run as soon as released), which is the naive carbon-blind
     default most systems already do. It isolates the value of TIMING alone — same jobs, same
     energy, the only difference is choosing a cleaner window.

 Q [hard]  "Two jobs both want 2am and the feeder is capped. What do you do?"
   A Enforce sum of P_j <= M every hour. Use ascending-slack greedy: schedule the tightest-
     deadline job first into its cleanest still-available window, decrement each hour's
     remaining capacity, then the next. Some job gets pushed to a dirtier hour — that's the
     capacity/latency tradeoff, and it's why this is a real heuristic not a closed form.

 Q [hard]  "Average vs marginal emissions — does your saving hold up?"
   A CI here is an AVERAGE intensity. A scheduling decision arguably displaces the MARGINAL
     generator (often gas), so the true avoided emissions can differ. Report the average basis
     honestly and disclose the marginal caveat; for a marginal signal use Electricity Maps or
     WattTime. (Microsoft/Google both discuss this; it's a maturity signal to raise it.)

 Q [hard]  "When does carbon-aware scheduling NOT help?"
   A (1) No slack — the job must run now. (2) Flat CI curve — nothing to shift into. (3) Tight
     SLAs / latency-critical loads. Savings depend on CI variance AND slack; if either is ~0,
     savings ~0. Don't oversell it on inflexible loads.

 Q [hard]  "Temporal vs spatial shifting — what's the difference?"
   A Temporal = move the job in TIME to a cleaner hour (this article). Spatial = move the job
     in SPACE to a cleaner region/datacenter (e.g. run the batch where the grid is greener
     right now). Big cloud providers do both; spatial adds data-locality and egress costs.

 Q [coding] "Given a CI array and (P, D, release, deadline), return the min-carbon start."
   A def best_start(ci, P, D, release, deadline):
         return min(range(release, deadline - D + 1),
                    key=lambda s: sum(P*ci[s+h] for h in range(D)))
     O((window)*D); precompute a prefix sum of ci to make each cost O(1) -> O(window) total.

8. When to use / tradeoffs (honest limits)

  USE carbon-aware scheduling when:
    + the load is deferrable with real SLACK (EV overnight, nightly ML batch, thermal store)
    + the CI curve actually VARIES a lot (renewables-heavy grids: CAISO solar, ISO-NE wind)
    + you can tolerate the latency of deferring (freshness cost is acceptable)

  DON'T bother when:
    - the load is inflexible / latency-critical (tight SLA, interactive, safety-critical)
    - the CI curve is nearly FLAT (mostly one fuel) -> no window is cleaner
    - slack ~ 0 -> nowhere to move -> ~0% savings no matter how smart the scheduler

Honest caveats to state out loud:

  • Average vs marginal. Savings are on an average-CI basis; the marginal generator you actually displace may differ. Disclose it; cite Electricity Maps / WattTime for a marginal signal.
  • Rebound effect. Cheap, clean capacity can induce more load — net emissions may not fall as much as the per-job number suggests.
  • Capacity contention. A per-hour cap means not every job gets the greenest hour; some are pushed to dirtier windows, shaving the headline savings.
  • Latency / freshness. Deferring a job delays its result; for some workloads that staleness has real cost.
  • Forecast risk (future scheduling). Backtests are exact, but scheduling forward relies on a CI forecast that can be wrong.

Full treatment: Green Computing Tradeoffs (No Free Lever).


  • Carbon intensity CI(t) = Σ gen_f·EF_f / Σ gen_f turns the hourly fuel mix into gCO₂/kWh; ISO-NE swings ~59 → 350 (≈).
  • A deferrable job (P, D, release, deadline) has slack; the energy P·D is fixed, so scheduling only changes which hour's CI it pays.
  • Greedy picks the min-cost(s) feasible window; with no shared capacity it is provably optimal (exchange argument). FIFO (earliest start) is the carbon-blind baseline.
  • Savings% = (FIFO − greedy)/FIFO; our worked EV case saves 81.5%. Savings need CI variance AND slack — no slack, no savings.
  • A per-hour cap M makes jobs contend → ascending-slack greedy heuristic; some jobs get pushed to dirtier hours.
  • Prove it with a historical backtestno deployment, no forecasting required. This is the foundation; the load-forecasting paper reuses this code and cites down to it.

Related: Green Computing Tradeoffs (No Free Lever) · Why Grid Carbon Intensity Varies Hour to Hour · Greedy Scheduling & Interval Selection · Scheduling Under a Time-Varying Cost Signal · Green / Carbon-Aware Systems — Interview Questions

Resources