Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion autonerves/site/ral.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
57 changes: 56 additions & 1 deletion autonerves/site_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -68,6 +79,7 @@
"pue": "range",
"grid_region": "str",
"ci_basis": "str",
"carbon_intensity": "carbon_intensity",
"energy_accounting": "bool",
"note": "str",
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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}"
Expand Down Expand Up @@ -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

Expand Down
66 changes: 66 additions & 0 deletions test_autonerves/test_site_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
)
== []
)
Loading