Skip to content

Commit 70e5a02

Browse files
committed
2.4.3 hotifx
1 parent 4d6017d commit 70e5a02

8 files changed

Lines changed: 221 additions & 18 deletions

File tree

carbontracker/config/fetchers.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"version": "2024-30-10",
33
"intensityFetchers": {
44
"electricitymaps": {
5-
"displayName": "Elictricity Maps API",
5+
"displayName": "Electricity Maps API",
66
"description": "Electricity Maps provides global access to electricity mix, prices and carbon intensity. ",
77
"requiredCredentials": [
88
{

carbontracker/emissions/intensity/fetchers/electricitymaps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ def fetch_carbon_intensity(self, g_location, time_dur=None) -> IntensityFetch:
2222
ci = self._carbon_intensity_by_location(lon=g_location.lng, lat=g_location.lat)
2323
except:
2424
ci = self._carbon_intensity_by_location(zone=g_location.country)
25-
25+
2626
return IntensityFetch(
27-
carbon_intensity=ci,
27+
carbon_intensity=float(ci),
2828
address=g_location.address,
2929
country=g_location.country,
3030
is_fetched=True,

carbontracker/emissions/intensity/intensity.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def fetch_carbon_intensity(self, time_duration = None) -> IntensityFetch:
3838

3939
return result;
4040
except:
41+
4142
self._log_fetch_failed()
4243
return self.default_carbon_intensity
4344

@@ -47,7 +48,7 @@ def _get_default_carbon_intensity(self) -> IntensityFetch:
4748
intensity = constants.WORLD_AVG_CARBON_INTENSITY
4849

4950
return IntensityFetch(
50-
carbon_intensity=intensity,
51+
carbon_intensity=float(intensity),
5152
address=self.address,
5253
country=self.country,
5354
is_localized=False,
@@ -74,9 +75,9 @@ def _get_default_carbon_intensity(self) -> IntensityFetch:
7475
intensity_row = carbon_intensities_df[
7576
carbon_intensities_df["alpha-2"] == self.country
7677
].iloc[0]
77-
intensity: float = intensity_row["Carbon intensity of electricity (gCO2eq/kWh)"]
78+
intensity = intensity_row["Carbon intensity of electricity (gCO2eq/kWh)"]
7879
return IntensityFetch(
79-
carbon_intensity=intensity,
80+
carbon_intensity=float(intensity),
8081
address=self.address,
8182
country=self.country,
8283
is_fetched=False,
@@ -87,7 +88,7 @@ def _get_default_carbon_intensity(self) -> IntensityFetch:
8788
self.logger.err_debug(err)
8889
intensity = constants.WORLD_AVG_CARBON_INTENSITY
8990
return IntensityFetch(
90-
carbon_intensity=intensity,
91+
carbon_intensity=float(intensity),
9192
address=self.address,
9293
country=self.country,
9394
is_localized=False,

carbontracker/report.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def __init__(self, log_content):
5555
self._parse_log()
5656

5757
def _parse_log(self):
58+
print("Parsing Log")
5859
# Parse version and PUE info
5960
version_match = re.search(r'carbontracker version ([\d\.]+)', self.log_content)
6061
if version_match:
@@ -86,13 +87,16 @@ def _parse_log(self):
8687
'cpu_power': cpu_power,
8788
'total_power': gpu_power + cpu_power
8889
})
89-
90-
# Parse carbon intensity
91-
ci_match = re.search(r'Average carbon intensity during training was ([\d\.]+) gCO2eq/kWh at detected location: (.*)', self.log_content)
90+
91+
ci_match = re.search(r'Average carbon intensity during training was ([\d\.]+) gCO2eq/kWh', self.log_content)
9292
if ci_match:
9393
self.carbon_intensity = float(ci_match.group(1))
94-
self.location = ci_match.group(2)
9594

95+
location_match = r'Carbon intensities .*? at detected location ([A-Za-z .-]+, [A-Za-z .-]+, [A-Z]{2})'
96+
location_match = re.search(location_match, self.log_content)
97+
if location_match:
98+
self.location = location_match.group(1)
99+
96100
# Parse timestamps
97101
timestamp_pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})'
98102
timestamps = re.findall(timestamp_pattern, self.log_content)

carbontracker/tracker.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,23 +79,26 @@ def predict_carbon_intensity(self, pred_time_dur) -> float:
7979

8080
def average_carbon_intensity(self) -> float :
8181
if not self.carbon_intensities_fetches:
82-
ci = self.carbon_intensity_service.fetch_carbon_intensity(time_duration=None)
83-
self.carbon_intensities_fetches.append(ci)
82+
ci_fetch = self.carbon_intensity_service.fetch_carbon_intensity(time_duration=None)
83+
self.carbon_intensities_fetches.append(ci_fetch)
8484

85-
location = self.carbon_intensities_fetches[-1].address
85+
location = self.carbon_intensity_service.address
8686
intensities = [ci.carbon_intensity for ci in self.carbon_intensities_fetches]
8787
avg_intensity = np.mean(intensities)
8888

8989
msg = (
9090
f"Average carbon intensity during training was {avg_intensity:.2f}"
9191
f" gCO2eq/kWh. "
9292
)
93-
93+
94+
formatted_intensities = [round(float(intensity),2) for intensity in intensities]
95+
9496
self.logger.info(
9597
"Carbon intensities (gCO2eq/kWh) fetched every "
96-
f"{self.update_interval} s at detected location {location}: "
97-
f"{intensities}"
98+
f"{self.update_interval} s at detected location: {location}: "
99+
f"{formatted_intensities}"
98100
)
101+
self.logger.info(msg)
99102
self.logger.output(msg, verbose_level=1)
100103

101104
return float(avg_intensity)

tests/intensity/test_electricitymaps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_carbon_intensity(self, mock_carbon_intensity_by_location):
6666
intensity_fetch = self.electricity_map.fetch_carbon_intensity(self.g_location)
6767

6868
self.assertEqual(intensity_fetch.carbon_intensity, 100.0)
69-
69+
self.assertIsInstance(intensity_fetch.carbon_intensity, float)
7070
@patch.object(ElectricityMap, "_carbon_intensity_by_location")
7171
def test_carbon_intensity_with_exception(self, mock_carbon_intensity_by_location):
7272
mock_carbon_intensity_by_location.side_effect = [Exception(), 25.0]

tests/intensity/test_intensity.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def test_get_default_intensity_success(self, mock_geocoder_ip):
3535
expected_intensity = intensity_row["Carbon intensity of electricity (gCO2eq/kWh)"]
3636

3737
self.assertEqual(default_intensity_fetch.carbon_intensity, expected_intensity)
38+
self.assertIsInstance(default_intensity_fetch.carbon_intensity, float)
3839

3940

4041
msg = (f"No carbon intensity provider specified. "
@@ -87,6 +88,7 @@ def test_get_default_intensity_location_failure_without_fetcher(self, mock_geoco
8788
f"Defaulting to global average carbon intensity for {constants.WORLD_AVG_CARBON_INTENSITY_YEAR}: "
8889
f"{constants.WORLD_AVG_CARBON_INTENSITY:.2f} gCO2eq/kWh."
8990
)
91+
self.assertIsInstance(default_intensity_fetch.carbon_intensity, float)
9092

9193
logger.err_warn.assert_called_with(msg)
9294

@@ -115,6 +117,7 @@ def test_get_default_intensity_data_file_failure(
115117
logger.err_warn.assert_called_with(msg)
116118

117119
self.assertEqual(default_intensity.carbon_intensity,constants.WORLD_AVG_CARBON_INTENSITY)
120+
self.assertIsInstance(default_intensity.carbon_intensity, float)
118121

119122

120123
@patch("geocoder.ip")
@@ -135,6 +138,7 @@ def test_get_default_intensity_data_file_failure_with_fetcher(
135138
default_intensity = intensity_service.default_carbon_intensity
136139
logger.err_warn.assert_not_called()
137140
self.assertEqual(default_intensity.carbon_intensity,constants.WORLD_AVG_CARBON_INTENSITY)
141+
self.assertIsInstance(default_intensity.carbon_intensity, float)
138142

139143

140144
@patch("geocoder.ip")
@@ -147,6 +151,7 @@ def test_get_default_intensity_ip_location_failure(self, mock_geocoder_ip):
147151
default_intensity = intensity_service.default_carbon_intensity
148152

149153
self.assertEqual(default_intensity.carbon_intensity, constants.WORLD_AVG_CARBON_INTENSITY)
154+
self.assertIsInstance(default_intensity.carbon_intensity, float)
150155
msg = (
151156
f"No carbon intensity provider specified and no location detected. "
152157
f"Defaulting to global average carbon intensity for {constants.WORLD_AVG_CARBON_INTENSITY_YEAR}: "
@@ -206,6 +211,7 @@ def test_default_ci_on_location_failure_without_fetcher(self, mock_geocoder_ip):
206211
self.assertEqual(default_intensity.is_fetched, False)
207212
self.assertEqual(default_intensity.is_localized, False)
208213
self.assertEqual(default_intensity.is_prediction, False)
214+
self.assertIsInstance(default_intensity.carbon_intensity, float)
209215

210216

211217
msg_err = (f"No carbon intensity provider specified and no location detected. "
@@ -229,6 +235,7 @@ def test_default_ci_on_location_failure_with_fetcher(self, mock_electricity_map,
229235
self.assertEqual(default_intensity.is_fetched, False)
230236
self.assertEqual(default_intensity.is_localized, False)
231237
self.assertEqual(default_intensity.is_prediction, False)
238+
self.assertIsInstance(default_intensity.carbon_intensity, float)
232239

233240
msg = (
234241
f"Location could not be determined. "
@@ -275,6 +282,7 @@ def test_carbon_intensity_failure_on_carbon_intensity_fetch(self, mock_electrici
275282
intensity_fetch = intensity_service.fetch_carbon_intensity()
276283

277284

285+
self.assertIsInstance(intensity_fetch.carbon_intensity, float)
278286
self.assertFalse(intensity_fetch.is_fetched)
279287
self.assertTrue(intensity_fetch == intensity_service.default_carbon_intensity)
280288

@@ -306,6 +314,7 @@ def test_carbon_intensity_exception_carbonintensitygb(self, mock_geocoder, mock_
306314

307315
intensity_fetch = intensity_service.fetch_carbon_intensity()
308316
self.assertEqual(intensity_fetch.carbon_intensity, 23.0)
317+
self.assertIsInstance(intensity_fetch.carbon_intensity, float)
309318
self.assertTrue(intensity_fetch.is_fetched)
310319

311320
@patch("carbontracker.emissions.intensity.fetchers.energidataservice.EnergiDataService")
@@ -326,5 +335,6 @@ def test_carbon_intensity_energidataservice(self,mock_geocoder, mock_energidatas
326335
logger = MagicMock()
327336
intensity_service = IntensityService(logger, mock_energidataservice.return_value)
328337
intensity_fetch = intensity_service.fetch_carbon_intensity()
338+
self.assertIsInstance(intensity_fetch.carbon_intensity, float)
329339
self.assertEqual(intensity_fetch.carbon_intensity, 23.0)
330340
self.assertTrue(intensity_fetch.is_fetched)

tests/test_report.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import io
2+
import os
3+
import sys
4+
import tempfile
5+
import types
6+
import unittest
7+
from unittest import mock
8+
9+
10+
def _install_reportlab_stubs():
11+
"""Provides minimal reportlab replacements so report.py can be imported."""
12+
reportlab_module = types.ModuleType("reportlab")
13+
lib_module = types.ModuleType("reportlab.lib")
14+
reportlab_module.lib = lib_module
15+
16+
colors_module = types.ModuleType("reportlab.lib.colors")
17+
colors_module.HexColor = lambda value: value
18+
lib_module.colors = colors_module
19+
20+
pagesizes_module = types.ModuleType("reportlab.lib.pagesizes")
21+
pagesizes_module.letter = ("letter", "letter")
22+
lib_module.pagesizes = pagesizes_module
23+
24+
units_module = types.ModuleType("reportlab.lib.units")
25+
units_module.inch = 1
26+
lib_module.units = units_module
27+
28+
styles_module = types.ModuleType("reportlab.lib.styles")
29+
30+
def getSampleStyleSheet():
31+
return {"Heading1": object(), "Heading2": object(), "Normal": object()}
32+
33+
class ParagraphStyle:
34+
def __init__(self, *args, **kwargs):
35+
self.args = args
36+
self.kwargs = kwargs
37+
38+
styles_module.getSampleStyleSheet = getSampleStyleSheet
39+
styles_module.ParagraphStyle = ParagraphStyle
40+
lib_module.styles = styles_module
41+
42+
platypus_module = types.ModuleType("reportlab.platypus")
43+
44+
class SimpleDocTemplate:
45+
def __init__(self, *args, **kwargs):
46+
self.args = args
47+
self.kwargs = kwargs
48+
49+
def build(self, story):
50+
self.story = story
51+
52+
class Paragraph:
53+
def __init__(self, text, style):
54+
self.text = text
55+
self.style = style
56+
57+
class Spacer:
58+
def __init__(self, width, height):
59+
self.width = width
60+
self.height = height
61+
62+
class Table:
63+
def __init__(self, data, colWidths=None, rowHeights=None):
64+
self.data = data
65+
self.colWidths = colWidths
66+
self.rowHeights = rowHeights
67+
68+
def setStyle(self, style):
69+
self.style = style
70+
71+
class TableStyle:
72+
def __init__(self, instructions):
73+
self.instructions = instructions
74+
75+
class Image:
76+
def __init__(self, data, width=None, height=None):
77+
self.data = data
78+
self.width = width
79+
self.height = height
80+
81+
platypus_module.SimpleDocTemplate = SimpleDocTemplate
82+
platypus_module.Paragraph = Paragraph
83+
platypus_module.Spacer = Spacer
84+
platypus_module.Table = Table
85+
platypus_module.TableStyle = TableStyle
86+
platypus_module.Image = Image
87+
reportlab_module.platypus = platypus_module
88+
89+
module_map = {
90+
"reportlab": reportlab_module,
91+
"reportlab.lib": lib_module,
92+
"reportlab.lib.colors": colors_module,
93+
"reportlab.lib.pagesizes": pagesizes_module,
94+
"reportlab.lib.styles": styles_module,
95+
"reportlab.lib.units": units_module,
96+
"reportlab.platypus": platypus_module,
97+
}
98+
99+
for name, module in module_map.items():
100+
sys.modules.setdefault(name, module)
101+
102+
103+
try:
104+
from carbontracker import report
105+
except ModuleNotFoundError as exc:
106+
if exc.name and exc.name.startswith("reportlab"):
107+
_install_reportlab_stubs()
108+
from carbontracker import report
109+
else:
110+
raise
111+
112+
113+
SAMPLE_LOG = """\
114+
2025-11-18 15:53:54 - carbontracker version 2.3.4.dev1+g61759d185.d20251107
115+
2025-11-18 15:53:54 - Only predicted and actual consumptions are multiplied by a PUE coefficient of 1.58 (Daniel Bizo, 2023, Uptime Institute Global Data Center Survey).
116+
2025-11-18 15:53:54 - The following components were found: GPU with device(s) GPU, ANE. CPU with device(s) CPU.
117+
2025-11-18 15:53:54 - Monitoring thread started.
118+
2025-11-18 15:54:28 - Epoch 1:
119+
2025-11-18 15:54:28 - Duration: 0:00:33.57
120+
2025-11-18 15:54:28 - Average power usage (W) for gpu: 0.019533333333333337
121+
2025-11-18 15:54:28 - Average power usage (W) for cpu: 6.785066666666666
122+
2025-11-18 15:54:28 - Carbon intensities (gCO2eq/kWh) fetched every 900 s at detected location: Copenhagen, Capital Region, DK: [143.3]
123+
2025-11-18 15:54:28 - Average carbon intensity during training was 143.30 gCO2eq/kWh.
124+
2025-11-18 15:54:28 - Monitoring thread ended.
125+
"""
126+
127+
128+
class TestReportModule(unittest.TestCase):
129+
def setUp(self):
130+
self.parser = report.LogParser(SAMPLE_LOG)
131+
132+
def test_format_duration_produces_readable_value(self):
133+
self.assertEqual(report.format_duration(3661), "1h 1min 1s")
134+
self.assertEqual(report.format_duration(59), "59s")
135+
136+
def test_log_parser_extracts_metadata_and_epochs(self):
137+
parser = self.parser
138+
self.assertEqual(parser.version, "2.3.4.")
139+
self.assertEqual(parser.pue, 1.58)
140+
self.assertIsNone(parser.location)
141+
self.assertEqual(parser.start_time, "2025-11-18 15:53:54")
142+
self.assertEqual(parser.end_time, "2025-11-18 15:54:28")
143+
self.assertEqual(len(parser.epochs), 1)
144+
self.assertAlmostEqual(parser.epochs[0]["duration"], 33.57)
145+
self.assertAlmostEqual(parser.epochs[0]["gpu_power"], 0.019533333333333337)
146+
self.assertAlmostEqual(parser.epochs[0]["cpu_power"], 6.785066666666666)
147+
self.assertAlmostEqual(parser.epochs[0]["total_power"], 6.8046)
148+
149+
def test_calculate_energy_metrics_matches_expected_numbers(self):
150+
metrics = self.parser.calculate_energy_metrics()
151+
self.assertAlmostEqual(metrics["total_duration"], 33.57)
152+
self.assertAlmostEqual(metrics["avg_gpu_power"], 0.019533333333333337)
153+
self.assertAlmostEqual(metrics["avg_cpu_power"], 6.785066666666666)
154+
self.assertAlmostEqual(metrics["total_power"], 6.8046)
155+
self.assertAlmostEqual(metrics["energy_kwh"], 6.345289499999999e-05, places=12)
156+
self.assertAlmostEqual(metrics["co2_kg"], 9.092799853499999e-06, places=12)
157+
158+
def test_generate_plots_returns_png_buffer(self):
159+
plots = self.parser.generate_plots()
160+
self.assertIn("combined_plots", plots)
161+
plot_buffer = plots["combined_plots"]
162+
self.assertGreater(len(plot_buffer.getvalue()), 0)
163+
164+
def test_generate_report_from_log_builds_document(self):
165+
with tempfile.TemporaryDirectory() as tmp_dir:
166+
log_path = os.path.join(tmp_dir, "training.log")
167+
output_path = os.path.join(tmp_dir, "report.pdf")
168+
with open(log_path, "w", encoding="utf-8") as log_file:
169+
log_file.write(SAMPLE_LOG)
170+
171+
with mock.patch("carbontracker.report.SimpleDocTemplate") as mock_doc_template, mock.patch(
172+
"carbontracker.report.Image"
173+
) as mock_image:
174+
mock_image.return_value = mock.Mock(name="ImageFlowable")
175+
doc_instance = mock_doc_template.return_value
176+
report.generate_report_from_log(log_path, output_path)
177+
178+
mock_doc_template.assert_called_once_with(
179+
output_path, pagesize=report.letter, rightMargin=72, leftMargin=72, topMargin=24, bottomMargin=72
180+
)
181+
doc_instance.build.assert_called_once()
182+
story_passed = doc_instance.build.call_args[0][0]
183+
self.assertGreater(len(story_passed), 0)
184+
self.assertTrue(any(isinstance(flowable, report.Paragraph) for flowable in story_passed))
185+

0 commit comments

Comments
 (0)