flopscope 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.
Files changed (115) hide show
  1. benchmarks/__init__.py +1 -0
  2. benchmarks/__main__.py +6 -0
  3. benchmarks/_baseline.py +171 -0
  4. benchmarks/_bitwise.py +231 -0
  5. benchmarks/_complex.py +176 -0
  6. benchmarks/_contractions.py +291 -0
  7. benchmarks/_fft.py +198 -0
  8. benchmarks/_impl_urls.py +139 -0
  9. benchmarks/_linalg.py +197 -0
  10. benchmarks/_linalg_delegates.py +407 -0
  11. benchmarks/_metadata.py +141 -0
  12. benchmarks/_misc.py +653 -0
  13. benchmarks/_perf.py +321 -0
  14. benchmarks/_perm_group_calibration.py +175 -0
  15. benchmarks/_pointwise.py +372 -0
  16. benchmarks/_polynomial.py +193 -0
  17. benchmarks/_random.py +209 -0
  18. benchmarks/_reductions.py +136 -0
  19. benchmarks/_sorting.py +289 -0
  20. benchmarks/_stats.py +137 -0
  21. benchmarks/_window.py +92 -0
  22. benchmarks/accumulation/__init__.py +0 -0
  23. benchmarks/accumulation/bench_cost_compute.py +138 -0
  24. benchmarks/dashboard.py +312 -0
  25. benchmarks/runner.py +636 -0
  26. flopscope/__init__.py +273 -0
  27. flopscope/_accumulation/__init__.py +13 -0
  28. flopscope/_accumulation/_bipartite.py +121 -0
  29. flopscope/_accumulation/_burnside.py +51 -0
  30. flopscope/_accumulation/_cache.py +146 -0
  31. flopscope/_accumulation/_components.py +153 -0
  32. flopscope/_accumulation/_cost.py +1414 -0
  33. flopscope/_accumulation/_cost_descriptions.py +63 -0
  34. flopscope/_accumulation/_detection.py +318 -0
  35. flopscope/_accumulation/_ladder.py +191 -0
  36. flopscope/_accumulation/_output_orbit.py +104 -0
  37. flopscope/_accumulation/_partition.py +290 -0
  38. flopscope/_accumulation/_path_info.py +211 -0
  39. flopscope/_accumulation/_public.py +169 -0
  40. flopscope/_accumulation/_reduction.py +310 -0
  41. flopscope/_accumulation/_regimes.py +303 -0
  42. flopscope/_accumulation/_shape.py +33 -0
  43. flopscope/_accumulation/_wreath.py +209 -0
  44. flopscope/_budget.py +1027 -0
  45. flopscope/_config.py +118 -0
  46. flopscope/_counting_ops.py +451 -0
  47. flopscope/_display.py +478 -0
  48. flopscope/_docstrings.py +59 -0
  49. flopscope/_dtypes.py +20 -0
  50. flopscope/_einsum.py +717 -0
  51. flopscope/_errstate.py +25 -0
  52. flopscope/_flops.py +282 -0
  53. flopscope/_free_ops.py +2654 -0
  54. flopscope/_ndarray.py +1126 -0
  55. flopscope/_opt_einsum/LICENSE +21 -0
  56. flopscope/_opt_einsum/NOTICE +59 -0
  57. flopscope/_opt_einsum/__init__.py +209 -0
  58. flopscope/_opt_einsum/_contract.py +1478 -0
  59. flopscope/_opt_einsum/_helpers.py +164 -0
  60. flopscope/_opt_einsum/_hsluv.py +273 -0
  61. flopscope/_opt_einsum/_path_random.py +462 -0
  62. flopscope/_opt_einsum/_paths.py +1653 -0
  63. flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
  64. flopscope/_opt_einsum/_symmetry.py +140 -0
  65. flopscope/_opt_einsum/_typing.py +37 -0
  66. flopscope/_perm_group.py +717 -0
  67. flopscope/_pointwise.py +2522 -0
  68. flopscope/_polynomial.py +278 -0
  69. flopscope/_registry.py +3216 -0
  70. flopscope/_sorting_ops.py +571 -0
  71. flopscope/_symmetric.py +812 -0
  72. flopscope/_symmetry_transport.py +510 -0
  73. flopscope/_symmetry_utils.py +669 -0
  74. flopscope/_type_info.py +12 -0
  75. flopscope/_unwrap.py +70 -0
  76. flopscope/_validation.py +83 -0
  77. flopscope/_version_check.py +46 -0
  78. flopscope/_weights.py +195 -0
  79. flopscope/_window.py +177 -0
  80. flopscope/accounting.py +565 -0
  81. flopscope/data/default_weights.json +462 -0
  82. flopscope/data/weights.csv +509 -0
  83. flopscope/errors.py +197 -0
  84. flopscope/numpy/__init__.py +878 -0
  85. flopscope/numpy/fft/__init__.py +55 -0
  86. flopscope/numpy/fft/_free.py +51 -0
  87. flopscope/numpy/fft/_transforms.py +695 -0
  88. flopscope/numpy/linalg/__init__.py +105 -0
  89. flopscope/numpy/linalg/_aliases.py +126 -0
  90. flopscope/numpy/linalg/_compound.py +161 -0
  91. flopscope/numpy/linalg/_decompositions.py +353 -0
  92. flopscope/numpy/linalg/_properties.py +533 -0
  93. flopscope/numpy/linalg/_solvers.py +444 -0
  94. flopscope/numpy/linalg/_svd.py +122 -0
  95. flopscope/numpy/random/__init__.py +684 -0
  96. flopscope/numpy/random/_cost_formulas.py +115 -0
  97. flopscope/numpy/random/_counted_classes.py +241 -0
  98. flopscope/numpy/testing/__init__.py +13 -0
  99. flopscope/numpy/typing/__init__.py +30 -0
  100. flopscope/py.typed +0 -0
  101. flopscope/stats/__init__.py +84 -0
  102. flopscope/stats/_base.py +77 -0
  103. flopscope/stats/_cauchy.py +146 -0
  104. flopscope/stats/_erf.py +190 -0
  105. flopscope/stats/_expon.py +146 -0
  106. flopscope/stats/_laplace.py +150 -0
  107. flopscope/stats/_logistic.py +148 -0
  108. flopscope/stats/_lognorm.py +160 -0
  109. flopscope/stats/_ndtri.py +133 -0
  110. flopscope/stats/_norm.py +149 -0
  111. flopscope/stats/_truncnorm.py +186 -0
  112. flopscope/stats/_uniform.py +141 -0
  113. flopscope-0.2.0.dist-info/METADATA +23 -0
  114. flopscope-0.2.0.dist-info/RECORD +115 -0
  115. flopscope-0.2.0.dist-info/WHEEL +4 -0
