From 87eb75ef1a409c7eea77c7aeb8b0f4baff07c0a0 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 17:52:30 +0200 Subject: [PATCH 1/2] feat(cli): add codecarbon ci-report Summarise an emissions.csv for CI pipelines: aggregate the rows of the most recent run, optionally diff against a baseline CSV, render markdown or JSON, and exit non-zero above an optional threshold. CI-agnostic on purpose, so GitLab, Jenkins and Buildkite get it too. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/ci_report.py | 152 +++++++++++++++++++++++ codecarbon/cli/main.py | 63 ++++++++++ docs/reference/cli.md | 47 ++++++++ tests/cli/test_cli_ci_report.py | 208 ++++++++++++++++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 codecarbon/cli/ci_report.py create mode 100644 tests/cli/test_cli_ci_report.py diff --git a/codecarbon/cli/ci_report.py b/codecarbon/cli/ci_report.py new file mode 100644 index 000000000..229f06208 --- /dev/null +++ b/codecarbon/cli/ci_report.py @@ -0,0 +1,152 @@ +""" +Summarise an ``emissions.csv`` produced by CodeCarbon, optionally against a +baseline run, and render it for a CI job summary or a pull request comment. + +Kept free of any CI-vendor specifics so GitLab, Jenkins, Buildkite and GitHub +Actions can all consume it. +""" + +import csv +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + + +class CIReportError(Exception): + """Raised when an emissions CSV cannot be read or makes no sense.""" + + +@dataclass +class RunSummary: + """Totals for a single CodeCarbon run, aggregated from its CSV rows.""" + + emissions: float + energy_consumed: float + duration: float + rows: int + project_name: str = "" + country_iso_code: str = "" + region: str = "" + + +def _to_float(value: Optional[str]) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def summarise(csv_path: Path) -> RunSummary: + """ + Aggregate the rows of the most recent run in ``csv_path``. + + During a run CodeCarbon appends one row per flush, each holding the delta + since the previous one, all sharing the same ``run_id``. Summing the rows of + the last ``run_id`` therefore gives the totals of that run, and degrades to + the single row when the run was written only once. + """ + if not csv_path.is_file(): + raise CIReportError(f"No emissions file found at {csv_path}") + + with open(csv_path, newline="") as csv_file: + rows: List[Dict[str, str]] = list(csv.DictReader(csv_file)) + + if not rows: + raise CIReportError(f"No emissions data in {csv_path}") + if "emissions" not in rows[0]: + raise CIReportError( + f"{csv_path} does not look like a CodeCarbon emissions file" + " (no 'emissions' column)" + ) + + last_run_id = rows[-1].get("run_id") + run_rows = [row for row in rows if row.get("run_id") == last_run_id] + + return RunSummary( + emissions=sum(_to_float(row.get("emissions")) for row in run_rows), + energy_consumed=sum(_to_float(row.get("energy_consumed")) for row in run_rows), + duration=sum(_to_float(row.get("duration")) for row in run_rows), + rows=len(run_rows), + project_name=run_rows[-1].get("project_name") or "", + country_iso_code=run_rows[-1].get("country_iso_code") or "", + region=run_rows[-1].get("region") or "", + ) + + +def _format_delta(current: float, baseline: float) -> str: + delta = current - baseline + sign = "+" if delta >= 0 else "-" + text = f"{sign}{abs(delta) * 1000:.1f} g" + if baseline: + text += f" ({sign}{abs(delta) / baseline * 100:.0f}%)" + return text + + +def render_markdown( + summary: RunSummary, + baseline: Optional[RunSummary] = None, + label: str = "", +) -> str: + header = "**🌱 CodeCarbon**" + if label: + header += f" — `{label}`" + elif summary.project_name: + header += f" — `{summary.project_name}`" + + line = ( + f"**{summary.emissions * 1000:.1f} g CO2eq**" + f" ({summary.energy_consumed:.3f} kWh, {summary.duration:.0f} s)" + ) + if baseline is not None: + line += ( + f" — **{_format_delta(summary.emissions, baseline.emissions)}** vs baseline" + ) + + location = summary.region or summary.country_iso_code + footer = "Measured with CodeCarbon" + if location: + footer += f" in `{location}`" + footer += ( + ". On virtualised CI runners CPU energy is estimated from the CPU model TDP," + " so values are comparable between runs on identical runners" + " rather than absolute." + ) + + return f"{header}\n{line}\n\n_{footer}_" + + +def render_json( + summary: RunSummary, + baseline: Optional[RunSummary] = None, + label: str = "", +) -> str: + payload = { + "label": label or summary.project_name, + "emissions_kg": summary.emissions, + "energy_kwh": summary.energy_consumed, + "duration_seconds": summary.duration, + "country_iso_code": summary.country_iso_code, + "region": summary.region, + "baseline_emissions_kg": None if baseline is None else baseline.emissions, + "delta_kg": ( + None if baseline is None else summary.emissions - baseline.emissions + ), + } + return json.dumps(payload, indent=2) + + +def render( + summary: RunSummary, + baseline: Optional[RunSummary] = None, + label: str = "", + output_format: str = "markdown", +) -> str: + """Render a summary in the requested format.""" + if output_format == "markdown": + return render_markdown(summary, baseline, label) + if output_format == "json": + return render_json(summary, baseline, label) + raise CIReportError( + f"Unknown format '{output_format}' (should be 'markdown' or 'json')" + ) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..b6ce98a73 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -465,6 +465,69 @@ def signal_handler(signum, frame): raise e +@codecarbon.command( + "ci-report", short_help="Summarise an emissions.csv, for CI pipelines." +) +def ci_report( + csv: Annotated[ + Path, + typer.Option(help="Emissions CSV to summarise."), + ] = Path("emissions.csv"), + baseline: Annotated[ + Optional[Path], + typer.Option(help="Emissions CSV to compare against, e.g. the target branch."), + ] = None, + output_format: Annotated[ + str, + typer.Option("--format", help="Output format: markdown or json."), + ] = "markdown", + label: Annotated[ + str, + typer.Option(help="Label for the measured workload, shown in the report."), + ] = "", + threshold_kg: Annotated[ + Optional[float], + typer.Option(help="Exit with code 1 above this many kgCO2eq."), + ] = None, +): + """ + Summarise a CodeCarbon run for a CI job summary or a pull request comment. + + Reads the rows of the most recent run in the CSV, optionally compares them + with a baseline run, and prints markdown or JSON. Nothing here is specific + to a CI provider. + + Examples: + + codecarbon ci-report --csv emissions.csv + + codecarbon ci-report --baseline base/emissions.csv --label "pytest -q" + + codecarbon ci-report --format json --threshold-kg 0.05 + """ + from codecarbon.cli.ci_report import CIReportError, render, summarise + + try: + summary = summarise(csv) + baseline_summary = ( + summarise(baseline) if baseline is not None and baseline.is_file() else None + ) + report = render(summary, baseline_summary, label, output_format) + except CIReportError as e: + print(f"[bold red]Error:[/bold red] {e}") + raise typer.Exit(1) + + # print() is Rich's, which would reflow and style the markdown + sys.stdout.write(report + "\n") + + if threshold_kg is not None and summary.emissions > threshold_kg: + print( + f"[bold red]Emissions above threshold:[/bold red]" + f" {summary.emissions:.6f} kgCO2eq > {threshold_kg} kgCO2eq" + ) + raise typer.Exit(1) + + @codecarbon.command("detect", short_help="Detect hardware and print information.") def detect(): """ diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71ba05a85..f4a0eb6fb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -98,3 +98,50 @@ codecarbon detect ``` Displays detected RAM, CPU, GPU, and other hardware information that CodeCarbon uses to estimate energy consumption. Useful for verifying that CodeCarbon can see all your hardware. + +### `codecarbon ci-report` + +Summarise an `emissions.csv` for a CI pipeline, optionally against a baseline run. + +**Usage:** +```bash +codecarbon ci-report [OPTIONS] +``` + +Reads the rows of the most recent run in the CSV (CodeCarbon appends one row per flush, all sharing the same `run_id`), sums them, and prints markdown or JSON. Nothing in the command is specific to a CI provider, so it works the same on GitHub Actions, GitLab CI, Jenkins or Buildkite. + +**Options:** + +| Option | Default | Description | +|---|---|---| +| `--csv PATH` | `emissions.csv` | Emissions CSV to summarise. | +| `--baseline PATH` | none | Emissions CSV to compare against, typically the target branch. A missing file is ignored and the comparison is simply omitted. | +| `--format [markdown\|json]` | `markdown` | Output format. | +| `--label TEXT` | project name | Label for the measured workload, shown in the report header. | +| `--threshold-kg FLOAT` | none | Exit with code 1 when total emissions exceed this many kgCO2eq. | + +**Examples:** +```bash +# Measure a test suite, then report on it +codecarbon monitor -- pytest -q +codecarbon ci-report --label "pytest -q" + +# Compare with a baseline downloaded from the target branch +codecarbon ci-report --baseline base/emissions.csv --label "pytest -q" + +# Fail the pipeline above a budget +codecarbon ci-report --threshold-kg 0.05 + +# Machine-readable output for further processing +codecarbon ci-report --format json +``` + +Writing the markdown to a GitHub Actions job summary needs no token and no extra permissions: + +```yaml +- run: codecarbon monitor -- pytest -q +- run: codecarbon ci-report --label "pytest -q" >> "$GITHUB_STEP_SUMMARY" +``` + +!!! warning "Accuracy on hosted CI runners" + Hosted runners are virtualised, so RAPL is unavailable and CPU energy falls back to an estimate based on the CPU model TDP. Numbers are meaningful when comparing runs on identical runner types, not as absolute figures. For accurate measurements use a self-hosted runner with [RAPL enabled](../how-to/enable-rapl.md). diff --git a/tests/cli/test_cli_ci_report.py b/tests/cli/test_cli_ci_report.py new file mode 100644 index 000000000..a4bd7f60c --- /dev/null +++ b/tests/cli/test_cli_ci_report.py @@ -0,0 +1,208 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from typer.testing import CliRunner + +from codecarbon.cli.ci_report import ( + CIReportError, + render, + render_markdown, + summarise, +) +from codecarbon.cli.main import codecarbon + +HEADER = "timestamp,project_name,run_id,duration,emissions,energy_consumed,country_iso_code,region\n" + + +def write_csv(directory: Path, name: str, rows: str, header: str = HEADER) -> Path: + path = directory / name + path.write_text(header + rows) + return path + + +class TestCIReport(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.tmp_dir = tempfile.TemporaryDirectory() + self.tmp_path = Path(self.tmp_dir.name) + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_summarise_sums_the_rows_of_the_last_run(self): + csv_path = write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,old,run-1,10,0.5,1.0,FRA,IDF\n" + "2024-01-02T00:00:00,proj,run-2,10,0.001,0.01,FRA,IDF\n" + "2024-01-02T00:00:10,proj,run-2,5,0.002,0.02,FRA,IDF\n", + ) + summary = summarise(csv_path) + self.assertEqual(summary.rows, 2) + self.assertAlmostEqual(summary.emissions, 0.003) + self.assertAlmostEqual(summary.energy_consumed, 0.03) + self.assertAlmostEqual(summary.duration, 15) + self.assertEqual(summary.project_name, "proj") + self.assertEqual(summary.country_iso_code, "FRA") + + def test_summarise_missing_file(self): + with self.assertRaises(CIReportError): + summarise(self.tmp_path / "nope.csv") + + def test_summarise_empty_file(self): + with self.assertRaises(CIReportError): + summarise(write_csv(self.tmp_path, "empty.csv", "")) + + def test_summarise_wrong_file(self): + with self.assertRaises(CIReportError): + summarise( + write_csv(self.tmp_path, "other.csv", "1,2\n", header="foo,bar\n") + ) + + def test_summarise_ignores_unparseable_values(self): + csv_path = write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-1,10,,0.01,FRA,IDF\n", + ) + self.assertEqual(summarise(csv_path).emissions, 0.0) + + def test_markdown_without_baseline_has_no_comparison(self): + csv_path = write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-1,94,0.0124,0.031,FRA,IDF\n", + ) + report = render_markdown(summarise(csv_path), None, "pytest -q") + self.assertIn("12.4 g CO2eq", report) + self.assertIn("`pytest -q`", report) + self.assertNotIn("vs baseline", report) + + def test_markdown_delta(self): + current = summarise( + write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-2,94,0.0124,0.031,FRA,IDF\n", + ) + ) + for baseline_emissions, expected in ( + (0.0105, "+1.9 g (+18%)"), + (0.0143, "-1.9 g (-13%)"), + (0.0124, "+0.0 g (+0%)"), + ): + baseline = summarise( + write_csv( + self.tmp_path, + "baseline.csv", + f"2024-01-01T00:00:00,proj,run-1,90,{baseline_emissions},0.03,FRA,IDF\n", + ) + ) + self.assertIn(expected, render_markdown(current, baseline)) + + def test_markdown_delta_with_zero_baseline(self): + current = summarise( + write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-2,94,0.0124,0.031,FRA,IDF\n", + ) + ) + baseline = summarise( + write_csv( + self.tmp_path, + "baseline.csv", + "2024-01-01T00:00:00,proj,run-1,90,0,0,FRA,IDF\n", + ) + ) + self.assertIn("+12.4 g", render_markdown(current, baseline)) + + def test_unknown_format(self): + summary = summarise( + write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-1,94,0.0124,0.031,FRA,IDF\n", + ) + ) + with self.assertRaises(CIReportError): + render(summary, output_format="xml") + + def test_cli_json_output(self): + csv_path = write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-1,94,0.0124,0.031,FRA,IDF\n", + ) + baseline_path = write_csv( + self.tmp_path, + "baseline.csv", + "2024-01-01T00:00:00,proj,run-0,90,0.0105,0.030,FRA,IDF\n", + ) + result = self.runner.invoke( + codecarbon, + [ + "ci-report", + "--csv", + str(csv_path), + "--baseline", + str(baseline_path), + "--format", + "json", + "--label", + "pytest", + ], + ) + self.assertEqual(result.exit_code, 0) + payload = json.loads(result.stdout) + self.assertEqual(payload["label"], "pytest") + self.assertAlmostEqual(payload["emissions_kg"], 0.0124) + self.assertAlmostEqual(payload["energy_kwh"], 0.031) + self.assertAlmostEqual(payload["delta_kg"], 0.0019) + + def test_cli_missing_baseline_is_not_an_error(self): + csv_path = write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-1,94,0.0124,0.031,FRA,IDF\n", + ) + result = self.runner.invoke( + codecarbon, + [ + "ci-report", + "--csv", + str(csv_path), + "--baseline", + str(self.tmp_path / "nope.csv"), + "--format", + "json", + ], + ) + self.assertEqual(result.exit_code, 0) + self.assertIsNone(json.loads(result.stdout)["delta_kg"]) + + def test_cli_threshold(self): + csv_path = write_csv( + self.tmp_path, + "emissions.csv", + "2024-01-01T00:00:00,proj,run-1,94,0.0124,0.031,FRA,IDF\n", + ) + for threshold, exit_code in (("0.05", 0), ("0.001", 1)): + result = self.runner.invoke( + codecarbon, + ["ci-report", "--csv", str(csv_path), "--threshold-kg", threshold], + ) + self.assertEqual(result.exit_code, exit_code) + + def test_cli_bad_csv_exits_cleanly(self): + result = self.runner.invoke( + codecarbon, ["ci-report", "--csv", str(self.tmp_path / "nope.csv")] + ) + self.assertEqual(result.exit_code, 1) + self.assertIn("No emissions file found", result.stdout) + + +if __name__ == "__main__": + unittest.main() From 6521851b6373fd738478b0df852caa2950627902 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 19:42:00 +0200 Subject: [PATCH 2/2] fix(ci-report): CSV rows are cumulative, not deltas FileOutput.out() discards the delta it is handed and writes the running total, so summing the rows of a run double-counts: a run flushing twice before stopping was reported at 2.9x its real emissions and its duration was the sum of the elapsed times. Take the last row of the last run_id instead, and rebuild the test fixture from a CSV a real run produced. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/ci_report.py | 29 +++++++++-------- codecarbon/cli/main.py | 6 ++-- docs/reference/cli.md | 2 +- tests/cli/test_cli_ci_report.py | 55 ++++++++++++++++++++++++--------- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/codecarbon/cli/ci_report.py b/codecarbon/cli/ci_report.py index 229f06208..a766752aa 100644 --- a/codecarbon/cli/ci_report.py +++ b/codecarbon/cli/ci_report.py @@ -19,7 +19,7 @@ class CIReportError(Exception): @dataclass class RunSummary: - """Totals for a single CodeCarbon run, aggregated from its CSV rows.""" + """Totals for a single CodeCarbon run, read from its last CSV row.""" emissions: float energy_consumed: float @@ -39,12 +39,14 @@ def _to_float(value: Optional[str]) -> float: def summarise(csv_path: Path) -> RunSummary: """ - Aggregate the rows of the most recent run in ``csv_path``. - - During a run CodeCarbon appends one row per flush, each holding the delta - since the previous one, all sharing the same ``run_id``. Summing the rows of - the last ``run_id`` therefore gives the totals of that run, and degrades to - the single row when the run was written only once. + Read the totals of the most recent run in ``csv_path``. + + During a run CodeCarbon appends one row per flush, all sharing the same + ``run_id``, and every row holds the *cumulative* totals since the start of + the run -- ``FileOutput.out()`` discards the delta it is handed and writes + the running total. The last row of the last ``run_id`` is therefore the + total of that run, and degrades to the single row when the run was written + only once. """ if not csv_path.is_file(): raise CIReportError(f"No emissions file found at {csv_path}") @@ -63,14 +65,15 @@ def summarise(csv_path: Path) -> RunSummary: last_run_id = rows[-1].get("run_id") run_rows = [row for row in rows if row.get("run_id") == last_run_id] + last = run_rows[-1] return RunSummary( - emissions=sum(_to_float(row.get("emissions")) for row in run_rows), - energy_consumed=sum(_to_float(row.get("energy_consumed")) for row in run_rows), - duration=sum(_to_float(row.get("duration")) for row in run_rows), + emissions=_to_float(last.get("emissions")), + energy_consumed=_to_float(last.get("energy_consumed")), + duration=_to_float(last.get("duration")), rows=len(run_rows), - project_name=run_rows[-1].get("project_name") or "", - country_iso_code=run_rows[-1].get("country_iso_code") or "", - region=run_rows[-1].get("region") or "", + project_name=last.get("project_name") or "", + country_iso_code=last.get("country_iso_code") or "", + region=last.get("region") or "", ) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index b6ce98a73..730c137a2 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -493,9 +493,9 @@ def ci_report( """ Summarise a CodeCarbon run for a CI job summary or a pull request comment. - Reads the rows of the most recent run in the CSV, optionally compares them - with a baseline run, and prints markdown or JSON. Nothing here is specific - to a CI provider. + Reads the totals of the most recent run in the CSV, optionally compares + them with a baseline run, and prints markdown or JSON. Nothing here is + specific to a CI provider. Examples: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f4a0eb6fb..ec06a296d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -108,7 +108,7 @@ Summarise an `emissions.csv` for a CI pipeline, optionally against a baseline ru codecarbon ci-report [OPTIONS] ``` -Reads the rows of the most recent run in the CSV (CodeCarbon appends one row per flush, all sharing the same `run_id`), sums them, and prints markdown or JSON. Nothing in the command is specific to a CI provider, so it works the same on GitHub Actions, GitLab CI, Jenkins or Buildkite. +Reads the totals of the most recent run in the CSV and prints markdown or JSON. CodeCarbon appends one row per flush, all sharing the same `run_id`, and each row holds the cumulative totals since the start of the run, so the last row of the last `run_id` is used. Nothing in the command is specific to a CI provider, so it works the same on GitHub Actions, GitLab CI, Jenkins or Buildkite. **Options:** diff --git a/tests/cli/test_cli_ci_report.py b/tests/cli/test_cli_ci_report.py index a4bd7f60c..7b6672384 100644 --- a/tests/cli/test_cli_ci_report.py +++ b/tests/cli/test_cli_ci_report.py @@ -15,6 +15,17 @@ HEADER = "timestamp,project_name,run_id,duration,emissions,energy_consumed,country_iso_code,region\n" +# Captured verbatim from an `EmissionsTracker` run that flushed twice before +# stopping (measure_power_secs=1, ~12 s). Note that every row holds the +# *cumulative* totals since `start()`: duration goes 6.1 -> 9.1 -> 12.1 and the +# emissions of the last row are exactly what `stop()` returned. Do not replace +# this with hand-written per-flush deltas: CodeCarbon never writes those. +REAL_RUN_CSV = """timestamp,project_name,run_id,experiment_id,duration,emissions,emissions_rate,cpu_power,gpu_power,ram_power,cpu_energy,gpu_energy,ram_energy,energy_consumed,water_consumed,country_name,country_iso_code,region,cloud_provider,cloud_region,os,python_version,codecarbon_version,cpu_count,cpu_model,gpu_count,gpu_model,longitude,latitude,ram_total_size,tracking_mode,cpu_utilization_percent,gpu_utilization_percent,ram_utilization_percent,ram_used_gb,on_cloud,pue,wue +2026-08-12T19:37:29,realrun,3b540626-49b2-49c4-bd31-9adabd153544,5b0fa12a-3dd7-45bb-9766-cc326314d9f1,6.111706958006835,4.3679212303523465e-06,7.146810637951176e-07,8.8272095485,0.0,6.0,1.4971799426185554e-05,0.0,1.0123984718326636e-05,2.5095784144512187e-05,0.0,Spain,ESP,madrid,,,macOS-26.5.2-arm64-arm-64bit-Mach-O,3.13.13,3.3.0,10,Apple M5,0,,-3.7011,40.4327,24.0,machine,0.0,0,64.23333333333333,9.161565144856771,N,1.0,0.0 +2026-08-12T19:37:32,realrun,3b540626-49b2-49c4-bd31-9adabd153544,5b0fa12a-3dd7-45bb-9766-cc326314d9f1,9.119719916001486,1.1022370326871737e-05,1.2086303557998372e-06,9.060489177045456,0.0,6.0,2.308286981475956e-05,0.0,1.515010402332943e-05,3.823297383808899e-05,0.0,Spain,ESP,madrid,,,macOS-26.5.2-arm64-arm-64bit-Mach-O,3.13.13,3.3.0,10,Apple M5,0,,-3.7011,40.4327,24.0,machine,11.11111111111111,0,64.46666666666667,9.215199788411459,N,1.0,0.0 +2026-08-12T19:37:35,realrun,3b540626-49b2-49c4-bd31-9adabd153544,5b0fa12a-3dd7-45bb-9766-cc326314d9f1,12.128133875005005,1.3327795140966287e-05,1.0989155692314441e-06,9.227810751732145,0.0,6.0,3.1311891250182006e-05,0.0,2.0166844231656195e-05,5.14787354818382e-05,0.0,Spain,ESP,madrid,,,macOS-26.5.2-arm64-arm-64bit-Mach-O,3.13.13,3.3.0,10,Apple M5,0,,-3.7011,40.4327,24.0,machine,8.333333333333334,0,64.60833333333333,9.254739125569662,N,1.0,0.0 +""" + def write_csv(directory: Path, name: str, rows: str, header: str = HEADER) -> Path: path = directory / name @@ -31,21 +42,35 @@ def setUp(self): def tearDown(self): self.tmp_dir.cleanup() - def test_summarise_sums_the_rows_of_the_last_run(self): - csv_path = write_csv( - self.tmp_path, - "emissions.csv", - "2024-01-01T00:00:00,old,run-1,10,0.5,1.0,FRA,IDF\n" - "2024-01-02T00:00:00,proj,run-2,10,0.001,0.01,FRA,IDF\n" - "2024-01-02T00:00:10,proj,run-2,5,0.002,0.02,FRA,IDF\n", - ) - summary = summarise(csv_path) - self.assertEqual(summary.rows, 2) - self.assertAlmostEqual(summary.emissions, 0.003) - self.assertAlmostEqual(summary.energy_consumed, 0.03) - self.assertAlmostEqual(summary.duration, 15) - self.assertEqual(summary.project_name, "proj") - self.assertEqual(summary.country_iso_code, "FRA") + def test_summarise_takes_the_totals_of_the_last_row_not_the_sum(self): + """A real run's rows are cumulative, so summing them double-counts.""" + path = self.tmp_path / "emissions.csv" + path.write_text(REAL_RUN_CSV) + + summary = summarise(path) + self.assertEqual(summary.rows, 3) + # The value `tracker.stop()` returned for this very run. + self.assertAlmostEqual(summary.emissions, 1.3327795140966287e-05) + self.assertAlmostEqual(summary.energy_consumed, 5.14787354818382e-05) + self.assertAlmostEqual(summary.duration, 12.128133875005005) + self.assertEqual(summary.project_name, "realrun") + self.assertEqual(summary.country_iso_code, "ESP") + self.assertEqual(summary.region, "madrid") + + # Summing the three flushes would report 2.9x the emissions and a 27 s + # run instead of a 12 s one. + self.assertLess(summary.emissions, 2e-05) + self.assertLess(summary.duration, 13) + + def test_summarise_ignores_previous_runs(self): + lines = REAL_RUN_CSV.splitlines(keepends=True) + older = lines[1].replace("3b540626-49b2-49c4-bd31-9adabd153544", "older-run") + path = self.tmp_path / "emissions.csv" + path.write_text(lines[0] + older + "".join(lines[1:])) + + summary = summarise(path) + self.assertEqual(summary.rows, 3) + self.assertAlmostEqual(summary.emissions, 1.3327795140966287e-05) def test_summarise_missing_file(self): with self.assertRaises(CIReportError):