diff --git a/autonerves/site/ral.yaml b/autonerves/site/ral.yaml index 200e493..75c6903 100644 --- a/autonerves/site/ral.yaml +++ b/autonerves/site/ral.yaml @@ -28,5 +28,15 @@ partitions: note: 8 A100s on two nodes (4 per node, unverified). pue: {low: 1.14, central: 1.31, high: 1.45, unverified: true} # R89 measured 1.28-1.45 (mean 1.31); new centre ~1.14; hosting building unverified. grid_region: GB -ci_basis: {value: location_annual_average, unverified: true} +ci_basis: {value: regional, unverified: true} # Ruling F6 (2026-10-28): regional central, national and DESNZ as bounds. +# Grid carbon intensity, gCO2e per kWh (hpc-campaign report §1.4, survey carbon.md §4): +# - regional: NESO Carbon Intensity API, region 12 "South England" (Harwell OX11), 2025 forecast series; +# central = annual mean, low/high = half-hourly p5/p95. +# - national: NESO GB national 2025 actual; central = mean, low/high = half-hourly p5/p95. +# - desnz: DESNZ 2025 UK company-reporting factor (0.177 kg/kWh; lags the grid by ~2 years). +# Unverified until a human confirms the hosting building's region and the year to use. +carbon_intensity: + regional: {low: 81, central: 188, high: 308, unverified: true} + national: {low: 51, central: 129, high: 231, unverified: true} + desnz: {value: 177, unverified: true} energy_accounting: {value: false, unverified: true} # Slurm AcctGatherEnergyType not confirmed. diff --git a/autonerves/site_config.py b/autonerves/site_config.py index dd8c79a..78e4d98 100644 --- a/autonerves/site_config.py +++ b/autonerves/site_config.py @@ -23,7 +23,11 @@ gpu_tdp_w: {low: 250, high: 400, unverified: true} pue: {low: 1.14, central: 1.31, high: 1.45, unverified: true} grid_region: GB - ci_basis: location_annual_average + ci_basis: regional + carbon_intensity: # gCO2e per kWh, one entry per basis + regional: {low: 81, central: 188, high: 308, unverified: true} + national: {low: 51, central: 129, high: 231, unverified: true} + desnz: {value: 177, unverified: true} energy_accounting: false Any value may be written plainly (``gpu_only: true``), as ``{value: ...}`` or as a @@ -32,6 +36,13 @@ used as a range and reported as unverified, never silently trusted. ``pue`` is always a range. +``carbon_intensity`` is optional: the grid's carbon intensity in gCO2e/kWh under one or +more named bases (e.g. ``regional``, ``national``, ``desnz``), each a value or a range. +When it is given, ``ci_basis`` names the basis an estimate uses centrally and must be +one of its keys; the other bases show how far the choice of basis moves the answer. A +site without the table gives no carbon figure (energy only); nothing falls back to a +world-average default. + A partition with ``gpu_only: true`` takes GPU jobs only: a planner must never place a CPU-only job there (``Site.cpu_partitions`` leaves it out, ``Site.check_cpu_job`` raises). @@ -68,6 +79,7 @@ "pue": "range", "grid_region": "str", "ci_basis": "str", + "carbon_intensity": "carbon_intensity", "energy_accounting": "bool", "note": "str", } @@ -191,6 +203,10 @@ def __init__(self, raw: Dict, source: Optional[Path] = None): for key in ("pue", "grid_region", "ci_basis", "energy_accounting") if key in raw } + self.carbon_intensity = { + basis: SiteValue.from_yaml(value) + for basis, value in (raw.get("carbon_intensity") or {}).items() + } def partition(self, name: str) -> Partition: try: @@ -236,6 +252,14 @@ def ci_basis(self): value = self.values.get("ci_basis") return None if value is None else value.central + def ci(self, basis: Optional[str] = None) -> Optional[SiteValue]: + """ + The grid carbon intensity (gCO2e/kWh) under ``basis`` (default ``ci_basis``), + or ``None`` when the site gives no ``carbon_intensity`` for it. + """ + basis = basis or self.ci_basis + return self.carbon_intensity.get(basis) if basis else None + @property def energy_accounting(self) -> bool: value = self.values.get("energy_accounting") @@ -246,6 +270,11 @@ def unverified(self) -> List[str]: The dotted names of every value marked ``unverified``. """ names = [key for key, value in self.values.items() if value.unverified] + names += [ + f"carbon_intensity.{basis}" + for basis, value in self.carbon_intensity.items() + if value.unverified + ] for partition in self.partitions.values(): names += [ f"partitions.{partition.name}.{key}" @@ -350,6 +379,32 @@ def validate(raw) -> List[str]: pue = raw["pue"] if isinstance(raw["pue"], dict) else {} if isinstance(pue.get("low"), (int, float)) and pue["low"] < 1: errors.append("pue: a PUE is at least 1") + if "carbon_intensity" in raw: + table = raw["carbon_intensity"] + if not isinstance(table, dict) or not table: + errors.append("carbon_intensity must map at least one basis to a value") + else: + for basis, value in table.items(): + where = f"carbon_intensity.{basis}" + _check_value(where, "number", value, errors) + values = value if isinstance(value, dict) else {"value": value} + for key in ("value", "low", "high", "central"): + number = values.get(key) + if isinstance(number, (int, float)) and number < 0: + errors.append(f"{where}.{key} cannot be negative") + basis = SiteValue.from_yaml(raw.get("ci_basis")).central + if basis is None: + errors.append("ci_basis: name the carbon_intensity basis to use") + elif basis not in table: + errors.append( + f"ci_basis {basis!r} is not a carbon_intensity basis " + f"({sorted(table)})" + ) + elif SiteValue.from_yaml(table[basis]).central is None: + errors.append( + f"carbon_intensity.{basis}: the ci_basis basis needs a value " + f"(or a low/high range); it is empty" + ) return errors diff --git a/test_autonerves/test_site_config.py b/test_autonerves/test_site_config.py index d98d802..546c061 100644 --- a/test_autonerves/test_site_config.py +++ b/test_autonerves/test_site_config.py @@ -146,3 +146,69 @@ def test_nerves_imports_no_autofit(): "assert not [m for m in sys.modules if m.split('.')[0] == 'autofit'], 'autofit'" ) subprocess.run([sys.executable, "-c", code], check=True, cwd=Path(conf.__file__).parents[1]) + + +def test_ral_carbon_intensity_bases(config): + site = config.push_site("ral") + + assert site.ci_basis == "regional" + assert site.ci().central == 188 + assert site.ci().range == (81, 308) + assert site.ci("national").central == 129 + assert site.ci("desnz").central == 177 + assert site.ci("absent") is None + assert {"carbon_intensity.regional", "carbon_intensity.desnz"} <= set( + site.unverified() + ) + + +def test_carbon_intensity_is_optional(): + assert validate(VALID) == [] + site = load_site_from(VALID) + assert site.carbon_intensity == {} + assert site.ci() is None + + +def test_carbon_intensity_validation(): + def errors(table, basis="regional"): + return validate({**VALID, "ci_basis": basis, "carbon_intensity": table}) + + assert errors({"regional": {"low": 80, "central": 190, "high": 310}}) == [] + assert errors({"regional": 190, "national": {"value": 130}}) == [] + assert any("not a carbon_intensity basis" in e for e in errors({"national": 130})) + assert any("negative" in e for e in errors({"regional": -5})) + assert any("must be a number" in e for e in errors({"regional": "high"})) + assert any("low 300 is above high" in e for e in errors({"regional": {"low": 300, "high": 100}})) + assert any("at least one basis" in e for e in errors({})) + assert any( + "name the carbon_intensity basis" in e + for e in validate({k: v for k, v in VALID.items() if k != "ci_basis"} | {"carbon_intensity": {"x": 1}}) + ) + + +def load_site_from(raw): + from autonerves.site_config import Site + + return Site(dict(raw)) + + +def test_ci_basis_basis_must_have_a_value(): + errors = validate( + { + **VALID, + "ci_basis": "regional", + "carbon_intensity": {"regional": {"value": None, "unverified": True}, "national": 129}, + } + ) + assert any("carbon_intensity.regional: the ci_basis basis needs a value" in e for e in errors) + # Another basis may be empty: only the one used centrally must have a value. + assert ( + validate( + { + **VALID, + "ci_basis": "national", + "carbon_intensity": {"regional": {"value": None, "unverified": True}, "national": 129}, + } + ) + == [] + )