green-bench 0.2.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.
@@ -0,0 +1,3 @@
1
+ """green-bench: automated green software energy benchmarking experiments."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,279 @@
1
+ import re
2
+ import shlex
3
+ import shutil
4
+ import sys
5
+ import time
6
+ from datetime import datetime
7
+ from importlib import resources
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ import typer.core
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ from green_bench import __version__
19
+ from green_bench.analysis import AnalysisEngine
20
+ from green_bench.environment import EnvironmentManager
21
+ from green_bench.measurement import BenchmarkError, MeasurementWrapper
22
+ from green_bench.orchestrator import ExperimentOrchestrator
23
+ from green_bench.results import FOREIGN, HAS_RESULTS, classify, describe
24
+
25
+ console = Console()
26
+
27
+ _SUBCOMMANDS = ("compare", "demo")
28
+
29
+
30
+ class _DefaultToCompare(typer.core.TyperGroup):
31
+ """`green-bench --name ...` is shorthand for `green-bench compare --name ...`."""
32
+
33
+ def parse_args(self, ctx, args):
34
+ if args and args[0] not in _SUBCOMMANDS and args[0] not in ctx.help_option_names:
35
+ args = ["compare", *args]
36
+ return super().parse_args(ctx, args)
37
+
38
+
39
+ app = typer.Typer(
40
+ cls=_DefaultToCompare,
41
+ add_completion=False,
42
+ help="Green Software Energy Benchmarking CLI Tool.\n\n"
43
+ "Run `green-bench demo` to try it on a bundled example, or `green-bench compare --help` "
44
+ "(or just `green-bench --name ...`) to compare two versions of your own project.",
45
+ )
46
+
47
+ _NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
48
+
49
+
50
+ def _version_callback(value: bool):
51
+ if value:
52
+ typer.echo(f"green-bench {__version__}")
53
+ raise typer.Exit()
54
+
55
+
56
+ def _is_interactive() -> bool:
57
+ return sys.stdin.isatty()
58
+
59
+
60
+ def _confirm_overwrite(results_dir: Path, resume: bool, overwrite: bool) -> None:
61
+ """Decide what to do with an existing results folder (before anything is created or deleted).
62
+
63
+ Nothing is deleted here: the orchestrator clears the folder after pre-flight has passed.
64
+ """
65
+ if resume:
66
+ return
67
+ state = classify(results_dir)
68
+ if state == FOREIGN:
69
+ raise BenchmarkError(
70
+ f"{results_dir} exists but does not look like a GreenBench results folder, so it will "
71
+ "not be touched. Choose a different --name or --output."
72
+ )
73
+ if state != HAS_RESULTS or overwrite: # missing or empty folders are simply reused
74
+ return
75
+ if not _is_interactive():
76
+ raise BenchmarkError(
77
+ f"Results folder already exists: {results_dir}. Use --overwrite to replace it "
78
+ "or --resume to continue the unfinished experiment."
79
+ )
80
+ console.print(f"[yellow]Results folder already exists:[/yellow] {escape(str(results_dir.resolve()))}")
81
+ console.print(f" contains {describe(results_dir)}")
82
+ console.print(" (To continue an unfinished experiment instead, answer n and run again with --resume.)")
83
+ if not typer.confirm("Delete these previous results and start over?", default=False):
84
+ console.print("Aborted. The existing results were not changed.")
85
+ raise typer.Exit(1)
86
+
87
+
88
+ def _validate_name(value: str) -> str:
89
+ if not _NAME_RE.match(value):
90
+ raise typer.BadParameter(
91
+ "Use letters, digits, '.', '_' or '-' only (it becomes a folder name)."
92
+ )
93
+ return value
94
+
95
+
96
+ @app.command()
97
+ def compare(
98
+ name: str = typer.Option(..., "--name", help="Name of the benchmark (used as the results folder name)",
99
+ callback=_validate_name),
100
+ base: Path = typer.Option(..., "--base", "-b", help="Directory containing the baseline version of the project"),
101
+ compare: Path = typer.Option(..., "--compare", "-c", help="Directory containing the version to test"),
102
+ setup: str = typer.Option(..., "--setup", help="Shell command run before every measurement (not measured). Use 'true' for none"),
103
+ cmd: str = typer.Option(..., "--cmd", help="Shell command to measure, e.g. 'python bench.py'. Chaining (a && b, pipes, ;) is rejected: put multi-step workloads in one script"),
104
+ iterations: int = typer.Option(30, "--iterations", "-n", min=2, help="Runs per version (30+ recommended)"),
105
+ carbon_intensity: float = typer.Option(475.0, "--intensity", min=0.0, help="Carbon intensity in gCO2e/kWh"),
106
+ output: Path = typer.Option(Path("green-bench-results"), "--output", "-o", help="Folder where results are saved"),
107
+ seed: Optional[int] = typer.Option(None, "--seed", help="Seed for the run order (default: random; it is printed so you can reproduce it)"),
108
+ cooldown: float = typer.Option(10.0, "--cooldown", min=0.0, help="Seconds to wait between setup and each measurement"),
109
+ interval: int = typer.Option(500, "--interval", min=1, help="EnergiBridge sampling interval in ms"),
110
+ timeout: Optional[float] = typer.Option(None, "--timeout", min=1.0, help="Abort any single command after this many seconds"),
111
+ keep_raw: bool = typer.Option(False, "--keep-raw", help="Keep each run's raw EnergiBridge CSV in <results>/raw"),
112
+ resume: bool = typer.Option(False, "--resume", help="Continue an interrupted experiment with the same name"),
113
+ overwrite: bool = typer.Option(False, "--overwrite", help="Replace an existing results folder without asking"),
114
+ no_plots: bool = typer.Option(False, "--no-plots", help="Skip plot generation"),
115
+ version: Optional[bool] = typer.Option(None, "--version", callback=_version_callback, is_eager=True,
116
+ help="Show the version and exit"),
117
+ ):
118
+ """
119
+ Automates energy measurement between two versions of a project.
120
+ """
121
+ _run_compare(
122
+ name=name, base=base, compare=compare, setup=setup, cmd=cmd, iterations=iterations,
123
+ carbon_intensity=carbon_intensity, output=output, seed=seed, cooldown=cooldown,
124
+ interval=interval, timeout=timeout, keep_raw=keep_raw, resume=resume,
125
+ overwrite=overwrite, no_plots=no_plots,
126
+ )
127
+
128
+
129
+ def _run_compare(*, name, base, compare, setup, cmd, iterations, carbon_intensity, output, seed,
130
+ cooldown, interval, timeout, keep_raw, resume, overwrite, no_plots):
131
+ console.print("━" * 75, style="dim white")
132
+ console.print(f"[bold green]GreenBench: Automated Software Energy Benchmarking Engine v{__version__}[/bold green]")
133
+ console.print("━" * 75, style="dim white")
134
+
135
+ results_dir = output / name
136
+ start_time = datetime.now()
137
+ start_perf = time.perf_counter()
138
+
139
+ grid = Table.grid(padding=(0, 2))
140
+ grid.add_column(style="bold white", justify="left", width=20)
141
+ grid.add_column(style="white")
142
+ grid.add_row("Project:", escape(name))
143
+ grid.add_row("Setup:", escape(setup))
144
+ grid.add_row("Workload:", escape(cmd))
145
+ grid.add_row("Base:", f"[cyan]{escape(str(base))}[/cyan]")
146
+ grid.add_row("Compare:", f"[green]{escape(str(compare))}[/green]")
147
+ grid.add_row("Iterations:", f"{iterations} per version")
148
+ grid.add_row("Output:", escape(str(results_dir)))
149
+ grid.add_row("Start Time:", start_time.strftime("%Y-%m-%d %H:%M:%S"))
150
+ console.print(grid)
151
+ console.print()
152
+
153
+ exit_code = 0
154
+ try:
155
+ _confirm_overwrite(results_dir, resume, overwrite)
156
+ environment = EnvironmentManager(base_path=base, compare_path=compare, output_path=results_dir)
157
+ orchestrator = ExperimentOrchestrator(environment, MeasurementWrapper(), console=console)
158
+ summary = orchestrator.run_benchmarks(
159
+ name=name, base=str(base), compare=str(compare), setup=setup, cmd=cmd,
160
+ iterations=iterations, seed=seed, cooldown=cooldown, interval_ms=interval,
161
+ timeout=timeout, keep_raw=keep_raw, resume=resume,
162
+ )
163
+ if not summary.completed:
164
+ exit_code = 1
165
+
166
+ console.print("[bold yellow][3/3][/bold yellow] [bold white]Analytical Data & Statistical Processing...[/bold white]")
167
+ report = AnalysisEngine.compare_results(summary.data_csv_path, grid_intensity=carbon_intensity, console=console)
168
+
169
+ plot_dir = None
170
+ if not no_plots and summary.runs_recorded >= 2:
171
+ plot_dir = AnalysisEngine.generate_visuals(summary.data_csv_path.parent)
172
+ if plot_dir is None:
173
+ console.print(
174
+ " [yellow]Plots skipped: install them with[/yellow] " + escape("pip install 'green-bench[plots]'")
175
+ )
176
+ else:
177
+ console.print(" • Generating plots... [bold green]Done.[/bold green]")
178
+
179
+ console.print(report)
180
+ if plot_dir:
181
+ console.print(f"[magenta]Artifacts stored at: {escape(str(plot_dir))}[/magenta]")
182
+ if not summary.completed:
183
+ console.print("[bold red]The experiment did not finish. The analysis above uses partial data.[/bold red]")
184
+ except BenchmarkError as err:
185
+ console.print(f"\n[bold red]Error:[/bold red] {escape(str(err))}")
186
+ exit_code = 1
187
+ except KeyboardInterrupt:
188
+ console.print("\n[bold red]Interrupted.[/bold red] Partial results are kept; continue with --resume.")
189
+ exit_code = 130
190
+
191
+ total = time.perf_counter() - start_perf
192
+ console.print("-" * 50)
193
+ console.print(f"Ended at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
194
+ console.print(f"Total duration: {int(total // 60):02d}:{int(total % 60):02d} ({total:.1f} s)")
195
+ raise typer.Exit(exit_code)
196
+
197
+
198
+ DEMO_NAME = "nqueens-demo"
199
+ DEMO_DIR = Path("green-bench-demo")
200
+ DEMO_WORKLOAD = "nqueens.py 13"
201
+
202
+
203
+ def _copy_demo_programs(dest: Path) -> None:
204
+ """Copy the bundled naive/optimized N-Queens programs into `dest` so the user can see and reuse them."""
205
+ source = resources.files("green_bench") / "demo_data" / "nqueens"
206
+ for version in ("naive", "optimized"):
207
+ target = dest / version
208
+ target.mkdir(parents=True, exist_ok=True)
209
+ for item in (source / version).iterdir():
210
+ if item.is_file() and item.name.endswith(".py"):
211
+ (target / item.name).write_bytes(item.read_bytes())
212
+
213
+
214
+ def _format_command(*, name: str, base: Path, compare: Path, setup: str, cmd: str, iterations: int,
215
+ cooldown: float, output: Path | None = None, seed: int | None = None) -> str:
216
+ """The `green-bench` invocation as a copy-pasteable, multi-line shell command."""
217
+ def fmt(value: float) -> str:
218
+ return f"{value:g}"
219
+
220
+ lines = [
221
+ f"green-bench --name {shlex.quote(name)}",
222
+ f"-b {shlex.quote(base.as_posix())} -c {shlex.quote(compare.as_posix())}",
223
+ f"--setup {shlex.quote(setup)} --cmd {shlex.quote(cmd)}",
224
+ f"-n {iterations} --cooldown {fmt(cooldown)}",
225
+ ]
226
+ if output is not None:
227
+ lines[-1] += f" -o {shlex.quote(output.as_posix())}"
228
+ if seed is not None:
229
+ lines[-1] += f" --seed {seed}"
230
+ return " \\\n ".join(lines)
231
+
232
+
233
+ def _print_command(command: str, title: str) -> None:
234
+ console.print(f"[bold cyan]{title}[/bold cyan]")
235
+ console.print(Text(command), soft_wrap=True, highlight=False)
236
+ console.print()
237
+
238
+
239
+ @app.command()
240
+ def demo(
241
+ iterations: int = typer.Option(5, "--iterations", "-n", min=2, help="Runs per version"),
242
+ cooldown: float = typer.Option(5.0, "--cooldown", min=0.0, help="Seconds to wait between setup and each measurement"),
243
+ output: Path = typer.Option(Path("green-bench-results"), "--output", "-o", help="Folder where results are saved"),
244
+ seed: Optional[int] = typer.Option(None, "--seed", help="Seed for the run order"),
245
+ overwrite: bool = typer.Option(False, "--overwrite", help="Replace an existing demo results folder without asking"),
246
+ no_plots: bool = typer.Option(False, "--no-plots", help="Skip plot generation"),
247
+ ):
248
+ """
249
+ Try GreenBench on a bundled example (naive vs. bitmask N-Queens) and see the exact command it ran.
250
+ """
251
+ _copy_demo_programs(DEMO_DIR)
252
+ python = "python" if sys.platform == "win32" or not shutil.which("python3") else "python3"
253
+ base, compare, setup, cmd = DEMO_DIR / "naive", DEMO_DIR / "optimized", "true", f"{python} {DEMO_WORKLOAD}"
254
+ command = _format_command(
255
+ name=DEMO_NAME, base=base, compare=compare, setup=setup, cmd=cmd, iterations=iterations,
256
+ cooldown=cooldown, output=None if output == Path("green-bench-results") else output, seed=seed,
257
+ )
258
+
259
+ console.print(f"Demo programs copied to [bold]{escape(str(DEMO_DIR))}/[/bold] (naive/ and optimized/).")
260
+ _print_command(command, "Running this GreenBench command:")
261
+
262
+ try:
263
+ _run_compare(
264
+ name=DEMO_NAME, base=base, compare=compare, setup=setup, cmd=cmd, iterations=iterations,
265
+ carbon_intensity=475.0, output=output, seed=seed, cooldown=cooldown, interval=500,
266
+ timeout=None, keep_raw=False, resume=False, overwrite=overwrite, no_plots=no_plots,
267
+ )
268
+ except typer.Exit:
269
+ console.print()
270
+ _print_command(
271
+ command,
272
+ "To repeat this on your own project, run the same command with -b/-c pointing at your two "
273
+ "directories and --cmd at your workload:",
274
+ )
275
+ raise
276
+
277
+
278
+ if __name__ == "__main__":
279
+ app()
@@ -0,0 +1,296 @@
1
+ """Energy integration, carbon conversion, statistics and (optional) plots."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from rich.console import Console, Group
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+ from scipy.stats import mannwhitneyu
13
+
14
+ from green_bench.measurement import MeasurementError
15
+
16
+ JOULES_PER_KWH = 3.6e6
17
+ ALPHA = 0.05
18
+ MIN_RECOMMENDED_N = 10
19
+
20
+ # Energy sources in order of preference. EnergiBridge exposes different columns per
21
+ # platform, so we inspect the header instead of assuming one name.
22
+ # power columns -> integrated over time (trapezoidal rule)
23
+ # energy columns -> cumulative counters (last sample minus first sample)
24
+ POWER_COLUMNS = ["SYSTEM_POWER (Watts)"]
25
+ ENERGY_COLUMNS = ["PACKAGE_ENERGY (J)", "CPU_ENERGY (J)"]
26
+ TIME_COLUMN = "Time" # milliseconds
27
+
28
+
29
+ class AnalysisEngine:
30
+ """Data processing, numerical integration, significance testing, carbon maths and plots."""
31
+
32
+ # ------------------------------------------------------------------ energy
33
+ @staticmethod
34
+ def _pick_column(columns, exact: list[str], suffix: str) -> Optional[str]:
35
+ for name in exact:
36
+ if name in columns:
37
+ return name
38
+ # Fallback: first column that looks like the right kind of metric, e.g. "FOO_POWER (Watts)".
39
+ for name in columns:
40
+ if name.endswith(suffix):
41
+ return name
42
+ return None
43
+
44
+ @classmethod
45
+ def calculate_eb_joules(cls, csv_path: Path) -> tuple[float, float]:
46
+ """Total energy (Joules) and duration (seconds) of one EnergiBridge run.
47
+
48
+ Raises MeasurementError (never returns a silent 0) if the file is missing,
49
+ too short, or has no recognisable energy/power column.
50
+ """
51
+ csv_path = Path(csv_path)
52
+ try:
53
+ df = pd.read_csv(csv_path)
54
+ except FileNotFoundError:
55
+ raise MeasurementError(f"Telemetry file not found: {csv_path}") from None
56
+ except Exception as err:
57
+ raise MeasurementError(f"Could not read telemetry file {csv_path.name}: {err}") from err
58
+
59
+ if len(df) < 2:
60
+ raise MeasurementError(
61
+ f"{csv_path.name} has {len(df)} sample(s); need at least 2. "
62
+ "The workload may be too short for the sampling interval."
63
+ )
64
+ if TIME_COLUMN not in df.columns:
65
+ raise MeasurementError(f"{csv_path.name} has no '{TIME_COLUMN}' column. Columns: {list(df.columns)}")
66
+
67
+ elapsed = (df[TIME_COLUMN] - df[TIME_COLUMN].min()) / 1000.0
68
+ duration = float(elapsed.max())
69
+ if duration <= 0:
70
+ raise MeasurementError(
71
+ f"{csv_path.name} covers 0 seconds: the workload finished before EnergiBridge could take "
72
+ "two samples at different times. Use a workload that runs for several seconds."
73
+ )
74
+
75
+ power_col = cls._pick_column(df.columns, POWER_COLUMNS, "(Watts)")
76
+ if power_col is not None:
77
+ joules = float(np.trapezoid(df[power_col], x=elapsed))
78
+ else:
79
+ energy_col = cls._pick_column(df.columns, ENERGY_COLUMNS, "ENERGY (J)")
80
+ if energy_col is None:
81
+ raise MeasurementError(
82
+ "No known power or energy column found in EnergiBridge output. "
83
+ f"Columns present: {list(df.columns)}"
84
+ )
85
+ joules = float(df[energy_col].iloc[-1] - df[energy_col].iloc[0])
86
+
87
+ if not np.isfinite(joules) or joules <= 0:
88
+ raise MeasurementError(
89
+ f"{csv_path.name} produced an invalid energy value ({joules}). "
90
+ "EnergiBridge may lack permission to read the energy counters (try an elevated terminal), "
91
+ "or the platform reports no usable power data."
92
+ )
93
+ return joules, duration
94
+
95
+ @staticmethod
96
+ def calculate_kwh(total_joules: float) -> float:
97
+ """Joules -> kilowatt-hours."""
98
+ return total_joules / JOULES_PER_KWH
99
+
100
+ @staticmethod
101
+ def calculate_co2_emissions(kwh: float, grid_intensity: float = 475.0) -> float:
102
+ """Operational emissions in gCO2e for ``kwh`` at ``grid_intensity`` gCO2e/kWh."""
103
+ return kwh * grid_intensity
104
+
105
+ # -------------------------------------------------------------- statistics
106
+ @staticmethod
107
+ def _bootstrap_median_change_ci(base: np.ndarray, treat: np.ndarray, n_boot: int = 5000, seed: int = 0):
108
+ """95% percentile-bootstrap CI for the % change in median energy."""
109
+ rng = np.random.default_rng(seed)
110
+ b = rng.choice(base, size=(n_boot, len(base)), replace=True)
111
+ t = rng.choice(treat, size=(n_boot, len(treat)), replace=True)
112
+ mb, mt = np.median(b, axis=1), np.median(t, axis=1)
113
+ changes = (mt - mb) / mb * 100
114
+ return float(np.percentile(changes, 2.5)), float(np.percentile(changes, 97.5))
115
+
116
+ @classmethod
117
+ def compare_results(cls, data_csv_path: Path, grid_intensity: float = 475.0, console: Optional[Console] = None):
118
+ """Compare baseline vs treatment and write ``results.csv`` next to the data.
119
+
120
+ Returns a rich renderable report, or a plain string if there is too little data.
121
+ """
122
+ console = console or Console()
123
+ data_csv_path = Path(data_csv_path)
124
+ if not data_csv_path.exists():
125
+ return "No results were recorded, so there is nothing to analyse."
126
+ df = pd.read_csv(data_csv_path)
127
+
128
+ base = df[df["label"] == "baseline"]["energy_joules"].to_numpy(dtype=float)
129
+ treat = df[df["label"] == "treatment"]["energy_joules"].to_numpy(dtype=float)
130
+ n_base, n_treat = len(base), len(treat)
131
+
132
+ if n_base < 2 or n_treat < 2:
133
+ return "Need at least 2 runs per group for statistical analysis."
134
+
135
+ u_stat, p_value = mannwhitneyu(base, treat, alternative="two-sided")
136
+ mean_base, mean_treat = float(base.mean()), float(treat.mean())
137
+ med_base, med_treat = float(np.median(base)), float(np.median(treat))
138
+ if mean_base <= 0 or med_base <= 0:
139
+ raise MeasurementError("Baseline energy is not positive; cannot compute relative change.")
140
+
141
+ pct_change = (mean_treat - mean_base) / mean_base * 100
142
+ pct_change_median = (med_treat - med_base) / med_base * 100
143
+ ci_low, ci_high = cls._bootstrap_median_change_ci(base, treat)
144
+ # P(a random treatment run uses less energy than a random baseline run), ties count half.
145
+ prob_lower = float(u_stat) / (n_base * n_treat)
146
+
147
+ co2_base = cls.calculate_co2_emissions(cls.calculate_kwh(mean_base), grid_intensity)
148
+ co2_treat = cls.calculate_co2_emissions(cls.calculate_kwh(mean_treat), grid_intensity)
149
+ co2_delta = co2_treat - co2_base
150
+
151
+ metrics = {
152
+ "baseline_n": n_base,
153
+ "treatment_n": n_treat,
154
+ "baseline_mean_joules": mean_base,
155
+ "treatment_mean_joules": mean_treat,
156
+ "baseline_median_joules": med_base,
157
+ "treatment_median_joules": med_treat,
158
+ "treatment_effect_pct_mean": pct_change,
159
+ "treatment_effect_pct_median": pct_change_median,
160
+ "median_effect_ci95_low_pct": ci_low,
161
+ "median_effect_ci95_high_pct": ci_high,
162
+ "prob_treatment_lower": prob_lower,
163
+ "grid_intensity_gco2e_kwh": grid_intensity,
164
+ "delta_gco2e_execution": co2_delta,
165
+ "mann_whitney_u_stat": float(u_stat),
166
+ "p_value": float(p_value),
167
+ "is_statistically_significant": bool(p_value < ALPHA),
168
+ }
169
+ results_csv_path = data_csv_path.parent / "results.csv"
170
+ pd.DataFrame({"metric": list(metrics), "value": list(metrics.values())}).to_csv(results_csv_path, index=False)
171
+ console.print(f" • Saved summary CSV to [bold cyan]{results_csv_path}[/bold cyan]")
172
+
173
+ return cls.generate_report(metrics)
174
+
175
+ @classmethod
176
+ def generate_report(cls, m: dict) -> Panel:
177
+ is_significant = m["p_value"] < ALPHA
178
+ res_color = "green" if is_significant else "yellow"
179
+ res_text = (
180
+ "Statistically significant difference detected (reject null hypothesis)"
181
+ if is_significant
182
+ else "No statistically significant difference detected (fail to reject null hypothesis)"
183
+ )
184
+
185
+ table = Table(box=None, padding=(0, 2), show_header=False)
186
+ table.add_column("Metric", style="bold cyan")
187
+ table.add_column("Value", justify="right")
188
+
189
+ table.add_row("Baseline N Size", f"{m['baseline_n']}")
190
+ table.add_row("Treatment N Size", f"{m['treatment_n']}")
191
+ table.add_row(
192
+ "Baseline Median Energy",
193
+ f"{m['baseline_median_joules']:,.2f} J [dim](mean {m['baseline_mean_joules']:,.2f} J)[/dim]",
194
+ )
195
+ table.add_row(
196
+ "Treatment Median Energy",
197
+ f"{m['treatment_median_joules']:,.2f} J [dim](mean {m['treatment_mean_joules']:,.2f} J)[/dim]",
198
+ )
199
+ style = "green" if m["treatment_effect_pct_median"] < 0 else "red"
200
+ table.add_row(
201
+ "Treatment Effect (median)",
202
+ f"[{style}]{m['treatment_effect_pct_median']:+.2f}%[/{style}] "
203
+ f"[dim](95% CI {m['median_effect_ci95_low_pct']:+.1f}% to {m['median_effect_ci95_high_pct']:+.1f}%)[/dim]",
204
+ )
205
+ table.add_row("Treatment Effect (mean)", f"{m['treatment_effect_pct_mean']:+.2f}%")
206
+ table.add_row("P(treatment run < baseline run)", f"{m['prob_treatment_lower']:.0%}")
207
+ table.add_row("Grid Intensity Used", f"{m['grid_intensity_gco2e_kwh']:,.2f} gCO2e/kWh")
208
+ co2_style = "green" if m["delta_gco2e_execution"] < 0 else "red"
209
+ table.add_row(
210
+ "Operational Emissions Delta",
211
+ f"[{co2_style}]{m['delta_gco2e_execution']:+.6f} gCO2e/exec[/{co2_style}]",
212
+ )
213
+ table.add_section()
214
+ table.add_row("\n[bold white]Hypothesis Test (Mann-Whitney U)[/bold white]", "")
215
+ table.add_row(" U statistic", f"{m['mann_whitney_u_stat']:,.1f}")
216
+ table.add_row(" p-value", f"{m['p_value']:.5f} [dim](α = {ALPHA})[/dim]")
217
+
218
+ parts = [table]
219
+ small_n = min(m["baseline_n"], m["treatment_n"])
220
+ if small_n < MIN_RECOMMENDED_N:
221
+ parts.append(
222
+ Text(
223
+ f"\n Warning: only {small_n} runs per group; results are unreliable "
224
+ f"(>= {MIN_RECOMMENDED_N} recommended, 30 for robust conclusions).",
225
+ style="bold yellow",
226
+ )
227
+ )
228
+ parts.append(
229
+ Text.assemble("\n", (" OUTCOME: ", f"bold {res_color}"), (res_text, "bold white"))
230
+ )
231
+ return Panel(
232
+ Group(*parts),
233
+ title="[bold white]METRIC EVALUATION & STATISTICAL ANALYSIS[/bold white]",
234
+ title_align="left",
235
+ border_style="green" if is_significant else "blue",
236
+ expand=False,
237
+ padding=(1, 3),
238
+ )
239
+
240
+ # ------------------------------------------------------------------- plots
241
+ @staticmethod
242
+ def plots_available() -> bool:
243
+ try:
244
+ import matplotlib # noqa: F401
245
+ import seaborn # noqa: F401
246
+ except ImportError:
247
+ return False
248
+ return True
249
+
250
+ @staticmethod
251
+ def generate_visuals(results_dir: Path) -> Optional[Path]:
252
+ """Write the energy plots. Returns None if the optional plotting extra is missing."""
253
+ if not AnalysisEngine.plots_available():
254
+ return None
255
+ import matplotlib
256
+
257
+ matplotlib.use("Agg") # headless-safe
258
+ import matplotlib.pyplot as plt
259
+ import seaborn as sns
260
+
261
+ results_dir = Path(results_dir)
262
+ df = pd.read_csv(results_dir / "data.csv")
263
+ palette = {"baseline": "#007F61", "treatment": "#fb9b60"}
264
+ with sns.axes_style("whitegrid"):
265
+ # Plot 1: distribution per group
266
+ fig, ax = plt.subplots(figsize=(8, 6))
267
+ sns.boxplot(
268
+ x="label", y="energy_joules", data=df, ax=ax, orient="v", palette=palette, hue="label",
269
+ legend=False, width=0.4, boxprops={"edgecolor": "#DDDDDD", "linewidth": 1},
270
+ whiskerprops={"linewidth": 1.5, "alpha": 0.4}, capprops={"linewidth": 1.5, "alpha": 0.4},
271
+ medianprops={"linewidth": 1, "alpha": 0.3},
272
+ )
273
+ sns.stripplot(x="label", y="energy_joules", data=df, ax=ax, color="black", alpha=0.3, size=5, jitter=0.15)
274
+ ax.set_title("Energy Consumption Distribution Across Groups", fontsize=12, fontweight="bold", pad=12)
275
+ ax.set_ylabel("Total Operational Energy (Joules)")
276
+ ax.set_xlabel("Experimental Group")
277
+ fig.savefig(results_dir / "energy_distribution.png", dpi=300)
278
+ plt.close(fig)
279
+
280
+ # Plot 2: energy over the course of the experiment
281
+ df_plot = df.copy()
282
+ df_plot["sequence_idx"] = df_plot.groupby("label").cumcount() + 1
283
+ fig, ax = plt.subplots(figsize=(10, 5))
284
+ sns.lineplot(
285
+ x="sequence_idx", y="energy_joules", hue="label", data=df_plot, ax=ax,
286
+ marker="o", markersize=6, linewidth=2, palette=palette,
287
+ )
288
+ ax.set_title("Runtime Operational Stability Over Iterations", fontsize=12, fontweight="bold", pad=12)
289
+ ax.set_ylabel("Total Operational Energy (Joules)")
290
+ ax.set_xlabel("Sequential Iteration Phase (per group)")
291
+ ax.legend(title="Experimental Group", frameon=True)
292
+ sns.despine(ax=ax, trim=True)
293
+ fig.tight_layout()
294
+ fig.savefig(results_dir / "energy_stability.png", dpi=300)
295
+ plt.close(fig)
296
+ return results_dir
@@ -0,0 +1,18 @@
1
+ """Count N-Queens solutions: simple backtracking that copies lists (slow on purpose)."""
2
+ import sys
3
+
4
+
5
+ def solve(n, row=0, cols=(), diag1=(), diag2=()):
6
+ if row == n:
7
+ return 1
8
+ total = 0
9
+ for col in range(n):
10
+ if col in cols or (row - col) in diag1 or (row + col) in diag2:
11
+ continue
12
+ total += solve(n, row + 1, cols + (col,), diag1 + (row - col,), diag2 + (row + col,))
13
+ return total
14
+
15
+
16
+ if __name__ == "__main__":
17
+ n = int(sys.argv[1]) if len(sys.argv) > 1 else 9
18
+ print(f"{n}-queens solutions: {solve(n)}")
@@ -0,0 +1,20 @@
1
+ """Count N-Queens solutions: same algorithm using bitmasks (no allocations)."""
2
+ import sys
3
+
4
+
5
+ def solve(n, cols=0, diag1=0, diag2=0, full=None):
6
+ full = (1 << n) - 1 if full is None else full
7
+ if cols == full:
8
+ return 1
9
+ total = 0
10
+ free = full & ~(cols | diag1 | diag2)
11
+ while free:
12
+ bit = free & -free
13
+ free ^= bit
14
+ total += solve(n, cols | bit, ((diag1 | bit) << 1) & full, (diag2 | bit) >> 1, full)
15
+ return total
16
+
17
+
18
+ if __name__ == "__main__":
19
+ n = int(sys.argv[1]) if len(sys.argv) > 1 else 9
20
+ print(f"{n}-queens solutions: {solve(n)}")