Skip to content

Commit b2f025d

Browse files
ciancbenoit-cty
authored andcommitted
Accumulate emissions periodically to capture carbon intensity variations (#994)
1 parent 3242f27 commit b2f025d

2 files changed

Lines changed: 123 additions & 6 deletions

File tree

codecarbon/emissions_tracker.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,8 @@ def __init__(
337337
self._start_time: Optional[float] = None
338338
self._last_measured_time: float = time.perf_counter()
339339
self._total_energy: Energy = Energy.from_energy(kWh=0)
340+
self._total_emissions: float = 0.0
341+
self._last_energy_covered: Energy = Energy.from_energy(kWh=0)
340342
self._total_water: Water = Water.from_litres(litres=0)
341343
# CPU and RAM utilization tracking
342344
self._cpu_utilization_history: List[float] = []
@@ -757,28 +759,43 @@ def _persist_data(
757759
if len(task_emissions_data) > 0:
758760
handler.task_out(task_emissions_data, experiment_name)
759761

762+
def _update_emissions(self) -> None:
763+
"""
764+
Compute emissions for the energy consumed since the last update
765+
and add them to the total emissions.
766+
"""
767+
delta_energy = self._total_energy - self._last_energy_covered
768+
if delta_energy.kWh > 0:
769+
cloud: CloudMetadata = self._get_cloud_metadata()
770+
if cloud.is_on_private_infra:
771+
delta_emissions = self._emissions.get_private_infra_emissions(
772+
delta_energy, self._geo
773+
)
774+
else:
775+
delta_emissions = self._emissions.get_cloud_emissions(
776+
delta_energy, cloud, self._geo
777+
)
778+
self._total_emissions += delta_emissions
779+
self._last_energy_covered = self._total_energy
780+
760781
def _prepare_emissions_data(self) -> EmissionsData:
761782
"""
762783
Prepare the emissions data to be sent to the API or written to a file.
763784
:return: EmissionsData object with the total emissions data.
764785
"""
786+
self._update_emissions()
765787
cloud: CloudMetadata = self._get_cloud_metadata()
766788
duration: Time = Time.from_seconds(time.perf_counter() - self._start_time)
767789

790+
emissions = self._total_emissions
768791
if cloud.is_on_private_infra:
769-
emissions = self._emissions.get_private_infra_emissions(
770-
self._total_energy, self._geo
771-
) # float: kg co2_eq
772792
country_name = self._geo.country_name
773793
country_iso_code = self._geo.country_iso_code
774794
region = self._geo.region
775795
on_cloud = "N"
776796
cloud_provider = ""
777797
cloud_region = ""
778798
else:
779-
emissions = self._emissions.get_cloud_emissions(
780-
self._total_energy, cloud, self._geo
781-
)
782799
# Try to get cloud region metadata, fall back to geo metadata if not found
783800
try:
784801
country_name = self._emissions.get_cloud_country_name(cloud)

tests/test_issue_994.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import unittest
2+
from unittest.mock import MagicMock, patch
3+
4+
from codecarbon.core.units import Energy, Power
5+
from codecarbon.emissions_tracker import EmissionsTracker
6+
7+
8+
class TestIssue994(unittest.TestCase):
9+
@patch("codecarbon.emissions_tracker.EmissionsTracker._get_geo_metadata")
10+
@patch("codecarbon.emissions_tracker.EmissionsTracker._get_cloud_metadata")
11+
@patch("codecarbon.core.electricitymaps_api.requests.get")
12+
@patch("codecarbon.emissions_tracker.ResourceTracker")
13+
@patch("codecarbon.emissions_tracker.BaseEmissionsTracker.get_detected_hardware")
14+
@patch("codecarbon.emissions_tracker.PeriodicScheduler")
15+
def test_cumulative_emissions_with_varying_intensity(
16+
self,
17+
mock_scheduler,
18+
mock_get_hw,
19+
mock_resource_tracker,
20+
mock_get,
21+
mock_cloud,
22+
mock_geo,
23+
):
24+
# Setup mocks
25+
mock_geo.return_value = MagicMock(
26+
latitude=1.0,
27+
longitude=1.0,
28+
country_iso_code="USA",
29+
country_2letter_iso_code="US",
30+
)
31+
mock_cloud.return_value = MagicMock(
32+
is_on_private_infra=True, provider="azure", region="eastus"
33+
)
34+
mock_get_hw.return_value = {
35+
"ram_total_size": 16.0,
36+
"cpu_count": 8,
37+
"cpu_physical_count": 4,
38+
"cpu_model": "Mock CPU",
39+
"gpu_count": 0,
40+
"gpu_model": "None",
41+
"gpu_ids": None,
42+
}
43+
44+
# Mock Electricity Maps API responses with different intensities
45+
# 1st call: 100 g/kWh, 2nd call: 200 g/kWh, 3rd call: 300 g/kWh
46+
responses = [
47+
MagicMock(status_code=200, json=lambda: {"carbonIntensity": 100}),
48+
MagicMock(status_code=200, json=lambda: {"carbonIntensity": 200}),
49+
MagicMock(status_code=200, json=lambda: {"carbonIntensity": 300}),
50+
]
51+
mock_get.side_effect = responses
52+
53+
tracker = EmissionsTracker(
54+
electricitymaps_api_token="test-token",
55+
save_to_file=False,
56+
measure_power_secs=1,
57+
allow_multiple_runs=True,
58+
)
59+
60+
# Manually inject a mock hardware component
61+
mock_cpu = MagicMock()
62+
from codecarbon.external.hardware import CPU
63+
64+
mock_cpu.__class__ = CPU
65+
# Mock measure_power_and_energy: return 1kWh delta each time
66+
mock_cpu.measure_power_and_energy.return_value = (
67+
Power.from_watts(100),
68+
Energy.from_energy(kWh=1.0),
69+
)
70+
tracker._hardware = [mock_cpu]
71+
72+
# Start tracking
73+
tracker.start()
74+
75+
# Step 1
76+
tracker._measure_power_and_energy()
77+
# total_energy = 1.0, intensity = 100 => emissions = 0.1 kg
78+
data1 = tracker._prepare_emissions_data()
79+
self.assertAlmostEqual(data1.emissions, 0.1)
80+
81+
# Step 2
82+
tracker._measure_power_and_energy()
83+
# total_energy = 2.0, delta_energy = 1.0, intensity = 200 => delta_emissions = 0.2 kg
84+
# total_emissions = 0.1 + 0.2 = 0.3 kg
85+
data2 = tracker._prepare_emissions_data()
86+
self.assertAlmostEqual(data2.emissions, 0.3)
87+
88+
# Step 3
89+
tracker._measure_power_and_energy()
90+
# total_energy = 3.0, delta_energy = 1.0, intensity = 300 => delta_emissions = 0.3 kg
91+
# total_emissions = 0.3 + 0.3 = 0.6 kg
92+
data3 = tracker._prepare_emissions_data()
93+
self.assertAlmostEqual(data3.emissions, 0.6)
94+
95+
# Verification: If it wasn't cumulative, it would be 3.0 kWh * 300 g/kWh = 0.9 kg
96+
self.assertLess(data3.emissions, 0.8)
97+
98+
99+
if __name__ == "__main__":
100+
unittest.main()

0 commit comments

Comments
 (0)