greentrain 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- greentrain/__init__.py +110 -0
- greentrain/carbon.py +311 -0
- greentrain/cli.py +224 -0
- greentrain/data/carbon_intensity.csv +39 -0
- greentrain/power.py +515 -0
- greentrain/py.typed +0 -0
- greentrain/report.py +231 -0
- greentrain/runner.py +460 -0
- greentrain/subnet.py +352 -0
- greentrain-0.1.0.dist-info/METADATA +166 -0
- greentrain-0.1.0.dist-info/RECORD +15 -0
- greentrain-0.1.0.dist-info/WHEEL +5 -0
- greentrain-0.1.0.dist-info/entry_points.txt +2 -0
- greentrain-0.1.0.dist-info/licenses/LICENSE +202 -0
- greentrain-0.1.0.dist-info/top_level.txt +1 -0
greentrain/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""greentrain: low-carbon AI training service built on iroh-tinker.
|
|
2
|
+
|
|
3
|
+
greentrain places a training workload on the lowest-carbon tier that still
|
|
4
|
+
meets the customer's accuracy and latency targets, runs it through
|
|
5
|
+
iroh-tinker's multi-cloud launch, and emits an auditable carbon report.
|
|
6
|
+
|
|
7
|
+
Trust rule: every energy and carbon number carries a provenance label
|
|
8
|
+
(``measured`` or ``estimated``). An unlabelled carbon claim is a liability.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from .carbon import (
|
|
15
|
+
CarbonIntensity,
|
|
16
|
+
IntensityError,
|
|
17
|
+
JobCarbon,
|
|
18
|
+
ProvenanceViolation,
|
|
19
|
+
carbon_kg_from_kwh,
|
|
20
|
+
check_provenance,
|
|
21
|
+
compute_carbon,
|
|
22
|
+
energy_kwh,
|
|
23
|
+
load_intensity,
|
|
24
|
+
lookup_intensity,
|
|
25
|
+
parse_intensity_csv,
|
|
26
|
+
)
|
|
27
|
+
from .power import (
|
|
28
|
+
TIER_POWER_MODELS,
|
|
29
|
+
EstimatedTierPowerReader,
|
|
30
|
+
ExternalMeterPowerReader,
|
|
31
|
+
NvidiaSmiPowerReader,
|
|
32
|
+
PowerReader,
|
|
33
|
+
PowerReading,
|
|
34
|
+
PowerUnavailable,
|
|
35
|
+
Provenance,
|
|
36
|
+
RaplPowerReader,
|
|
37
|
+
TierPowerModel,
|
|
38
|
+
average_watts,
|
|
39
|
+
combine_provenance,
|
|
40
|
+
detect_local_tier,
|
|
41
|
+
select_power_reader,
|
|
42
|
+
)
|
|
43
|
+
from .report import (
|
|
44
|
+
CarbonReport,
|
|
45
|
+
build_report,
|
|
46
|
+
render_json,
|
|
47
|
+
render_markdown,
|
|
48
|
+
render_prometheus,
|
|
49
|
+
report_from_dict,
|
|
50
|
+
)
|
|
51
|
+
from .runner import GreenTrainRunner, RunResult, Workload
|
|
52
|
+
from .subnet import (
|
|
53
|
+
TIER_CAPABILITIES,
|
|
54
|
+
NoFeasibleTier,
|
|
55
|
+
SubnetSelector,
|
|
56
|
+
Targets,
|
|
57
|
+
TierCandidate,
|
|
58
|
+
TierCapability,
|
|
59
|
+
TierPlan,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"__version__",
|
|
64
|
+
# power
|
|
65
|
+
"PowerReader",
|
|
66
|
+
"PowerReading",
|
|
67
|
+
"PowerUnavailable",
|
|
68
|
+
"Provenance",
|
|
69
|
+
"NvidiaSmiPowerReader",
|
|
70
|
+
"RaplPowerReader",
|
|
71
|
+
"EstimatedTierPowerReader",
|
|
72
|
+
"ExternalMeterPowerReader",
|
|
73
|
+
"TIER_POWER_MODELS",
|
|
74
|
+
"TierPowerModel",
|
|
75
|
+
"average_watts",
|
|
76
|
+
"combine_provenance",
|
|
77
|
+
"detect_local_tier",
|
|
78
|
+
"select_power_reader",
|
|
79
|
+
# carbon
|
|
80
|
+
"CarbonIntensity",
|
|
81
|
+
"IntensityError",
|
|
82
|
+
"JobCarbon",
|
|
83
|
+
"ProvenanceViolation",
|
|
84
|
+
"carbon_kg_from_kwh",
|
|
85
|
+
"check_provenance",
|
|
86
|
+
"compute_carbon",
|
|
87
|
+
"energy_kwh",
|
|
88
|
+
"load_intensity",
|
|
89
|
+
"lookup_intensity",
|
|
90
|
+
"parse_intensity_csv",
|
|
91
|
+
# subnet
|
|
92
|
+
"SubnetSelector",
|
|
93
|
+
"Targets",
|
|
94
|
+
"TierCandidate",
|
|
95
|
+
"TierCapability",
|
|
96
|
+
"TierPlan",
|
|
97
|
+
"TIER_CAPABILITIES",
|
|
98
|
+
"NoFeasibleTier",
|
|
99
|
+
# report
|
|
100
|
+
"CarbonReport",
|
|
101
|
+
"build_report",
|
|
102
|
+
"render_json",
|
|
103
|
+
"render_markdown",
|
|
104
|
+
"render_prometheus",
|
|
105
|
+
"report_from_dict",
|
|
106
|
+
# runner
|
|
107
|
+
"GreenTrainRunner",
|
|
108
|
+
"RunResult",
|
|
109
|
+
"Workload",
|
|
110
|
+
]
|
greentrain/carbon.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Carbon accounting with per-number provenance.
|
|
2
|
+
|
|
3
|
+
Accounting is deliberately trivial and auditable:
|
|
4
|
+
|
|
5
|
+
energy_kwh = average_watts * duration_s / 3_600_000
|
|
6
|
+
carbon_kg = energy_kwh * intensity_gco2_per_kwh / 1000
|
|
7
|
+
|
|
8
|
+
The interesting part is not the arithmetic, it is the labels. Every number
|
|
9
|
+
that reaches a report carries a :class:`~greentrain.power.Provenance`:
|
|
10
|
+
|
|
11
|
+
* ``energy_provenance`` comes from the power reading.
|
|
12
|
+
* ``carbon_provenance`` is ``measured`` only when the energy *and* the grid
|
|
13
|
+
intensity were both measured. A modelled energy figure or a dataset
|
|
14
|
+
intensity always downgrades the carbon figure to ``estimated``.
|
|
15
|
+
|
|
16
|
+
:func:`check_provenance` enforces that invariant and raises
|
|
17
|
+
:class:`ProvenanceViolation` when it is broken.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
import datetime as _dt
|
|
23
|
+
from collections.abc import Iterable, Mapping
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .power import Provenance
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CarbonIntensity",
|
|
32
|
+
"IntensityError",
|
|
33
|
+
"JobCarbon",
|
|
34
|
+
"ProvenanceViolation",
|
|
35
|
+
"carbon_kg_from_kwh",
|
|
36
|
+
"check_provenance",
|
|
37
|
+
"compute_carbon",
|
|
38
|
+
"default_intensity_path",
|
|
39
|
+
"energy_kwh",
|
|
40
|
+
"load_intensity",
|
|
41
|
+
"lookup_intensity",
|
|
42
|
+
"parse_intensity_csv",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
JOULES_PER_KWH = 3_600_000.0
|
|
46
|
+
GRAMS_PER_KG = 1000.0
|
|
47
|
+
|
|
48
|
+
DEFAULT_INTENSITY_PATH = Path(__file__).with_name("data") / "carbon_intensity.csv"
|
|
49
|
+
|
|
50
|
+
_INTENSITY_COLUMNS = ("region", "provider", "gco2_per_kwh", "provenance", "retrieved", "source")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IntensityError(ValueError):
|
|
54
|
+
"""Raised when a carbon-intensity row is missing required provenance."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ProvenanceViolation(RuntimeError):
|
|
58
|
+
"""Raised when a label claims more than the underlying data supports."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class CarbonIntensity:
|
|
63
|
+
"""Grid carbon intensity for one region."""
|
|
64
|
+
|
|
65
|
+
region: str
|
|
66
|
+
gco2_per_kwh: float
|
|
67
|
+
provenance: Provenance = Provenance.ESTIMATED
|
|
68
|
+
provider: str = ""
|
|
69
|
+
retrieved: str = ""
|
|
70
|
+
source: str = ""
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.gco2_per_kwh < 0:
|
|
74
|
+
raise ValueError(f"gco2_per_kwh must be >= 0, got {self.gco2_per_kwh}")
|
|
75
|
+
if not self.region:
|
|
76
|
+
raise ValueError("region must be a non-empty string")
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"region": self.region,
|
|
81
|
+
"gco2_per_kwh": self.gco2_per_kwh,
|
|
82
|
+
"provenance": self.provenance.value,
|
|
83
|
+
"provider": self.provider,
|
|
84
|
+
"retrieved": self.retrieved,
|
|
85
|
+
"source": self.source,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class JobCarbon:
|
|
91
|
+
"""The carbon footprint of one job, with the label for every number."""
|
|
92
|
+
|
|
93
|
+
job_id: str
|
|
94
|
+
region: str
|
|
95
|
+
device_type: str
|
|
96
|
+
device_count: int
|
|
97
|
+
duration_s: float
|
|
98
|
+
energy_kwh: float
|
|
99
|
+
carbon_kg: float
|
|
100
|
+
intensity_gco2_per_kwh: float
|
|
101
|
+
energy_provenance: Provenance
|
|
102
|
+
carbon_provenance: Provenance
|
|
103
|
+
power_source: str = ""
|
|
104
|
+
notes: tuple[str, ...] = ()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def measured(self) -> bool:
|
|
108
|
+
return self.carbon_provenance is Provenance.MEASURED
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def gco2(self) -> float:
|
|
112
|
+
return self.carbon_kg * GRAMS_PER_KG
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, Any]:
|
|
115
|
+
return {
|
|
116
|
+
"job_id": self.job_id,
|
|
117
|
+
"region": self.region,
|
|
118
|
+
"device_type": self.device_type,
|
|
119
|
+
"device_count": self.device_count,
|
|
120
|
+
"duration_s": round(self.duration_s, 3),
|
|
121
|
+
"energy_kwh": self.energy_kwh,
|
|
122
|
+
"carbon_kg": self.carbon_kg,
|
|
123
|
+
"gco2": self.gco2,
|
|
124
|
+
"intensity_gco2_per_kwh": self.intensity_gco2_per_kwh,
|
|
125
|
+
"energy_provenance": self.energy_provenance.value,
|
|
126
|
+
"carbon_provenance": self.carbon_provenance.value,
|
|
127
|
+
"power_source": self.power_source,
|
|
128
|
+
"notes": list(self.notes),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def energy_kwh(average_watts: float, duration_s: float) -> float:
|
|
133
|
+
"""Convert an average power and a duration into kWh."""
|
|
134
|
+
if average_watts < 0:
|
|
135
|
+
raise ValueError(f"average_watts must be >= 0, got {average_watts}")
|
|
136
|
+
if duration_s < 0:
|
|
137
|
+
raise ValueError(f"duration_s must be >= 0, got {duration_s}")
|
|
138
|
+
return (average_watts * duration_s) / JOULES_PER_KWH
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def carbon_kg_from_kwh(kwh: float, gco2_per_kwh: float) -> float:
|
|
142
|
+
"""Convert energy plus grid intensity into kgCO2e."""
|
|
143
|
+
if kwh < 0:
|
|
144
|
+
raise ValueError(f"kwh must be >= 0, got {kwh}")
|
|
145
|
+
if gco2_per_kwh < 0:
|
|
146
|
+
raise ValueError(f"gco2_per_kwh must be >= 0, got {gco2_per_kwh}")
|
|
147
|
+
return (kwh * gco2_per_kwh) / GRAMS_PER_KG
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def compute_carbon(
|
|
151
|
+
*,
|
|
152
|
+
job_id: str,
|
|
153
|
+
region: str,
|
|
154
|
+
device_type: str,
|
|
155
|
+
device_count: int,
|
|
156
|
+
duration_s: float,
|
|
157
|
+
average_watts: float,
|
|
158
|
+
energy_provenance: Provenance,
|
|
159
|
+
power_source: str = "",
|
|
160
|
+
intensity: CarbonIntensity,
|
|
161
|
+
notes: Iterable[str] = (),
|
|
162
|
+
) -> JobCarbon:
|
|
163
|
+
"""Build a :class:`JobCarbon` with consistent provenance labels."""
|
|
164
|
+
kwh = energy_kwh(average_watts, duration_s)
|
|
165
|
+
kg = carbon_kg_from_kwh(kwh, intensity.gco2_per_kwh)
|
|
166
|
+
|
|
167
|
+
note_list = list(notes)
|
|
168
|
+
if energy_provenance is Provenance.MEASURED and intensity.provenance is Provenance.MEASURED:
|
|
169
|
+
carbon_provenance = Provenance.MEASURED
|
|
170
|
+
else:
|
|
171
|
+
carbon_provenance = Provenance.ESTIMATED
|
|
172
|
+
if energy_provenance is Provenance.ESTIMATED:
|
|
173
|
+
note_list.append(
|
|
174
|
+
"energy is estimated from a tier power model, so the carbon figure is estimated"
|
|
175
|
+
)
|
|
176
|
+
if intensity.provenance is Provenance.ESTIMATED:
|
|
177
|
+
note_list.append(
|
|
178
|
+
"grid carbon intensity is an estimated dataset value, "
|
|
179
|
+
"so the carbon figure is estimated"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
job = JobCarbon(
|
|
183
|
+
job_id=job_id,
|
|
184
|
+
region=region,
|
|
185
|
+
device_type=device_type,
|
|
186
|
+
device_count=device_count,
|
|
187
|
+
duration_s=duration_s,
|
|
188
|
+
energy_kwh=kwh,
|
|
189
|
+
carbon_kg=kg,
|
|
190
|
+
intensity_gco2_per_kwh=intensity.gco2_per_kwh,
|
|
191
|
+
energy_provenance=energy_provenance,
|
|
192
|
+
carbon_provenance=carbon_provenance,
|
|
193
|
+
power_source=power_source,
|
|
194
|
+
notes=tuple(note_list),
|
|
195
|
+
)
|
|
196
|
+
check_provenance(job)
|
|
197
|
+
return job
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def check_provenance(job: JobCarbon) -> None:
|
|
201
|
+
"""Raise if a job claims more certainty than its inputs support."""
|
|
202
|
+
if job.carbon_provenance is Provenance.MEASURED:
|
|
203
|
+
if job.energy_provenance is not Provenance.MEASURED:
|
|
204
|
+
raise ProvenanceViolation(
|
|
205
|
+
f"job {job.job_id!r} reports measured carbon from estimated energy"
|
|
206
|
+
)
|
|
207
|
+
if job.energy_kwh < 0 or job.carbon_kg < 0:
|
|
208
|
+
raise ProvenanceViolation(f"job {job.job_id!r} reports a negative footprint")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def default_intensity_path() -> Path:
|
|
212
|
+
"""Path of the bundled carbon-intensity table."""
|
|
213
|
+
return DEFAULT_INTENSITY_PATH
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_intensity_csv(text: str) -> dict[str, CarbonIntensity]:
|
|
217
|
+
"""Parse the intensity table.
|
|
218
|
+
|
|
219
|
+
Rows without a retrieval date are rejected: an undated intensity cannot be
|
|
220
|
+
audited, and the provenance contract does not allow shipping one.
|
|
221
|
+
"""
|
|
222
|
+
out: dict[str, CarbonIntensity] = {}
|
|
223
|
+
reader = csv.DictReader(
|
|
224
|
+
line for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#")
|
|
225
|
+
)
|
|
226
|
+
if reader.fieldnames is None:
|
|
227
|
+
raise IntensityError("carbon intensity table is empty")
|
|
228
|
+
missing = [c for c in _INTENSITY_COLUMNS if c not in reader.fieldnames]
|
|
229
|
+
if missing:
|
|
230
|
+
raise IntensityError(f"carbon intensity table is missing columns: {', '.join(missing)}")
|
|
231
|
+
|
|
232
|
+
for lineno, row in enumerate(reader, start=2):
|
|
233
|
+
region = (row.get("region") or "").strip()
|
|
234
|
+
if not region:
|
|
235
|
+
raise IntensityError(f"line {lineno}: region must not be empty")
|
|
236
|
+
if region in out:
|
|
237
|
+
raise IntensityError(f"line {lineno}: duplicate region {region!r}")
|
|
238
|
+
|
|
239
|
+
retrieved = (row.get("retrieved") or "").strip()
|
|
240
|
+
if not retrieved:
|
|
241
|
+
raise IntensityError(
|
|
242
|
+
f"line {lineno}: region {region!r} has no retrieval date; "
|
|
243
|
+
"undated intensity values cannot be audited"
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
_dt.date.fromisoformat(retrieved)
|
|
247
|
+
except ValueError as exc:
|
|
248
|
+
raise IntensityError(
|
|
249
|
+
f"line {lineno}: region {region!r} has an invalid retrieval date {retrieved!r}"
|
|
250
|
+
) from exc
|
|
251
|
+
|
|
252
|
+
raw_value = (row.get("gco2_per_kwh") or "").strip()
|
|
253
|
+
try:
|
|
254
|
+
value = float(raw_value)
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
raise IntensityError(
|
|
257
|
+
f"line {lineno}: region {region!r} has a non-numeric gco2_per_kwh {raw_value!r}"
|
|
258
|
+
) from exc
|
|
259
|
+
if value < 0:
|
|
260
|
+
raise IntensityError(f"line {lineno}: region {region!r} has a negative intensity")
|
|
261
|
+
|
|
262
|
+
raw_provenance = (row.get("provenance") or "estimated").strip().lower()
|
|
263
|
+
try:
|
|
264
|
+
provenance = Provenance(raw_provenance)
|
|
265
|
+
except ValueError as exc:
|
|
266
|
+
raise IntensityError(
|
|
267
|
+
f"line {lineno}: region {region!r} has an unknown provenance {raw_provenance!r}"
|
|
268
|
+
) from exc
|
|
269
|
+
|
|
270
|
+
out[region] = CarbonIntensity(
|
|
271
|
+
region=region,
|
|
272
|
+
gco2_per_kwh=value,
|
|
273
|
+
provenance=provenance,
|
|
274
|
+
provider=(row.get("provider") or "").strip(),
|
|
275
|
+
retrieved=retrieved,
|
|
276
|
+
source=(row.get("source") or "").strip(),
|
|
277
|
+
)
|
|
278
|
+
if not out:
|
|
279
|
+
raise IntensityError("carbon intensity table has no rows")
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def load_intensity(path: str | Path | None = None) -> dict[str, CarbonIntensity]:
|
|
284
|
+
"""Load the intensity table from ``path`` (bundled table by default)."""
|
|
285
|
+
target = Path(path) if path is not None else DEFAULT_INTENSITY_PATH
|
|
286
|
+
if not target.exists():
|
|
287
|
+
raise IntensityError(f"carbon intensity table not found: {target}")
|
|
288
|
+
return parse_intensity_csv(target.read_text())
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def lookup_intensity(
|
|
292
|
+
table: Mapping[str, CarbonIntensity], region: str
|
|
293
|
+
) -> CarbonIntensity:
|
|
294
|
+
"""Resolve a region, falling back to the ``global`` row.
|
|
295
|
+
|
|
296
|
+
The fallback is explicit: the returned intensity keeps its own region
|
|
297
|
+
name so the report shows which grid was actually used.
|
|
298
|
+
"""
|
|
299
|
+
if region in table:
|
|
300
|
+
return table[region]
|
|
301
|
+
if "global" in table:
|
|
302
|
+
fallback = table["global"]
|
|
303
|
+
return CarbonIntensity(
|
|
304
|
+
region=fallback.region,
|
|
305
|
+
gco2_per_kwh=fallback.gco2_per_kwh,
|
|
306
|
+
provenance=fallback.provenance,
|
|
307
|
+
provider=fallback.provider,
|
|
308
|
+
retrieved=fallback.retrieved,
|
|
309
|
+
source=fallback.source,
|
|
310
|
+
)
|
|
311
|
+
raise IntensityError(f"no carbon intensity for region {region!r} and no global fallback")
|
greentrain/cli.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""``greentrain`` command line interface.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
``tiers`` list the tier power models and their provenance
|
|
6
|
+
``intensity`` show the grid carbon intensity table
|
|
7
|
+
``plan`` plan a workload without running it
|
|
8
|
+
``run`` run a workload through iroh-tinker and report its carbon
|
|
9
|
+
``report`` re-render the report of a finished job
|
|
10
|
+
|
|
11
|
+
Every command supports ``--json`` for machine consumption.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .carbon import default_intensity_path, load_intensity
|
|
23
|
+
from .power import TIER_POWER_MODELS
|
|
24
|
+
from .report import (
|
|
25
|
+
render_markdown,
|
|
26
|
+
render_prometheus,
|
|
27
|
+
report_from_dict,
|
|
28
|
+
)
|
|
29
|
+
from .runner import GreenTrainRunner, Workload, default_candidates
|
|
30
|
+
|
|
31
|
+
__all__ = ["build_parser", "main"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="greentrain",
|
|
37
|
+
description="Low-carbon AI training service built on iroh-tinker.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
40
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
|
|
42
|
+
tiers = sub.add_parser("tiers", help="list tier power models")
|
|
43
|
+
tiers.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
44
|
+
|
|
45
|
+
intensity = sub.add_parser("intensity", help="show grid carbon intensity")
|
|
46
|
+
intensity.add_argument("--region", help="only show one region")
|
|
47
|
+
intensity.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
48
|
+
|
|
49
|
+
plan = sub.add_parser("plan", help="plan a workload")
|
|
50
|
+
plan.add_argument("--spec", required=True, help="path to a JSON workload spec")
|
|
51
|
+
plan.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
52
|
+
|
|
53
|
+
run = sub.add_parser("run", help="run a workload and report its carbon")
|
|
54
|
+
run.add_argument("--spec", required=True, help="path to a JSON workload spec")
|
|
55
|
+
run.add_argument("--dry-run", action="store_true", help="plan and write the spec only")
|
|
56
|
+
run.add_argument(
|
|
57
|
+
"--format",
|
|
58
|
+
default="json",
|
|
59
|
+
choices=["json", "markdown", "prometheus"],
|
|
60
|
+
help="report rendering",
|
|
61
|
+
)
|
|
62
|
+
run.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
63
|
+
|
|
64
|
+
report = sub.add_parser("report", help="re-render a finished job's report")
|
|
65
|
+
report.add_argument("--job-id", required=True, help="job name")
|
|
66
|
+
report.add_argument(
|
|
67
|
+
"--format",
|
|
68
|
+
default="markdown",
|
|
69
|
+
choices=["json", "markdown", "prometheus"],
|
|
70
|
+
help="report rendering",
|
|
71
|
+
)
|
|
72
|
+
report.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
73
|
+
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_workload(path: str) -> Workload:
|
|
78
|
+
spec_path = Path(path)
|
|
79
|
+
if not spec_path.exists():
|
|
80
|
+
raise FileNotFoundError(f"workload spec not found: {spec_path}")
|
|
81
|
+
raw = json.loads(spec_path.read_text())
|
|
82
|
+
workload = Workload.from_dict(raw)
|
|
83
|
+
if not workload.candidates:
|
|
84
|
+
workload.candidates = default_candidates()
|
|
85
|
+
return workload
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_tiers(args: argparse.Namespace) -> int:
|
|
89
|
+
rows = [
|
|
90
|
+
{
|
|
91
|
+
"key": model.key,
|
|
92
|
+
"device_type": model.device_type,
|
|
93
|
+
"watts_per_device": model.watts_per_device,
|
|
94
|
+
"source": model.source,
|
|
95
|
+
"notes": model.notes,
|
|
96
|
+
"provenance": "estimated",
|
|
97
|
+
}
|
|
98
|
+
for model in TIER_POWER_MODELS.values()
|
|
99
|
+
]
|
|
100
|
+
if args.json:
|
|
101
|
+
_emit_json(rows)
|
|
102
|
+
return 0
|
|
103
|
+
print("greentrain tier power models (all ESTIMATED unless a meter is attached)")
|
|
104
|
+
for row in rows:
|
|
105
|
+
print(
|
|
106
|
+
f" {row['key']:<20} {row['device_type']:<8} "
|
|
107
|
+
f"{row['watts_per_device']:>7.1f} W {row['source']}"
|
|
108
|
+
)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_intensity(args: argparse.Namespace) -> int:
|
|
113
|
+
table = load_intensity()
|
|
114
|
+
rows = [value.to_dict() for value in table.values()]
|
|
115
|
+
if args.region:
|
|
116
|
+
rows = [row for row in rows if row["region"] == args.region]
|
|
117
|
+
if not rows:
|
|
118
|
+
print(f"no intensity row for region {args.region!r}", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
if args.json:
|
|
121
|
+
_emit_json(rows)
|
|
122
|
+
return 0
|
|
123
|
+
print("region gCO2eq/kWh provenance retrieved provider")
|
|
124
|
+
for row in rows:
|
|
125
|
+
print(
|
|
126
|
+
f" {row['region']:<12} {row['gco2_per_kwh']:>9.1f} "
|
|
127
|
+
f"{row['provenance']:<10} {row['retrieved']} {row['provider']}"
|
|
128
|
+
)
|
|
129
|
+
print(f"source: {default_intensity_path()}")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cmd_plan(args: argparse.Namespace) -> int:
|
|
134
|
+
workload = _load_workload(args.spec)
|
|
135
|
+
runner = GreenTrainRunner()
|
|
136
|
+
plan = runner.plan(workload)
|
|
137
|
+
if args.json:
|
|
138
|
+
_emit_json(plan)
|
|
139
|
+
return 0
|
|
140
|
+
chosen = plan.get("chosen")
|
|
141
|
+
if not chosen:
|
|
142
|
+
print("no candidate tier can meet the targets", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
print(f"job: {plan['job_id']} region: {plan['region']}")
|
|
145
|
+
print(
|
|
146
|
+
f"chosen tier: {chosen['candidate']['device_type']} in {chosen['candidate']['region']} "
|
|
147
|
+
f"({chosen['subnet']['width_mult']}x width, {chosen['subnet']['depth_mult']}x depth)"
|
|
148
|
+
)
|
|
149
|
+
print(
|
|
150
|
+
f"estimated energy: {chosen['estimated_kwh']:.6f} kWh "
|
|
151
|
+
f"estimated carbon: {chosen['estimated_carbon_kg']:.6f} kg CO2e (estimated)"
|
|
152
|
+
)
|
|
153
|
+
print("")
|
|
154
|
+
print("candidate energy kWh carbon kg feasible")
|
|
155
|
+
for candidate in plan["candidates"]:
|
|
156
|
+
c = candidate["candidate"]
|
|
157
|
+
print(
|
|
158
|
+
f" {c['device_type']:<8} {c['region']:<16} "
|
|
159
|
+
f"{candidate['estimated_kwh']:>10.6f} {candidate['estimated_carbon_kg']:>11.6f} "
|
|
160
|
+
f"{candidate['feasible']}"
|
|
161
|
+
)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
166
|
+
workload = _load_workload(args.spec)
|
|
167
|
+
runner = GreenTrainRunner()
|
|
168
|
+
result = runner.run(workload, dry_run=args.dry_run)
|
|
169
|
+
if args.json or args.format == "json":
|
|
170
|
+
_emit_json(result.to_dict())
|
|
171
|
+
elif args.format == "markdown":
|
|
172
|
+
sys.stdout.write(render_markdown(result.report))
|
|
173
|
+
else:
|
|
174
|
+
sys.stdout.write(render_prometheus(result.report))
|
|
175
|
+
return 0 if result.status in ("done", "dry-run") else 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cmd_report(args: argparse.Namespace) -> int:
|
|
179
|
+
from iroh_tinker.cloud.job import JobManager
|
|
180
|
+
|
|
181
|
+
job_dir = Path(JobManager().root) / args.job_id
|
|
182
|
+
report_path = job_dir / "report.json"
|
|
183
|
+
if not report_path.exists():
|
|
184
|
+
raise FileNotFoundError(f"no report found for job {args.job_id!r} in {job_dir}")
|
|
185
|
+
report = report_from_dict(json.loads(report_path.read_text()))
|
|
186
|
+
if args.json or args.format == "json":
|
|
187
|
+
_emit_json(report.to_dict())
|
|
188
|
+
elif args.format == "markdown":
|
|
189
|
+
sys.stdout.write(render_markdown(report))
|
|
190
|
+
else:
|
|
191
|
+
sys.stdout.write(render_prometheus(report))
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _emit_json(payload: Any) -> None:
|
|
196
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
200
|
+
parser = build_parser()
|
|
201
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
202
|
+
try:
|
|
203
|
+
if args.command == "tiers":
|
|
204
|
+
return _cmd_tiers(args)
|
|
205
|
+
if args.command == "intensity":
|
|
206
|
+
return _cmd_intensity(args)
|
|
207
|
+
if args.command == "plan":
|
|
208
|
+
return _cmd_plan(args)
|
|
209
|
+
if args.command == "run":
|
|
210
|
+
return _cmd_run(args)
|
|
211
|
+
if args.command == "report":
|
|
212
|
+
return _cmd_report(args)
|
|
213
|
+
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
214
|
+
if getattr(args, "json", False):
|
|
215
|
+
_emit_json({"error": str(exc)})
|
|
216
|
+
else:
|
|
217
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
218
|
+
return 2
|
|
219
|
+
parser.error(f"unknown command {args.command!r}")
|
|
220
|
+
return 2
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__": # pragma: no cover
|
|
224
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# greentrain grid carbon intensity table (gCO2eq/kWh)
|
|
2
|
+
#
|
|
3
|
+
# PROVENANCE: every row below is an ESTIMATED value taken from public
|
|
4
|
+
# annual-average grid intensity figures. They are placeholders until the
|
|
5
|
+
# carbon-intensity source question (open question 1 in the product-suite plan)
|
|
6
|
+
# is resolved and a licensed feed (Ember / WattTime / Electricity Maps) is wired
|
|
7
|
+
# in. Do not publish customer-facing carbon claims from these numbers without
|
|
8
|
+
# re-sourcing them.
|
|
9
|
+
#
|
|
10
|
+
# Columns:
|
|
11
|
+
# region lookup key (GCP/AWS/Azure style region names)
|
|
12
|
+
# provider who published the underlying figure
|
|
13
|
+
# gco2_per_kwh grid carbon intensity, grams CO2eq per kWh
|
|
14
|
+
# provenance measured | estimated
|
|
15
|
+
# retrieved ISO date the value was taken from the source
|
|
16
|
+
# source where the value came from
|
|
17
|
+
#
|
|
18
|
+
# The loader (greentrain.carbon.parse_intensity_csv) rejects any row without
|
|
19
|
+
# a valid `retrieved` date.
|
|
20
|
+
region,provider,gco2_per_kwh,provenance,retrieved,source
|
|
21
|
+
us-central1,Ember,393,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
22
|
+
us-east1,Ember,380,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
23
|
+
us-east-2,Ember,440,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
24
|
+
us-west1,Ember,130,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
25
|
+
us-west2,Ember,381,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
26
|
+
ca-central1,Ember,127,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
27
|
+
eu-west-1,Ember,310,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
28
|
+
eu-west-2,Ember,233,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
29
|
+
eu-central-1,Ember,381,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
30
|
+
eu-north-1,Ember,41,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
31
|
+
eu-south-1,Ember,338,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
32
|
+
as-east-1,Ember,578,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
33
|
+
as-northeast-1,Ember,479,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
34
|
+
as-southeast-1,Ember,431,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
35
|
+
as-south-1,Ember,708,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
36
|
+
ap-southeast-1,Ember,584,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
37
|
+
me-central-1,Ember,484,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
38
|
+
sa-east-1,Ember,104,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|
|
39
|
+
global,Ember,475,estimated,2026-10-01,https://ember-energy.org/data/electricity-data-explorer/
|