Skip to content

Commit 45bef88

Browse files
authored
feat(emission factors): emission factor model by year, zone, and mode (#8496)
* feat(emission factors): emission factor model by year, zone, and mode * fix * rename to fix name collision * renaming dropped the file * add top level function that's a switch
1 parent 68a4c0b commit 45bef88

File tree

3 files changed

+381
-1
lines changed

3 files changed

+381
-1
lines changed
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
from datetime import datetime, timezone
2+
from operator import itemgetter
3+
from typing import Any
4+
5+
from electricitymap.contrib.config import (
6+
CO2EQ_PARAMETERS_DIRECT,
7+
CO2EQ_PARAMETERS_LIFECYCLE,
8+
ZONE_PARENT,
9+
ZONES_CONFIG,
10+
)
11+
from electricitymap.contrib.config.constants import ENERGIES
12+
from electricitymap.contrib.config.model import (
13+
EmissionFactorVariant,
14+
YearZoneModeEmissionFactor,
15+
)
16+
from electricitymap.contrib.lib.types import ZoneKey
17+
18+
19+
def get_zone_specific_co2eq_parameter(
20+
co2eq_parameters: dict,
21+
zone_key: str,
22+
key: str,
23+
sub_key: str,
24+
dt: datetime,
25+
metadata: bool = False,
26+
) -> dict[str, float]:
27+
if metadata:
28+
return _get_zone_specific_co2eq_parameter_with_metadata(
29+
co2eq_parameters=co2eq_parameters,
30+
zone_key=zone_key,
31+
key=key,
32+
sub_key=sub_key,
33+
dt=dt,
34+
)
35+
else:
36+
return _get_zone_specific_co2eq_parameter_no_metadata(
37+
co2eq_parameters=co2eq_parameters,
38+
zone_key=zone_key,
39+
key=key,
40+
sub_key=sub_key,
41+
dt=dt,
42+
)
43+
44+
45+
def _get_zone_specific_co2eq_parameter_no_metadata(
46+
co2eq_parameters: dict, zone_key: str, key: str, sub_key: str, dt: datetime
47+
) -> dict[str, float]: # TODO: actually this returns Union[Dict, bool]
48+
"""Accessor for co2eq_parameters.
49+
If Available, it will return the zoneOverride value. If not, it will return the default value.
50+
51+
Args:
52+
co2eq_parameters (dict): The dictionary to read from.
53+
zone_key (str): The zone_key to try and find a zoneOverride for.
54+
key (str): The key of the parameter to find.
55+
sub_key (str): The specific sub key inside the parameter that you want to access
56+
dt (datetime): Will return the most recent co2eq for that dt. For the latest co2eq, pass `datetime.max`
57+
58+
Raises:
59+
ValueError: Raised when both the zoneOverride and default value is unavailable
60+
"""
61+
# TODO this doesn't raise a ValueError at the moment. Which gives typing errors because it can return None.
62+
params = co2eq_parameters[key]
63+
zone_key = ZoneKey(zone_key)
64+
65+
defaults = params["defaults"][sub_key]
66+
zone_override = params["zoneOverrides"].get(zone_key, {}).get(sub_key, None)
67+
# If no entry was found, use the parent if it exists
68+
if ZONE_PARENT.get(zone_key) and not zone_override:
69+
zone_override = (
70+
params["zoneOverrides"].get(ZONE_PARENT[zone_key], {}).get(sub_key, None)
71+
)
72+
73+
res = None
74+
res = zone_override if zone_override is not None else defaults
75+
76+
if isinstance(res, list):
77+
# `n` dates are sorted in ascending order (d1, d2, ..., dn)
78+
# d1 is valid from from (epoch to d2)
79+
# d2 is valid from (d2 to d3)
80+
# dn is valid from (dn to end_of_time)
81+
82+
if len(res) == 0:
83+
raise ValueError(
84+
f"Error in given co2eq_parameters. List is empty for [{zone_key}, {key}, {sub_key}]"
85+
)
86+
87+
res.sort(key=itemgetter("datetime"))
88+
dt = dt.replace(tzinfo=timezone.utc)
89+
for co2eq in reversed(res):
90+
co2eq_dt = datetime.fromisoformat(co2eq["datetime"]).replace(
91+
tzinfo=timezone.utc
92+
)
93+
if co2eq_dt <= dt:
94+
return co2eq # type: ignore[no-any-return]
95+
breakpoint()
96+
return res[0] # type: ignore[no-any-return]
97+
98+
else:
99+
return res # type: ignore[no-any-return]
100+
101+
102+
def _get_zone_specific_co2eq_parameter_with_metadata(
103+
co2eq_parameters: dict,
104+
zone_key: str,
105+
key: str,
106+
sub_key: str,
107+
dt: datetime,
108+
) -> dict[str, Any]:
109+
"""
110+
Lookup logic identical to get_zone_specific_co2eq_parameter.
111+
Adds a 'variant' field that provides context about where an emission factor comes from.
112+
Variant selection logic:
113+
114+
GLOBAL/ZONE -> defined in config/defaults.yaml or config/zones/<zone_key>.yaml
115+
116+
*_EXACT_TIMELESS -> no "datetime"
117+
*_EXACT_TIMELY -> has "datetime" and "datetime".year == dt.year
118+
*_FALLBACK_LATEST -> has "datetime" and max("datetime").year < dt.year
119+
*_FALLBACK_OLDER -> has "datetime" and max("datetime").year < dt.year < min("datetime").year and is not EXACT
120+
*_FALLBACK_OLDEST -> has "datetime" and dt.year < min("datetime").year
121+
"""
122+
params = co2eq_parameters[key]
123+
zone_key = ZoneKey(zone_key)
124+
125+
defaults = params["defaults"][sub_key]
126+
zone_override = params["zoneOverrides"].get(zone_key, {}).get(sub_key, None)
127+
# If no entry was found, use the parent if it exists
128+
if ZONE_PARENT.get(zone_key) and not zone_override:
129+
zone_override = (
130+
params["zoneOverrides"].get(ZONE_PARENT[zone_key], {}).get(sub_key, None)
131+
)
132+
133+
res = None
134+
res = zone_override if zone_override is not None else defaults
135+
136+
if isinstance(res, list):
137+
# `n` dates are sorted in ascending order (d1, d2, ..., dn)
138+
# d1 is valid from from (epoch to d2)
139+
# d2 is valid from (d2 to d3)
140+
# dn is valid from (dn to end_of_time)
141+
142+
if len(res) == 0:
143+
raise ValueError(
144+
f"Error in given co2eq_parameters. List is empty for [{zone_key}, {key}, {sub_key}]"
145+
)
146+
147+
res.sort(key=itemgetter("datetime"))
148+
dt = dt.replace(tzinfo=timezone.utc)
149+
for i, co2eq in enumerate(reversed(res)):
150+
co2eq_dt = datetime.fromisoformat(co2eq["datetime"]).replace(
151+
tzinfo=timezone.utc
152+
)
153+
if co2eq_dt <= dt:
154+
ret = co2eq
155+
156+
ef_year = co2eq_dt.year
157+
dt_year = dt.year
158+
if ef_year == dt_year:
159+
if zone_override:
160+
variant = EmissionFactorVariant.ZONE_EXACT_TIMELY
161+
else:
162+
variant = EmissionFactorVariant.GLOBAL_EXACT_TIMELY
163+
elif ef_year < dt_year:
164+
if i == 0:
165+
if zone_override:
166+
variant = EmissionFactorVariant.ZONE_FALLBACK_LATEST
167+
else:
168+
variant = EmissionFactorVariant.GLOBAL_FALLBACK_LATEST
169+
else:
170+
if zone_override:
171+
variant = EmissionFactorVariant.ZONE_FALLBACK_OLDER
172+
else:
173+
variant = EmissionFactorVariant.GLOBAL_FALLBACK_OLDER
174+
175+
ret["variant"] = variant.value
176+
return ret
177+
178+
# res is sorted in ascending order, if we get here
179+
# it means dt < res[0]["datetime"]
180+
ret = res[0]
181+
if zone_override:
182+
variant = EmissionFactorVariant.ZONE_FALLBACK_OLDEST
183+
else:
184+
variant = EmissionFactorVariant.GLOBAL_FALLBACK_OLDEST
185+
ret["variant"] = variant.value
186+
return ret
187+
188+
else:
189+
# res is not a list, which implies it's a dict
190+
# there are two kinds of dicts
191+
# if it has "datetime" then it's timely
192+
# else it's timeless
193+
ret = res
194+
ret_dt = res.get("datetime")
195+
if ret_dt is None:
196+
if zone_override:
197+
variant = EmissionFactorVariant.ZONE_EXACT_TIMELESS
198+
else:
199+
variant = EmissionFactorVariant.GLOBAL_EXACT_TIMELESS
200+
else:
201+
ret_dt_year = (
202+
datetime.fromisoformat(ret_dt).replace(tzinfo=timezone.utc).year
203+
)
204+
dt_year = dt.year
205+
if ret_dt_year == dt_year:
206+
if zone_override:
207+
variant = EmissionFactorVariant.ZONE_EXACT_TIMELY
208+
else:
209+
variant = EmissionFactorVariant.GLOBAL_EXACT_TIMELY
210+
elif ret_dt_year < dt_year:
211+
if zone_override:
212+
variant = EmissionFactorVariant.ZONE_FALLBACK_LATEST
213+
else:
214+
variant = EmissionFactorVariant.GLOBAL_FALLBACK_LATEST
215+
else:
216+
if zone_override:
217+
variant = EmissionFactorVariant.ZONE_FALLBACK_OLDEST
218+
else:
219+
variant = EmissionFactorVariant.GLOBAL_FALLBACK_OLDEST
220+
221+
ret["variant"] = variant.value
222+
return ret
223+
224+
225+
def _get_emission_factor_lifecycle_and_direct(
226+
zone_key, dt, mode
227+
) -> YearZoneModeEmissionFactor:
228+
item = {}
229+
item["dt"] = dt
230+
item["zone_key"] = zone_key
231+
item["mode"] = mode
232+
for description, data in [
233+
("lifecycle", CO2EQ_PARAMETERS_LIFECYCLE),
234+
("direct", CO2EQ_PARAMETERS_DIRECT),
235+
]:
236+
result = get_zone_specific_co2eq_parameter(
237+
co2eq_parameters=data,
238+
zone_key=zone_key,
239+
key="emissionFactors",
240+
sub_key=mode,
241+
dt=dt,
242+
metadata=True,
243+
)
244+
d = {
245+
f"{description}_{k}": v
246+
for k, v in result.items()
247+
if k in ("datetime", "source", "value", "variant")
248+
}
249+
item = {**item, **d}
250+
print(item)
251+
model_obj = YearZoneModeEmissionFactor(**item)
252+
return model_obj
253+
254+
255+
def get_emission_factors_with_metadata_all_years() -> list[YearZoneModeEmissionFactor]:
256+
start = 2015
257+
end = datetime.now(tz=timezone.utc).year
258+
259+
acc = []
260+
for zone_key in ZONES_CONFIG:
261+
for i in range(start, end):
262+
dt = datetime(year=i, month=1, day=1, tzinfo=timezone.utc)
263+
for mode in ENERGIES:
264+
model_obj = _get_emission_factor_lifecycle_and_direct(
265+
zone_key, dt, mode
266+
)
267+
acc.append(model_obj)
268+
269+
return acc

electricitymap/contrib/config/model.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Callable
2-
from datetime import date, datetime
2+
from datetime import date, datetime, timezone
3+
from enum import Enum
34

45
from pydantic import (
56
BaseModel,
@@ -402,6 +403,77 @@ def ids_match_configs(cls, v):
402403
return v
403404

404405

406+
class EmissionFactorVariant(Enum):
407+
"""
408+
Describes where an emission factor (EF) comes from.
409+
See electricitymap/contrib/config/emission_factors.py::_get_zone_specific_co2eq_parameter_with_metadata
410+
"""
411+
412+
GLOBAL_EXACT_TIMELESS = "global_exact_timeless"
413+
GLOBAL_EXACT_TIMELY = "global_exact_timely"
414+
GLOBAL_FALLBACK_LATEST = "global_fallback_latest"
415+
GLOBAL_FALLBACK_OLDER = "global_fallback_older"
416+
GLOBAL_FALLBACK_OLDEST = "global_fallback_oldest"
417+
418+
ZONE_EXACT_TIMELESS = "zone_exact_timeless"
419+
ZONE_EXACT_TIMELY = "zone_exact_timely"
420+
ZONE_FALLBACK_LATEST = "zone_fallback_latest"
421+
ZONE_FALLBACK_OLDER = "zone_fallback_older"
422+
ZONE_FALLBACK_OLDEST = "zone_fallback_oldest"
423+
424+
425+
class EmissionFactorMode(Enum):
426+
BIOMASS = "biomass"
427+
COAL = "coal"
428+
GAS = "gas"
429+
GEOTHERMAL = "geothermal"
430+
HYDRO = "hydro"
431+
NUCLEAR = "nuclear"
432+
OIL = "oil"
433+
SOLAR = "solar"
434+
UNKNOWN = "unknown"
435+
WIND = "wind"
436+
BATTERY_DISCHARGE = "battery discharge"
437+
HYDRO_DISCHARGE = "hydro discharge"
438+
439+
440+
class YearZoneModeEmissionFactor(StrictBaseModelWithAlias):
441+
dt: datetime = Field(..., alias="datetime")
442+
zone_key: ZoneKey
443+
mode: EmissionFactorMode
444+
lifecycle_value: NonNegativeFloat
445+
lifecycle_source: str
446+
lifecycle_variant: EmissionFactorVariant
447+
lifecycle_datetime: datetime | None
448+
direct_value: NonNegativeFloat
449+
direct_source: str
450+
direct_variant: EmissionFactorVariant
451+
direct_datetime: datetime | None
452+
453+
@validator("dt", "lifecycle_datetime", "direct_datetime", pre=True)
454+
def validate_timezone_aware(cls, v: datetime | None) -> datetime | None:
455+
if v is None:
456+
return v
457+
458+
if isinstance(v, str):
459+
v = datetime.fromisoformat(v).replace(tzinfo=timezone.utc)
460+
461+
if v.tzinfo is None or v.tzinfo.utcoffset(v) is None:
462+
raise ValueError("Datetime must be timezone-aware")
463+
464+
truncated_to_year = v.replace(
465+
month=1,
466+
day=1,
467+
hour=0,
468+
minute=0,
469+
second=0,
470+
microsecond=0,
471+
)
472+
if v != truncated_to_year:
473+
raise ValueError("Datetime must be truncated to year.")
474+
return v
475+
476+
405477
DATA_CENTERS_CONFIG_MODEL = DataCenters(data_centers=DATA_CENTERS_CONFIG)
406478

407479

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from datetime import datetime, timezone
2+
3+
from electricitymap.contrib.config import CO2EQ_PARAMETERS_DIRECT
4+
from electricitymap.contrib.config.emission_factors_lookup import (
5+
get_emission_factors_with_metadata_all_years,
6+
get_zone_specific_co2eq_parameter,
7+
)
8+
9+
10+
def test_all_emission_factors():
11+
efs = get_emission_factors_with_metadata_all_years()
12+
expected_len = 46560
13+
assert len(efs) == expected_len
14+
15+
16+
def test_get_zone_specific_co2eq_parameter_identical_return():
17+
zone_key = "AT"
18+
year = 2024
19+
dt = datetime(year=year, month=1, day=1, tzinfo=timezone.utc)
20+
mode = "coal"
21+
22+
res1 = get_zone_specific_co2eq_parameter(
23+
co2eq_parameters=CO2EQ_PARAMETERS_DIRECT,
24+
zone_key=zone_key,
25+
key="emissionFactors",
26+
sub_key=mode,
27+
dt=dt,
28+
)
29+
30+
res2 = get_zone_specific_co2eq_parameter(
31+
co2eq_parameters=CO2EQ_PARAMETERS_DIRECT,
32+
zone_key=zone_key,
33+
key="emissionFactors",
34+
sub_key=mode,
35+
dt=dt,
36+
metadata=True,
37+
)
38+
39+
assert res1["value"] == res2["value"]

0 commit comments

Comments
 (0)