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 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/