diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 9d96afe3a..c0936cd0f 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -12,6 +12,7 @@ on: - "pyproject.toml" - "uv.lock" - "scripts/check_docs_links.py" + - "scripts/check_docs_drift.py" - ".github/workflows/build-docs.yml" pull_request: branches: [master] @@ -44,3 +45,6 @@ jobs: - name: Check documentation links (site/) run: uv run python scripts/check_docs_links.py site + + - name: Check documentation constant drift + run: uv run python scripts/check_docs_drift.py diff --git a/codecarbon/viz/data.py b/codecarbon/viz/data.py index 1d8d02f09..5eda5e8da 100644 --- a/codecarbon/viz/data.py +++ b/codecarbon/viz/data.py @@ -7,6 +7,11 @@ from codecarbon.core.emissions import Emissions from codecarbon.input import DataSource, DataSourceException +# Equivalence factors, documented in docs/explanation/equivalences.md. +KG_CO2E_PER_MILE = 0.409 +KG_CO2_PER_TV_HOUR = 0.097 +KG_CO2_PER_HOUSEHOLD_WEEK = 160.58 + class Data: def __init__(self): @@ -62,7 +67,7 @@ def get_car_miles(self, project_carbon_equivalent: float): :param project_carbon_equivalent: total project emissions in kg CO2E :return: number of miles driven by avg car """ - return f"{project_carbon_equivalent / 0.409:.0f}" + return f"{project_carbon_equivalent / KG_CO2E_PER_MILE:.0f}" def get_tv_time(self, project_carbon_equivalent: float): """ @@ -73,7 +78,7 @@ def get_tv_time(self, project_carbon_equivalent: float): :param project_carbon_equivalent: total project emissions in kg CO2E :return: equivalent TV time """ - time_in_minutes = project_carbon_equivalent * (1 / 0.097) * 60 + time_in_minutes = project_carbon_equivalent / KG_CO2_PER_TV_HOUR * 60 formated_value = f"{time_in_minutes:.0f} minutes" if time_in_minutes >= 60: time_in_hours = time_in_minutes / 60 @@ -93,7 +98,7 @@ def get_household_fraction(self, project_carbon_equivalent: float): :param project_carbon_equivalent: total project emissions in kg CO2E :return: % of weekly emissions re: an average American household """ - return f"{project_carbon_equivalent / 160.58 * 100:.2f}" + return f"{project_carbon_equivalent / KG_CO2_PER_HOUSEHOLD_WEEK * 100:.2f}" def get_global_emissions_choropleth_data( self, net_energy_consumed: float diff --git a/docs/explanation/equivalences.md b/docs/explanation/equivalences.md index 0c5437601..3857a743c 100644 --- a/docs/explanation/equivalences.md +++ b/docs/explanation/equivalences.md @@ -11,16 +11,16 @@ and are used by `viz/carbonboard.py` and `viz/carbonboard_on_api.py`. | Comparison | Factor | Applied as | Source | |---|---|---|---| -| Car travel | **0.409 kg CO₂e per mile** | `emissions_kg / 0.409` → miles driven (`data.py:65`) | US EPA | -| Television | **0.097 kg CO₂ per hour** | `emissions_kg / 0.097` → hours of TV (`data.py:76`) | unsourced in code | -| US household | **160.58 kg CO₂ per week** | `emissions_kg / 160.58 × 100` → % of a household-week (`data.py:96`) | US EPA | +| Car travel | **0.409 kg CO₂e per mile** | `emissions_kg / 0.409` → miles driven (`Data.get_car_miles`) | US EPA | +| Television | **0.097 kg CO₂ per hour** | `emissions_kg / 0.097` → hours of TV (`Data.get_tv_time`) | unsourced in code | +| US household | **160.58 kg CO₂ per week** | `emissions_kg / 160.58 × 100` → % of a household-week (`Data.get_household_fraction`) | US EPA | ## How each factor is derived The derivations below are reproduced from the docstrings in `viz/data.py`; they are the only justification the code carries. -**Car — 0.409 kg CO₂e/mile** (`data.py:54-65`) +**Car — 0.409 kg CO₂e/mile** (`KG_CO2E_PER_MILE`) ```text 8.89 × 10⁻³ metric tons CO₂ per gallon of gasoline @@ -34,14 +34,14 @@ This is the US EPA passenger-vehicle figure, so it reflects the US vehicle fleet and US fuel. It is not a European or global average, and the unit is **miles**, not kilometres. -**Television — 0.097 kg CO₂/hour** (`data.py:67-76`) +**Television — 0.097 kg CO₂/hour** (`KG_CO2_PER_TV_HOUR`) Described in the code only as the ratio for "a 32-inch LCD flat screen TV". **No source, screen power, or grid intensity is given in the code**, so the figure cannot be reproduced from what ships in the repository. Treat it as an illustrative round number rather than a defensible factor. -**US household — 160.58 kg CO₂/week** (`data.py:86-96`) +**US household — 160.58 kg CO₂/week** (`KG_CO2_PER_HOUSEHOLD_WEEK`) ```text 5.734 t CO₂ electricity diff --git a/pyproject.toml b/pyproject.toml index cae1630ca..b1e96103f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,7 @@ test-coverage = "CODECARBON_ALLOW_MULTIPLE_RUNS=True pytest --cov --cov-report=x test-package-integ = "CODECARBON_ALLOW_MULTIPLE_RUNS=True python -m pytest -vv tests/" docs = "uv run --only-group doc zensical build -f mkdocs.yml && uv run --only-group doc python scripts/check_docs_links.py site" docs-serve = "zensical serve -f mkdocs.yml" -docs-check-drift = "python scripts/check-docs-drift.py" +docs-check-drift = "python scripts/check_docs_drift.py" carbonboard = "python codecarbon/viz/carbonboard.py" [tool.bumpver] diff --git a/scripts/check_docs_drift.py b/scripts/check_docs_drift.py new file mode 100644 index 000000000..fb82344f4 --- /dev/null +++ b/scripts/check_docs_drift.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Fail when a numeric constant in the code no longer matches the docs. + +Guards exactly one failure mode: a hardcoded number changes in +``codecarbon/`` while the page documenting it keeps the old value. Every +value is **imported from the codebase** (or loaded from the actual data +file) and then asserted to appear verbatim in the Markdown source. Nothing +here re-parses Python source with regexes -- a check that reads the code +textually can drift from the code it is supposed to guard. + +Deliberately NOT in scope, do not extend it this way: + +* **Prose accuracy.** Not mechanically checkable; a checker that tries + produces noise. +* **Link checking.** ``scripts/check_docs_links.py`` already does it. +* **The order of any fallback ladder.** Verifying narrative structure + false-fails, and a check that false-fails gets disabled -- which is worse + than no check at all. If ladder ordering needs guarding, the honest tool + is a unit test over ``codecarbon/core/resource_tracker.py``, not a docs + check. + +Two documented numbers are knowingly unguarded because they are inline +literals with no importable name: the ``0.1``/``0.9`` cpu_load cubic +coefficients (``external/hardware.py:287-288``) and the ``0.9/0.8/0.7`` RAM +marginal-efficiency multipliers (``external/ram.py:168-190``). Guarding them +would mean re-parsing source, which this script refuses to do. Give them +names in the code and they can be added here in one line each. + +Usage: ``python scripts/check_docs_drift.py`` (exit 1 on drift). +""" + +from __future__ import annotations + +import json +import sys +import types +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DOCS = REPO / "docs" / "explanation" + +sys.path.insert(0, str(REPO)) + +from codecarbon.core.cpu import DEFAULT_POWER_PER_CORE # noqa: E402 +from codecarbon.external.hardware import ( # noqa: E402 + CONSUMPTION_PERCENTAGE_CONSTANT, + POWER_CONSTANT, +) +from codecarbon.external.ram import RAM_SLOT_POWER_X86 # noqa: E402 + + +def _equivalence_divisors(): + """Import the equivalence factors from ``codecarbon.viz.data``. + + ``dash`` is only used there for a return annotation and a DataTable this + script never touches, so a stub module is enough to import the constants. + """ + if "dash" not in sys.modules: + dash = types.ModuleType("dash") + dash.dash_table = types.SimpleNamespace(DataTable=object) + sys.modules["dash"] = dash + from codecarbon.viz import data + + return { + "car, kg CO2e/mile": ( + data.KG_CO2E_PER_MILE, + "{} kg CO₂e per mile", + ), + "tv, kg CO2/hour": (data.KG_CO2_PER_TV_HOUR, "{} kg CO₂ per hour"), + "household, kg CO2/week": ( + data.KG_CO2_PER_HOUSEHOLD_WEEK, + "{} kg CO₂ per week", + ), + } + + +def _checks(docs: Path = DOCS): + """Yield (constant name, code value, string the docs must contain, page).""" + methodology = docs / "methodology.md" + equivalences = docs / "equivalences.md" + + intensity = json.loads( + ( + REPO + / "codecarbon" + / "data" + / "private_infra" + / "carbon_intensity_per_source.json" + ).read_text(encoding="utf-8") + ) + world_average = intensity["world_average"] + + checks = [ + ( + "POWER_CONSTANT", + POWER_CONSTANT, + f"POWER_CONSTANT = {POWER_CONSTANT}", + methodology, + ), + ( + "CONSUMPTION_PERCENTAGE_CONSTANT", + CONSUMPTION_PERCENTAGE_CONSTANT, + f"CONSUMPTION_PERCENTAGE_CONSTANT = {CONSUMPTION_PERCENTAGE_CONSTANT}", + methodology, + ), + ( + "DEFAULT_POWER_PER_CORE", + DEFAULT_POWER_PER_CORE, + f"DEFAULT_POWER_PER_CORE = {DEFAULT_POWER_PER_CORE}", + methodology, + ), + ( + "RAM_SLOT_POWER_X86", + RAM_SLOT_POWER_X86, + f"RAM_SLOT_POWER_X86 = {RAM_SLOT_POWER_X86}", + methodology, + ), + ( + "RAM x86 power floor (2 x RAM_SLOT_POWER_X86)", + RAM_SLOT_POWER_X86 * 2, + f"{RAM_SLOT_POWER_X86 * 2} W (2 DIMMs × {RAM_SLOT_POWER_X86} W)", + methodology, + ), + ( + "carbon_intensity_per_source.json: world_average", + world_average, + f"{world_average} gCO₂eq/kWh", + methodology, + ), + ] + + for name, (value, phrase) in _equivalence_divisors().items(): + checks.append( + (f"equivalence, {name}", value, phrase.format(value), equivalences) + ) + + return checks + + +def _docs_say(text: str, needle: str) -> str: + """Best-effort report of what the page says instead.""" + token = needle.split(" = ")[0] if " = " in needle else needle.split(" ")[0] + hits = [ + line.strip() + for line in text.splitlines() + if token in line and token != line.strip() + ] + if not hits: + return "the constant is not mentioned on the page at all" + return "page says: " + " | ".join(hits[:3]) + + +def main(docs: Path = DOCS) -> int: + checks = _checks(docs) + failures = [] + for name, value, needle, page in checks: + text = page.read_text(encoding="utf-8") + if needle not in text: + failures.append( + f" {name}\n" + f" code value : {value} (docs must contain {needle!r})\n" + f" docs : {_docs_say(text, needle)}\n" + f" fix : edit {page} to match the code -- or fix the " + f"code if the docs are the correct value" + ) + + if failures: + print( + "Documentation drift: a constant changed in the code but the docs " + "still show the old value.\n", + file=sys.stderr, + ) + print("\n\n".join(failures), file=sys.stderr) + return 1 + + print(f"docs drift check: {len(checks)} constants match the docs") + return 0 + + +if __name__ == "__main__": + sys.exit(main())