diff --git a/codecarbon/data/private_infra/2023/usa_emissions.json b/codecarbon/data/private_infra/2023/usa_emissions.json new file mode 100644 index 000000000..4b70855b7 --- /dev/null +++ b/codecarbon/data/private_infra/2023/usa_emissions.json @@ -0,0 +1,220 @@ +{ + "_unit": { + "emissions": "lbs/MWh", + "regionCode": "2 digit state/region code" + }, + "_source": { + "name": "EPA eGRID2023, annual CO2 equivalent total output emission rate", + "url": "https://www.epa.gov/egrid/download-data", + "field": "STC2ERTA", + "year": 2023 + }, + "alabama": { + "emissions": 713.976, + "regionCode": "AL" + }, + "alaska": { + "emissions": 814.457, + "regionCode": "AK" + }, + "arizona": { + "emissions": 689.104, + "regionCode": "AZ" + }, + "arkansas": { + "emissions": 998.414, + "regionCode": "AR" + }, + "california": { + "emissions": 394.769, + "regionCode": "CA" + }, + "colorado": { + "emissions": 1091.078, + "regionCode": "CO" + }, + "connecticut": { + "emissions": 540.674, + "regionCode": "CT" + }, + "delaware": { + "emissions": 704.171, + "regionCode": "DE" + }, + "district of columbia": { + "emissions": 395.011, + "regionCode": "DC" + }, + "florida": { + "emissions": 789.133, + "regionCode": "FL" + }, + "georgia": { + "emissions": 717.445, + "regionCode": "GA" + }, + "hawaii": { + "emissions": 1394.68, + "regionCode": "HI" + }, + "idaho": { + "emissions": 313.88, + "regionCode": "ID" + }, + "illinois": { + "emissions": 474.436, + "regionCode": "IL" + }, + "indiana": { + "emissions": 1465.502, + "regionCode": "IN" + }, + "iowa": { + "emissions": 634.156, + "regionCode": "IA" + }, + "kansas": { + "emissions": 733.284, + "regionCode": "KS" + }, + "kentucky": { + "emissions": 1747.004, + "regionCode": "KY" + }, + "louisiana": { + "emissions": 763.068, + "regionCode": "LA" + }, + "maine": { + "emissions": 316.835, + "regionCode": "ME" + }, + "maryland": { + "emissions": 522.207, + "regionCode": "MD" + }, + "massachusetts": { + "emissions": 830.101, + "regionCode": "MA" + }, + "michigan": { + "emissions": 797.432, + "regionCode": "MI" + }, + "minnesota": { + "emissions": 752.329, + "regionCode": "MN" + }, + "mississippi": { + "emissions": 828.274, + "regionCode": "MS" + }, + "missouri": { + "emissions": 1454.593, + "regionCode": "MO" + }, + "montana": { + "emissions": 1063.921, + "regionCode": "MT" + }, + "nebraska": { + "emissions": 1025.822, + "regionCode": "NE" + }, + "nevada": { + "emissions": 643.859, + "regionCode": "NV" + }, + "new hampshire": { + "emissions": 276.222, + "regionCode": "NH" + }, + "new jersey": { + "emissions": 470.168, + "regionCode": "NJ" + }, + "new mexico": { + "emissions": 773.637, + "regionCode": "NM" + }, + "new york": { + "emissions": 466.616, + "regionCode": "NY" + }, + "north carolina": { + "emissions": 626.438, + "regionCode": "NC" + }, + "north dakota": { + "emissions": 1297.852, + "regionCode": "ND" + }, + "ohio": { + "emissions": 1068.27, + "regionCode": "OH" + }, + "oklahoma": { + "emissions": 648.834, + "regionCode": "OK" + }, + "oregon": { + "emissions": 364.963, + "regionCode": "OR" + }, + "pennsylvania": { + "emissions": 647.871, + "regionCode": "PA" + }, + "rhode island": { + "emissions": 840.004, + "regionCode": "RI" + }, + "south carolina": { + "emissions": 560.363, + "regionCode": "SC" + }, + "south dakota": { + "emissions": 335.582, + "regionCode": "SD" + }, + "tennessee": { + "emissions": 661.166, + "regionCode": "TN" + }, + "texas": { + "emissions": 771.179, + "regionCode": "TX" + }, + "utah": { + "emissions": 1421.234, + "regionCode": "UT" + }, + "vermont": { + "emissions": 52.239, + "regionCode": "VT" + }, + "virginia": { + "emissions": 539.599, + "regionCode": "VA" + }, + "washington": { + "emissions": 266.585, + "regionCode": "WA" + }, + "west virginia": { + "emissions": 1968.869, + "regionCode": "WV" + }, + "wisconsin": { + "emissions": 1163.572, + "regionCode": "WI" + }, + "wyoming": { + "emissions": 1833.263, + "regionCode": "WY" + }, + "usa": { + "emissions": 770.884, + "regionCode": "usa" + } +} diff --git a/codecarbon/data/private_infra/update_usa_emissions.py b/codecarbon/data/private_infra/update_usa_emissions.py new file mode 100644 index 000000000..2e8220b32 --- /dev/null +++ b/codecarbon/data/private_infra/update_usa_emissions.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Build ``usa_emissions.json`` from the EPA eGRID data workbook. + +Download the workbook from https://www.epa.gov/egrid/download-data, then run: + + uv run --with openpyxl python codecarbon/data/private_infra/update_usa_emissions.py \\ + egrid2023_data_rev2.xlsx --year 2023 + +The state rates come from the ``ST`` sheet and the national rate from the +``US`` sheet. Both sheets have a description row, then a row of eGRID +field names, then the data. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pandas as pd + +STATE_NAMES = { + "AK": "alaska", + "AL": "alabama", + "AR": "arkansas", + "AZ": "arizona", + "CA": "california", + "CO": "colorado", + "CT": "connecticut", + "DC": "district of columbia", + "DE": "delaware", + "FL": "florida", + "GA": "georgia", + "HI": "hawaii", + "IA": "iowa", + "ID": "idaho", + "IL": "illinois", + "IN": "indiana", + "KS": "kansas", + "KY": "kentucky", + "LA": "louisiana", + "MA": "massachusetts", + "MD": "maryland", + "ME": "maine", + "MI": "michigan", + "MN": "minnesota", + "MO": "missouri", + "MS": "mississippi", + "MT": "montana", + "NC": "north carolina", + "ND": "north dakota", + "NE": "nebraska", + "NH": "new hampshire", + "NJ": "new jersey", + "NM": "new mexico", + "NV": "nevada", + "NY": "new york", + "OH": "ohio", + "OK": "oklahoma", + "OR": "oregon", + "PA": "pennsylvania", + "RI": "rhode island", + "SC": "south carolina", + "SD": "south dakota", + "TN": "tennessee", + "TX": "texas", + "UT": "utah", + "VA": "virginia", + "VT": "vermont", + "WA": "washington", + "WI": "wisconsin", + "WV": "west virginia", + "WY": "wyoming", +} + +RATE_FIELDS = { + "co2e": ("C2ERTA", "CO2 equivalent"), + "co2": ("CO2RTA", "CO2"), +} + + +def read_egrid_rates(workbook: Path, year: int, gas: str = "co2e") -> tuple: + """Return the state rates and the national rate (lb/MWh) for one gas.""" + yy = f"{year % 100:02d}" + field = RATE_FIELDS[gas][0] + states = pd.read_excel(workbook, sheet_name=f"ST{yy}", header=1) + national = pd.read_excel(workbook, sheet_name=f"US{yy}", header=1) + state_rates = dict(zip(states["PSTATABB"], states[f"ST{field}"])) + return state_rates, float(national[f"US{field}"].iloc[0]) + + +def build_usa_emissions( + state_rates: dict, national_rate: float, year: int, gas: str = "co2e" +) -> dict: + field, gas_label = RATE_FIELDS[gas] + missing = sorted(set(STATE_NAMES) - set(state_rates)) + if missing: + raise ValueError(f"States missing from eGRID{year}: {', '.join(missing)}") + + data = { + "_unit": { + "emissions": "lbs/MWh", + "regionCode": "2 digit state/region code", + }, + "_source": { + "name": f"EPA eGRID{year}, annual {gas_label} total output emission rate", + "url": "https://www.epa.gov/egrid/download-data", + "field": f"ST{field}", + "year": year, + }, + } + for code, name in sorted(STATE_NAMES.items(), key=lambda item: item[1]): + data[name] = { + "emissions": round(float(state_rates[code]), 3), + "regionCode": code, + } + data["usa"] = {"emissions": round(national_rate, 3), "regionCode": "usa"} + return data + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("workbook", type=Path, help="eGRID data workbook (.xlsx)") + parser.add_argument("--year", type=int, required=True, help="eGRID data year") + parser.add_argument("--gas", choices=sorted(RATE_FIELDS), default="co2e") + parser.add_argument( + "--output", + type=Path, + default=None, + help="defaults to /usa_emissions.json next to this script", + ) + args = parser.parse_args() + + output = args.output or ( + Path(__file__).resolve().parent / str(args.year) / "usa_emissions.json" + ) + state_rates, national_rate = read_egrid_rates(args.workbook, args.year, args.gas) + data = build_usa_emissions(state_rates, national_rate, args.year, args.gas) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(data, indent=2) + "\n") + print(f"Wrote {len(STATE_NAMES)} states and the national rate to {output}") + + +if __name__ == "__main__": + main() diff --git a/codecarbon/input.py b/codecarbon/input.py index eaf26644c..677348798 100644 --- a/codecarbon/input.py +++ b/codecarbon/input.py @@ -85,7 +85,7 @@ def __init__(self): self.config = { "geo_js_url": "https://get.geojs.io/v1/ip/geo.json", "cloud_emissions_path": "data/cloud/impact.csv", - "usa_emissions_data_path": "data/private_infra/2016/usa_emissions.json", + "usa_emissions_data_path": "data/private_infra/2023/usa_emissions.json", "can_energy_mix_data_path": "data/private_infra/2023/canada_energy_mix.json", # noqa: E501 "global_energy_mix_data_path": "data/private_infra/global_energy_mix.json", # noqa: E501 "carbon_intensity_per_source_path": "data/private_infra/carbon_intensity_per_source.json", diff --git a/docs/explanation/methodology.md b/docs/explanation/methodology.md index ef6a09c1f..842a58ac6 100644 --- a/docs/explanation/methodology.md +++ b/docs/explanation/methodology.md @@ -42,11 +42,19 @@ reports a region in one of them: | Country | Granularity | Source | |---------|-------------|--------| -| United States | State | [Emissions per state, 2016](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/2016/usa_emissions.json) | +| United States | State | [EPA eGRID2023 emission rate per state](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/2023/usa_emissions.json) | | Canada | Province | [Energy mix per province, 2023](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/2023/canada_energy_mix.json) | | Sweden, Norway, Finland | Electricity bidding zone (SE1-SE4, NO1-NO5, FI) | [ENTSO-E generation mix, 2025](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/nordic_emissions.json) | | China | Province (30 provinces, municipalities and autonomous regions) | [MEE/NBS Announcement 2025 No. 47, 2023 factors](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/2023/china_provincial_emissions.json) | +For the United States, the values are the eGRID2023 state annual CO₂eq +total output emission rates published by the U.S. Environmental Protection +Agency ([source](https://www.epa.gov/egrid/download-data)). They range from +24 gCO₂eq/kWh in Vermont to 893 gCO₂eq/kWh in West Virginia, with a national +value of 350 gCO₂eq/kWh. To update them when EPA publishes a new edition, +download the eGRID data workbook and run +`uv run --with openpyxl python codecarbon/data/private_infra/update_usa_emissions.py --year `. + For China, the values are the official provincial average electricity CO₂ emission factors published by the Ministry of Ecology and Environment and the National Bureau of Statistics diff --git a/pyproject.toml b/pyproject.toml index 57d3f2154..fea5ec51d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ codecarbon = [ "data/private_infra/global_energy_mix.json", "data/private_infra/carbon_intensity_per_source.json", "data/private_infra/nordic_emissions.json", - "data/private_infra/2016/usa_emissions.json", + "data/private_infra/2023/usa_emissions.json", "data/private_infra/2023/canada_energy_mix.json", "data/private_infra/2023/china_provincial_emissions.json", "viz/assets/car_icon.png", @@ -198,4 +198,5 @@ source = [ ] omit = [ "codecarbon/viz/*", + "codecarbon/data/*", ] diff --git a/tests/test_emissions.py b/tests/test_emissions.py index 2e067d306..8dd5c3856 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -165,8 +165,9 @@ def test_get_emissions_PRIVATE_INFRA_USA_WITH_REGION(self): ) # THEN + # Illinois: 474.4 lbCO2eq/MWh in eGRID2023 assert isinstance(emissions, float) - self.assertAlmostEqual(emissions, 0.11, places=2) + self.assertAlmostEqual(emissions, 0.3 * 474.4 * 0.00045359237, places=4) def test_get_emissions_PRIVATE_INFRA_USA_WITHOUT_REGION(self): # WHEN @@ -571,3 +572,35 @@ def test_china_forced_intensity_takes_precedence(self): ) self.assertAlmostEqual(emissions, 0.1, places=6) + + def _usa_geo(self, region): + return GeoMetadata( + country_iso_code="USA", country_name="United States", region=region + ) + + def test_usa_state_rates_match_egrid2023(self): + data = self._data_source.get_country_emissions_data("usa") + + self.assertEqual(data["_source"]["year"], 2023) + self.assertEqual(data["_source"]["field"], "STC2ERTA") + states = {k: v for k, v in data.items() if not k.startswith("_")} + self.assertEqual(len(states), 52) + self.assertAlmostEqual(data["georgia"]["emissions"], 717.4, delta=0.05) + self.assertAlmostEqual(data["vermont"]["emissions"], 52.2, delta=0.05) + self.assertAlmostEqual(data["west virginia"]["emissions"], 1968.9, delta=0.05) + self.assertAlmostEqual(data["usa"]["emissions"], 770.9, delta=0.05) + + def test_usa_every_state_resolves_to_its_own_rate(self): + data = self._data_source.get_country_emissions_data("usa") + energy = Energy.from_energy(kWh=1.0) + + for name, state in data.items(): + if name.startswith("_") or name == "usa": + continue + with self.subTest(state=name): + emissions = self._emissions.get_private_infra_emissions( + energy, self._usa_geo(name.title()) + ) + self.assertAlmostEqual( + emissions, state["emissions"] * 0.00045359237, places=9 + ) diff --git a/tests/test_update_usa_emissions.py b/tests/test_update_usa_emissions.py new file mode 100644 index 000000000..14fa2e5e4 --- /dev/null +++ b/tests/test_update_usa_emissions.py @@ -0,0 +1,102 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "codecarbon/data/private_infra/update_usa_emissions.py" +) +DATA = ( + Path(__file__).resolve().parents[1] + / "codecarbon/data/private_infra/2023/usa_emissions.json" +) + +# The script is a maintenance tool that is not shipped in the wheel, so the +# wheel test run (which only checks out tests/) does not have it. +pytestmark = pytest.mark.skipif( + not SCRIPT.exists(), reason="update script is not part of the installed package" +) + + +@pytest.fixture(scope="module") +def script(): + spec = importlib.util.spec_from_file_location("update_usa_emissions", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _rates(script, value=500.0): + return {code: value for code in script.STATE_NAMES} + + +def test_build_has_every_state_and_the_national_rate(script): + data = script.build_usa_emissions(_rates(script), 600.0, 2023) + + assert data["georgia"] == {"emissions": 500.0, "regionCode": "GA"} + assert data["district of columbia"]["regionCode"] == "DC" + assert data["usa"] == {"emissions": 600.0, "regionCode": "usa"} + assert data["_source"]["field"] == "STC2ERTA" + assert len([k for k in data if not k.startswith("_")]) == 52 + + +def test_build_can_use_co2_only_rates(script): + data = script.build_usa_emissions(_rates(script), 600.0, 2023, gas="co2") + + assert data["_source"]["field"] == "STCO2RTA" + + +def test_build_rejects_a_missing_state(script): + rates = _rates(script) + del rates["WY"] + + with pytest.raises(ValueError, match="WY"): + script.build_usa_emissions(rates, 600.0, 2023) + + +def test_extra_regions_are_ignored(script): + rates = _rates(script) + rates["PR"] = 1548.5 + + data = script.build_usa_emissions(rates, 600.0, 2023) + + assert "puerto rico" not in data + assert all(v.get("regionCode") != "PR" for v in data.values()) + + +def test_packaged_file_matches_the_script_format(script): + packaged = json.loads(DATA.read_text()) + state_rates = { + v["regionCode"]: v["emissions"] + for k, v in packaged.items() + if not k.startswith("_") and k != "usa" + } + + rebuilt = script.build_usa_emissions( + state_rates, packaged["usa"]["emissions"], 2023 + ) + + assert rebuilt == packaged + + +def test_read_egrid_rates_from_workbook(script, tmp_path): + pytest.importorskip("openpyxl") + import pandas as pd + + workbook = tmp_path / "egrid.xlsx" + with pd.ExcelWriter(workbook) as writer: + for sheet, frame in { + "ST23": pd.DataFrame({"PSTATABB": ["GA", "VT"], "STC2ERTA": [717.4, 52.2]}), + "US23": pd.DataFrame({"USC2ERTA": [770.9]}), + }.items(): + pd.DataFrame([["description"] * len(frame.columns)]).to_excel( + writer, sheet_name=sheet, index=False, header=False + ) + frame.to_excel(writer, sheet_name=sheet, index=False, startrow=1) + + state_rates, national_rate = script.read_egrid_rates(workbook, 2023) + + assert state_rates == {"GA": 717.4, "VT": 52.2} + assert national_rate == 770.9