benchmarks/_perf.py ADDED
@@ -0,0 +1,321 @@
1
+ """Measure floating-point work for benchmark operations.
2
+
3
+ Primary method: Linux ``perf stat`` hardware counters (exact FP op counts).
4
+ Fallback: wall-clock time measurement (relative proxy, works everywhere).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ PERF_EVENTS = [
18
+ "fp_arith_inst_retired.scalar_double",
19
+ "fp_arith_inst_retired.128b_packed_double",
20
+ "fp_arith_inst_retired.256b_packed_double",
21
+ "fp_arith_inst_retired.512b_packed_double",
22
+ ]
23
+
24
+ # Mapping from event name suffix to SIMD width multiplier.
25
+ _WIDTH = {
26
+ "scalar_double": 1,
27
+ "128b_packed_double": 2,
28
+ "256b_packed_double": 4,
29
+ "512b_packed_double": 8,
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PerfResult:
35
+ """Counts of retired floating-point arithmetic instructions."""
36
+
37
+ scalar_double: int
38
+ packed_128_double: int
39
+ packed_256_double: int
40
+ packed_512_double: int
41
+
42
+ @property
43
+ def total_flops(self) -> int:
44
+ """Total double-precision FLOPs, weighted by SIMD width."""
45
+ return (
46
+ self.scalar_double * 1
47
+ + self.packed_128_double * 2
48
+ + self.packed_256_double * 4
49
+ + self.packed_512_double * 8
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class TimingResult:
55
+ """Wall-clock timing result used as fallback when perf is unavailable.
56
+
57
+ Stores elapsed nanoseconds. Consumers use ``total_flops`` which returns
58
+ the raw nanosecond value — the normalization step (op_time / add_time)
59
+ in the runner cancels units, producing valid relative weights.
60
+ """
61
+
62
+ elapsed_ns: int
63
+
64
+ @property
65
+ def total_flops(self) -> int:
66
+ """Return elapsed nanoseconds as a proxy for FP work.
67
+
68
+ This is intentionally named ``total_flops`` so that all benchmark
69
+ modules can use the same interface regardless of measurement mode.
70
+ The values are only meaningful as ratios (normalized against the
71
+ baseline ``np.add`` measurement).
72
+ """
73
+ return self.elapsed_ns
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class InstructionsResult:
78
+ """Total retired instructions measured via ``perf stat -e instructions``.
79
+
80
+ Used as a hardware-counter fallback for integer/bitwise operations
81
+ where ``fp_arith_inst_retired`` reads 0. More stable than wall-clock
82
+ timing because it is deterministic and independent of system load.
83
+
84
+ Like ``TimingResult``, the ``total_flops`` property returns the raw
85
+ instruction count — normalization against the baseline (``np.add``)
86
+ in the runner cancels units, producing valid relative weights.
87
+ """
88
+
89
+ instructions: int
90
+
91
+ @property
92
+ def total_flops(self) -> int:
93
+ """Return total retired instructions as a proxy for work."""
94
+ return self.instructions
95
+
96
+
97
+ # Union type for all measurement modes
98
+ MeasureResult = PerfResult | TimingResult | InstructionsResult
99
+
100
+
101
+ def has_perf() -> bool:
102
+ """Return True if the ``perf`` binary is on PATH.
103
+
104
+ The check can be overridden by setting the environment variable
105
+ ``FLOPSCOPE_FORCE_TIMING=1``, which forces timing mode regardless
106
+ of whether ``perf`` is available. This is used by the dual-mode
107
+ validation loop to re-run benchmarks in timing mode.
108
+ """
109
+ if os.environ.get("FLOPSCOPE_FORCE_TIMING") == "1":
110
+ return False
111
+ return shutil.which("perf") is not None
112
+
113
+
114
+ def measurement_mode() -> str:
115
+ """Return the active measurement mode: ``'perf'`` or ``'timing'``."""
116
+ return "perf" if has_perf() else "timing"
117
+
118
+
119
+ def _parse_perf_csv(output: str) -> PerfResult:
120
+ """Parse the CSV output produced by ``perf stat -x ,``."""
121
+ counts: dict[str, int] = {}
122
+ for line in output.splitlines():
123
+ line = line.strip()
124
+ if not line or line.startswith("#"):
125
+ continue
126
+ parts = line.split(",")
127
+ if len(parts) < 3:
128
+ continue
129
+ raw_count = parts[0].strip()
130
+ event_name = parts[2].strip()
131
+ # Match against known events.
132
+ for evt in PERF_EVENTS:
133
+ if evt == event_name:
134
+ try:
135
+ counts[evt] = int(raw_count)
136
+ except (ValueError, TypeError):
137
+ # <not supported> or similar
138
+ counts[evt] = 0
139
+ break
140
+
141
+ return PerfResult(
142
+ scalar_double=counts.get(PERF_EVENTS[0], 0),
143
+ packed_128_double=counts.get(PERF_EVENTS[1], 0),
144
+ packed_256_double=counts.get(PERF_EVENTS[2], 0),
145
+ packed_512_double=counts.get(PERF_EVENTS[3], 0),
146
+ )
147
+
148
+
149
+ def _build_script(setup_code: str, bench_code: str, repeats: int) -> str:
150
+ """Build the benchmark Python script content."""
151
+ return (
152
+ "import numpy as np\n"
153
+ f"{setup_code}\n"
154
+ f"for _i in range({repeats}):\n"
155
+ f" {bench_code}\n"
156
+ )
157
+
158
+
159
+ def _measure_perf(setup_code: str, bench_code: str, repeats: int) -> PerfResult:
160
+ """Measure using Linux perf stat hardware counters."""
161
+ script = _build_script(setup_code, bench_code, repeats)
162
+
163
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
164
+ try:
165
+ tmp.write(script)
166
+ tmp.close()
167
+
168
+ events_arg = ",".join(PERF_EVENTS)
169
+ proc = subprocess.run(
170
+ [
171
+ "perf",
172
+ "stat",
173
+ "-e",
174
+ events_arg,
175
+ "-x",
176
+ ",",
177
+ sys.executable,
178
+ tmp.name,
179
+ ],
180
+ capture_output=True,
181
+ text=True,
182
+ )
183
+ return _parse_perf_csv(proc.stderr)
184
+ finally:
185
+ Path(tmp.name).unlink(missing_ok=True)
186
+
187
+
188
+ def _measure_timing(setup_code: str, bench_code: str, repeats: int) -> TimingResult:
189
+ """Measure using wall-clock time in a subprocess."""
190
+ script = (
191
+ "import time\n"
192
+ "import numpy as np\n"
193
+ f"{setup_code}\n"
194
+ "# Warmup\n"
195
+ f"for _i in range(2):\n"
196
+ f" {bench_code}\n"
197
+ "# Timed run\n"
198
+ "_t0 = time.perf_counter_ns()\n"
199
+ f"for _i in range({repeats}):\n"
200
+ f" {bench_code}\n"
201
+ "_t1 = time.perf_counter_ns()\n"
202
+ "print(_t1 - _t0)\n"
203
+ )
204
+
205
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
206
+ try:
207
+ tmp.write(script)
208
+ tmp.close()
209
+
210
+ proc = subprocess.run(
211
+ [sys.executable, tmp.name],
212
+ capture_output=True,
213
+ text=True,
214
+ timeout=300,
215
+ )
216
+ if proc.returncode != 0:
217
+ raise RuntimeError(
218
+ f"Benchmark subprocess failed (exit {proc.returncode}):\n"
219
+ f"stderr: {proc.stderr}"
220
+ )
221
+ elapsed_ns = int(proc.stdout.strip())
222
+ return TimingResult(elapsed_ns=elapsed_ns)
223
+ finally:
224
+ Path(tmp.name).unlink(missing_ok=True)
225
+
226
+
227
+ def _measure_instructions(
228
+ setup_code: str, bench_code: str, repeats: int
229
+ ) -> InstructionsResult:
230
+ """Measure total retired instructions via ``perf stat -e instructions``.
231
+
232
+ This is a hardware counter that counts all retired instructions (integer,
233
+ FP, branch, load/store). It is deterministic and independent of system
234
+ load, making it a better fallback than wall-clock timing for integer
235
+ and bitwise operations where ``fp_arith_inst_retired`` reads 0.
236
+ """
237
+ script = _build_script(setup_code, bench_code, repeats)
238
+
239
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
240
+ try:
241
+ tmp.write(script)
242
+ tmp.close()
243
+
244
+ proc = subprocess.run(
245
+ [
246
+ "perf",
247
+ "stat",
248
+ "-e",
249
+ "instructions",
250
+ "-x",
251
+ ",",
252
+ sys.executable,
253
+ tmp.name,
254
+ ],
255
+ capture_output=True,
256
+ text=True,
257
+ )
258
+ # Parse CSV: first field is count, third is event name
259
+ instructions = 0
260
+ for line in proc.stderr.splitlines():
261
+ line = line.strip()
262
+ if not line or line.startswith("#"):
263
+ continue
264
+ parts = line.split(",")
265
+ if len(parts) >= 3 and "instructions" in parts[2]:
266
+ try:
267
+ instructions = int(parts[0].strip())
268
+ except (ValueError, TypeError):
269
+ pass
270
+ break
271
+ return InstructionsResult(instructions=instructions)
272
+ finally:
273
+ Path(tmp.name).unlink(missing_ok=True)
274
+
275
+
276
+ def measure_flops(
277
+ setup_code: str,
278
+ bench_code: str,
279
+ repeats: int = 10,
280
+ ) -> MeasureResult:
281
+ """Measure FP work for a benchmark operation.
282
+
283
+ Uses ``perf stat`` hardware counters when available (Linux). Falls back
284
+ to wall-clock time measurement on other platforms. Both return an object
285
+ with a ``total_flops`` property — for perf mode this is actual FP ops,
286
+ for timing mode it is elapsed nanoseconds (valid as a relative proxy
287
+ when normalized against the baseline).
288
+
289
+ Parameters
290
+ ----------
291
+ setup_code:
292
+ Python code executed once before the hot loop (numpy is
293
+ already imported as ``np``).
294
+ bench_code:
295
+ Python code executed *repeats* times inside the hot loop.
296
+ repeats:
297
+ Number of iterations of the hot loop.
298
+ """
299
+ if has_perf():
300
+ return _measure_perf(setup_code, bench_code, repeats)
301
+ return _measure_timing(setup_code, bench_code, repeats)
302
+
303
+
304
+ def measure_instructions(
305
+ setup_code: str,
306
+ bench_code: str,
307
+ repeats: int = 10,
308
+ ) -> InstructionsResult:
309
+ """Measure total retired instructions for a benchmark operation.
310
+
311
+ Uses ``perf stat -e instructions`` hardware counter. This is the
312
+ preferred fallback for integer/bitwise operations where
313
+ ``fp_arith_inst_retired`` reads 0. Requires ``perf`` to be available.
314
+
315
+ Falls back to timing if ``perf`` is not available.
316
+ """
317
+ if shutil.which("perf") is not None:
318
+ return _measure_instructions(setup_code, bench_code, repeats)
319
+ # If perf isn't available at all, fall back to timing
320
+ timing = _measure_timing(setup_code, bench_code, repeats)
321
+ return InstructionsResult(instructions=timing.elapsed_ns)
@@ -0,0 +1,175 @@
1
+ """Calibration script for the dimino_budget setting.
2
+
3
+ Measures cold ``_dimino`` enumeration time plus ``burnside_unique_count``
4
+ time across representative permutation groups, then prints a
5
+ recommendation for ``dimino_budget``.
6
+
7
+ Run with::
8
+
9
+ python -m benchmarks._perm_group_calibration
10
+ python -m benchmarks._perm_group_calibration --budget-ms 50 \\
11
+ --output benchmarks/_perm_group_calibration.json
12
+
13
+ Output is informational. Users on faster/slower machines run this and
14
+ ``flops.configure(dimino_budget=<recommended>)``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import platform
22
+ import time
23
+ from dataclasses import dataclass
24
+
25
+ from flopscope._perm_group import SymmetryGroup, _dimino
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Measurement:
30
+ label: str
31
+ group_order: int
32
+ degree: int
33
+ dimino_ms: float
34
+ burnside_ms: float
35
+
36
+ @property
37
+ def total_ms(self) -> float:
38
+ return self.dimino_ms + self.burnside_ms
39
+
40
+
41
+ def _measure_one(group: SymmetryGroup, label: str) -> Measurement:
42
+ """Time cold ``_dimino`` + Burnside enumeration on one group."""
43
+ # Cold dimino: bypass the cache by calling _dimino directly.
44
+ t = time.perf_counter()
45
+ _dimino(group._generators)
46
+ dimino_ms = (time.perf_counter() - t) * 1000.0
47
+
48
+ # Burnside: realistic size_dict (uniform 4-dim across all axes).
49
+ size_dict = dict.fromkeys(range(group.degree), 4)
50
+ t = time.perf_counter()
51
+ group.burnside_unique_count(size_dict)
52
+ burnside_ms = (time.perf_counter() - t) * 1000.0
53
+
54
+ return Measurement(
55
+ label=label,
56
+ group_order=group.order(),
57
+ degree=group.degree,
58
+ dimino_ms=dimino_ms,
59
+ burnside_ms=burnside_ms,
60
+ )
61
+
62
+
63
+ def _sample_groups() -> list[tuple[str, SymmetryGroup]]:
64
+ """Return the representative group sample for calibration."""
65
+ samples: list[tuple[str, SymmetryGroup]] = []
66
+ for n in (3, 4, 5, 6, 7, 8, 9):
67
+ samples.append((f"S_{n}", SymmetryGroup.symmetric(axes=tuple(range(n)))))
68
+ for n in (4, 8, 16, 32, 64):
69
+ samples.append((f"C_{n}", SymmetryGroup.cyclic(axes=tuple(range(n)))))
70
+ for n in (4, 8, 16, 32, 64):
71
+ samples.append((f"D_{n}", SymmetryGroup.dihedral(axes=tuple(range(n)))))
72
+ samples.append(
73
+ (
74
+ "S_3 x S_3",
75
+ SymmetryGroup.direct_product(
76
+ SymmetryGroup.symmetric(axes=(0, 1, 2)),
77
+ SymmetryGroup.symmetric(axes=(3, 4, 5)),
78
+ ),
79
+ )
80
+ )
81
+ samples.append(
82
+ (
83
+ "S_4 x S_4",
84
+ SymmetryGroup.direct_product(
85
+ SymmetryGroup.symmetric(axes=(0, 1, 2, 3)),
86
+ SymmetryGroup.symmetric(axes=(4, 5, 6, 7)),
87
+ ),
88
+ )
89
+ )
90
+ samples.append(
91
+ (
92
+ "C_5 x C_5 x C_5",
93
+ SymmetryGroup.direct_product(
94
+ SymmetryGroup.cyclic(axes=(0, 1, 2, 3, 4)),
95
+ SymmetryGroup.cyclic(axes=(5, 6, 7, 8, 9)),
96
+ SymmetryGroup.cyclic(axes=(10, 11, 12, 13, 14)),
97
+ ),
98
+ )
99
+ )
100
+ return samples
101
+
102
+
103
+ def _print_table(measurements: list[Measurement]) -> None:
104
+ header = f"{'group':<22}{'|G|':>12}{'dimino':>12}{'burnside':>12}{'total':>12}"
105
+ print(header)
106
+ print("-" * len(header))
107
+ for m in measurements:
108
+ print(
109
+ f"{m.label:<22}"
110
+ f"{m.group_order:>12}"
111
+ f"{m.dimino_ms:>10.2f}ms"
112
+ f"{m.burnside_ms:>10.2f}ms"
113
+ f"{m.total_ms:>10.2f}ms"
114
+ )
115
+
116
+
117
+ def _recommend_budget(measurements: list[Measurement], budget_ms: float) -> int:
118
+ """Largest |G| whose total cold cost stays under ``budget_ms``."""
119
+ under_budget = [m for m in measurements if m.total_ms <= budget_ms]
120
+ if not under_budget:
121
+ return 1 # nothing fits; degenerate machine
122
+ return max(m.group_order for m in under_budget)
123
+
124
+
125
+ def main() -> None:
126
+ parser = argparse.ArgumentParser(description=__doc__)
127
+ parser.add_argument(
128
+ "--budget-ms",
129
+ type=float,
130
+ default=100.0,
131
+ help="Wall-clock budget per group construction (default: 100ms)",
132
+ )
133
+ parser.add_argument(
134
+ "--output",
135
+ type=str,
136
+ default=None,
137
+ help="Optional JSON output path",
138
+ )
139
+ args = parser.parse_args()
140
+
141
+ samples = _sample_groups()
142
+ measurements = [_measure_one(g, label) for label, g in samples]
143
+
144
+ _print_table(measurements)
145
+ recommended = _recommend_budget(measurements, args.budget_ms)
146
+ print()
147
+ print(
148
+ f"Recommended dimino_budget: {recommended} "
149
+ f"(group_order_budget_ms={args.budget_ms}, machine: {platform.platform()})"
150
+ )
151
+
152
+ if args.output:
153
+ payload = {
154
+ "machine": platform.platform(),
155
+ "budget_ms": args.budget_ms,
156
+ "recommended_dimino_budget": recommended,
157
+ "measurements": [
158
+ {
159
+ "label": m.label,
160
+ "group_order": m.group_order,
161
+ "degree": m.degree,
162
+ "dimino_ms": m.dimino_ms,
163
+ "burnside_ms": m.burnside_ms,
164
+ "total_ms": m.total_ms,
165
+ }
166
+ for m in measurements
167
+ ],
168
+ }
169
+ with open(args.output, "w") as f:
170
+ json.dump(payload, f, indent=2)
171
+ print(f"Wrote calibration data to {args.output}")
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()