matchpatch 0.4.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,646 @@
1
+ """Timing-parameter stability optimization for measurement runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, replace
8
+ from typing import Any, Protocol
9
+
10
+ import numpy as np
11
+
12
+ from matchpatch.analysis import AnalysisOptions, analyze_audio
13
+ from matchpatch.devices.base import DeviceProfile
14
+
15
+
16
+ class MeasurementBackend(Protocol):
17
+ def activate_preset(self, preset_id: int) -> None: ...
18
+
19
+ def reapply_snapshot(self, snapshot: int) -> None: ...
20
+
21
+ def record(self, reference_audio: np.ndarray) -> np.ndarray: ...
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TimingParameter:
26
+ name: str
27
+ label: str
28
+ table: tuple[str, ...]
29
+ key: str
30
+ duration_multiplier: float = 1.0
31
+ lower_bound: Callable[[dict[str, float]], float] = lambda values: 0.0
32
+ stable_start: Callable[[dict[str, float]], float] = lambda values: 0.0
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class StabilityStatistics:
37
+ snapshot1_lufs_mean: float
38
+ snapshot1_lufs_std: float
39
+ snapshot1_crest_mean: float
40
+ snapshot1_crest_std: float
41
+ snapshot2_lufs_mean: float
42
+ snapshot2_lufs_std: float
43
+ snapshot2_crest_mean: float
44
+ snapshot2_crest_std: float
45
+ tolerance_percent: float = 2.0
46
+ snapshot1_lufs_tolerance: float = 0.0
47
+ snapshot1_lufs_max_deviation: float = 0.0
48
+ snapshot1_crest_tolerance: float = 0.0
49
+ snapshot1_crest_max_deviation: float = 0.0
50
+ snapshot2_lufs_tolerance: float = 0.0
51
+ snapshot2_lufs_max_deviation: float = 0.0
52
+ snapshot2_crest_tolerance: float = 0.0
53
+ snapshot2_crest_max_deviation: float = 0.0
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ return {
57
+ "snapshot1_lufs_mean": self.snapshot1_lufs_mean,
58
+ "snapshot1_lufs_std": self.snapshot1_lufs_std,
59
+ "snapshot1_crest_mean": self.snapshot1_crest_mean,
60
+ "snapshot1_crest_std": self.snapshot1_crest_std,
61
+ "snapshot2_lufs_mean": self.snapshot2_lufs_mean,
62
+ "snapshot2_lufs_std": self.snapshot2_lufs_std,
63
+ "snapshot2_crest_mean": self.snapshot2_crest_mean,
64
+ "snapshot2_crest_std": self.snapshot2_crest_std,
65
+ "tolerance_percent": self.tolerance_percent,
66
+ "snapshot1_lufs_tolerance": self.snapshot1_lufs_tolerance,
67
+ "snapshot1_lufs_max_deviation": self.snapshot1_lufs_max_deviation,
68
+ "snapshot1_crest_tolerance": self.snapshot1_crest_tolerance,
69
+ "snapshot1_crest_max_deviation": self.snapshot1_crest_max_deviation,
70
+ "snapshot2_lufs_tolerance": self.snapshot2_lufs_tolerance,
71
+ "snapshot2_lufs_max_deviation": self.snapshot2_lufs_max_deviation,
72
+ "snapshot2_crest_tolerance": self.snapshot2_crest_tolerance,
73
+ "snapshot2_crest_max_deviation": self.snapshot2_crest_max_deviation,
74
+ }
75
+
76
+ @classmethod
77
+ def from_dict(cls, value: dict[str, Any]) -> StabilityStatistics:
78
+ return cls(
79
+ snapshot1_lufs_mean=float(value["snapshot1_lufs_mean"]),
80
+ snapshot1_lufs_std=float(value["snapshot1_lufs_std"]),
81
+ snapshot1_crest_mean=float(value["snapshot1_crest_mean"]),
82
+ snapshot1_crest_std=float(value["snapshot1_crest_std"]),
83
+ snapshot2_lufs_mean=float(value["snapshot2_lufs_mean"]),
84
+ snapshot2_lufs_std=float(value["snapshot2_lufs_std"]),
85
+ snapshot2_crest_mean=float(value["snapshot2_crest_mean"]),
86
+ snapshot2_crest_std=float(value["snapshot2_crest_std"]),
87
+ tolerance_percent=float(value.get("tolerance_percent", 2.0)),
88
+ snapshot1_lufs_tolerance=float(value.get("snapshot1_lufs_tolerance", 0.0)),
89
+ snapshot1_lufs_max_deviation=float(value.get("snapshot1_lufs_max_deviation", 0.0)),
90
+ snapshot1_crest_tolerance=float(value.get("snapshot1_crest_tolerance", 0.0)),
91
+ snapshot1_crest_max_deviation=float(value.get("snapshot1_crest_max_deviation", 0.0)),
92
+ snapshot2_lufs_tolerance=float(value.get("snapshot2_lufs_tolerance", 0.0)),
93
+ snapshot2_lufs_max_deviation=float(value.get("snapshot2_lufs_max_deviation", 0.0)),
94
+ snapshot2_crest_tolerance=float(value.get("snapshot2_crest_tolerance", 0.0)),
95
+ snapshot2_crest_max_deviation=float(value.get("snapshot2_crest_max_deviation", 0.0)),
96
+ )
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ParameterOptimizationResult:
101
+ parameter: TimingParameter
102
+ value: float
103
+ stable: bool
104
+ iterations: int
105
+ statistics: StabilityStatistics | None = None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class OptimizationProgress:
110
+ kind: str
111
+ message: str
112
+ parameter: str | None = None
113
+ candidate: float | None = None
114
+ stable: bool | None = None
115
+ low: float | None = None
116
+ high: float | None = None
117
+ best: float | None = None
118
+ iteration: int | None = None
119
+ statistics: StabilityStatistics | None = None
120
+ result_toml: str | None = None
121
+ results: tuple[ParameterOptimizationResult, ...] = ()
122
+
123
+ def to_json(self) -> str:
124
+ payload = {
125
+ "kind": self.kind,
126
+ "message": self.message,
127
+ "parameter": self.parameter,
128
+ "candidate": self.candidate,
129
+ "stable": self.stable,
130
+ "low": self.low,
131
+ "high": self.high,
132
+ "best": self.best,
133
+ "iteration": self.iteration,
134
+ "statistics": (self.statistics.to_dict() if self.statistics is not None else None),
135
+ "result_toml": self.result_toml,
136
+ "results": [
137
+ {
138
+ "parameter": {
139
+ "name": result.parameter.name,
140
+ "label": result.parameter.label,
141
+ "table": result.parameter.table,
142
+ "key": result.parameter.key,
143
+ },
144
+ "value": result.value,
145
+ "stable": result.stable,
146
+ "iterations": result.iterations,
147
+ "statistics": (
148
+ result.statistics.to_dict() if result.statistics is not None else None
149
+ ),
150
+ }
151
+ for result in self.results
152
+ ],
153
+ }
154
+ return json.dumps(payload, separators=(",", ":"))
155
+
156
+ @classmethod
157
+ def from_json(cls, value: str) -> OptimizationProgress:
158
+ payload: Any = json.loads(value)
159
+ if not isinstance(payload, dict):
160
+ raise ValueError("Optimization progress must be a JSON object")
161
+ payload["results"] = tuple(
162
+ ParameterOptimizationResult(
163
+ TimingParameter(
164
+ item["parameter"]["name"],
165
+ item["parameter"]["label"],
166
+ tuple(item["parameter"]["table"]),
167
+ item["parameter"]["key"],
168
+ ),
169
+ item["value"],
170
+ item["stable"],
171
+ item["iterations"],
172
+ StabilityStatistics.from_dict(item["statistics"])
173
+ if item.get("statistics") is not None
174
+ else None,
175
+ )
176
+ for item in payload.get("results", ())
177
+ )
178
+ if payload.get("statistics") is not None:
179
+ payload["statistics"] = StabilityStatistics.from_dict(payload["statistics"])
180
+ return cls(**payload)
181
+
182
+
183
+ class BackendFactory(Protocol):
184
+ def __call__(self, values: dict[str, float]) -> MeasurementBackend: ...
185
+
186
+
187
+ ProgressCallback = Callable[[OptimizationProgress], None]
188
+
189
+
190
+ class MeasurementAnalysisError(ValueError):
191
+ """Raised when a timing candidate produces audio that cannot be analyzed."""
192
+
193
+
194
+ TIMING_PARAMETERS: tuple[TimingParameter, ...] = (
195
+ TimingParameter(
196
+ "pre_roll",
197
+ "Pre-roll",
198
+ ("analysis",),
199
+ "pre_roll_seconds",
200
+ 2.0,
201
+ stable_start=lambda values: 1.0,
202
+ ),
203
+ TimingParameter(
204
+ "post_roll",
205
+ "Post-roll",
206
+ ("analysis",),
207
+ "post_roll_seconds",
208
+ 2.0,
209
+ lower_bound=lambda values: values["round_trip_latency"],
210
+ stable_start=lambda values: 1.0,
211
+ ),
212
+ TimingParameter(
213
+ "round_trip_latency",
214
+ "Round-trip latency",
215
+ ("analysis",),
216
+ "round_trip_latency_seconds",
217
+ 2.0,
218
+ stable_start=lambda values: 0.05,
219
+ ),
220
+ TimingParameter(
221
+ "preset_wait",
222
+ "Preset wait",
223
+ ("devices", "{device}", "steering"),
224
+ "preset_wait_seconds",
225
+ 1.0,
226
+ stable_start=lambda values: 2.0,
227
+ ),
228
+ TimingParameter(
229
+ "snapshot_wait",
230
+ "Snapshot wait",
231
+ ("devices", "{device}", "steering"),
232
+ "snapshot_wait_seconds",
233
+ 2.0,
234
+ stable_start=lambda values: 1.0,
235
+ ),
236
+ TimingParameter(
237
+ "measurement_wait",
238
+ "Measurement wait",
239
+ ("devices", "{device}", "steering"),
240
+ "measurement_wait_seconds",
241
+ 2.0,
242
+ stable_start=lambda values: 1.0,
243
+ ),
244
+ )
245
+
246
+
247
+ def _optimization_start_values(
248
+ initial_values: dict[str, float], parameters: tuple[TimingParameter, ...]
249
+ ) -> dict[str, float]:
250
+ values = dict(initial_values)
251
+ for parameter in parameters:
252
+ lower = parameter.lower_bound(values)
253
+ stable_start = parameter.stable_start(values)
254
+ values[parameter.name] = max(values[parameter.name], lower, stable_start)
255
+ return values
256
+
257
+
258
+ def _parameters_by_duration_impact(
259
+ values: dict[str, float], parameters: tuple[TimingParameter, ...]
260
+ ) -> tuple[TimingParameter, ...]:
261
+ return tuple(
262
+ sorted(
263
+ parameters,
264
+ key=lambda parameter: (
265
+ values[parameter.name] * parameter.duration_multiplier,
266
+ parameter.duration_multiplier,
267
+ ),
268
+ reverse=True,
269
+ )
270
+ )
271
+
272
+
273
+ def optimize_timing_parameters(
274
+ profile: DeviceProfile,
275
+ preset_id: int,
276
+ alternate_preset_id: int,
277
+ reference: np.ndarray,
278
+ sample_rate: int,
279
+ backend_factory: BackendFactory,
280
+ initial_values: dict[str, float],
281
+ analysis_options: AnalysisOptions,
282
+ *,
283
+ stability_runs: int = 3,
284
+ termination_tolerance_percent: float = 10.0,
285
+ stability_tolerance_percent: float = 2.0,
286
+ on_progress: ProgressCallback | None = None,
287
+ parameters: tuple[TimingParameter, ...] = TIMING_PARAMETERS,
288
+ ) -> tuple[ParameterOptimizationResult, ...]:
289
+ if stability_runs < 2:
290
+ raise ValueError("Stability runs must be at least 2")
291
+ if termination_tolerance_percent <= 0:
292
+ raise ValueError("Termination tolerance must be greater than zero")
293
+ if stability_tolerance_percent < 0:
294
+ raise ValueError("Stability tolerance must be zero or greater")
295
+
296
+ results: list[ParameterOptimizationResult] = []
297
+ values = _optimization_start_values(initial_values, parameters)
298
+ ordered_parameters = _parameters_by_duration_impact(values, parameters)
299
+ preset_label = profile.format_patch_id(preset_id)
300
+ reference_stable, reference_statistics = _is_stable(
301
+ profile,
302
+ preset_id,
303
+ alternate_preset_id,
304
+ reference,
305
+ sample_rate,
306
+ backend_factory,
307
+ values,
308
+ analysis_options,
309
+ stability_runs,
310
+ stability_tolerance_percent,
311
+ )
312
+ proven_stable_statistics = reference_statistics if reference_stable else None
313
+
314
+ for parameter in ordered_parameters:
315
+ start = values[parameter.name]
316
+ low = parameter.lower_bound(values)
317
+ if start < low:
318
+ raise ValueError(f"{parameter.label} must be at least {low:g} s for optimization")
319
+ high = start
320
+ iterations = 0
321
+ best = high
322
+ best_statistics = proven_stable_statistics
323
+ tolerance = abs(start) * termination_tolerance_percent / 100.0
324
+ if tolerance == 0:
325
+ tolerance = termination_tolerance_percent / 1000.0
326
+
327
+ _emit(
328
+ on_progress,
329
+ OptimizationProgress(
330
+ "parameter_started",
331
+ (
332
+ f"Investigating {parameter.label} from {start:.6g} s "
333
+ f"on preset {preset_label} using snapshots 1 and 2 "
334
+ f"({stability_runs} stability runs)"
335
+ ),
336
+ parameter=parameter.name,
337
+ low=low,
338
+ high=high,
339
+ best=best,
340
+ iteration=iterations,
341
+ results=tuple(results),
342
+ ),
343
+ )
344
+
345
+ if proven_stable_statistics is not None:
346
+ stable = True
347
+ statistics = proven_stable_statistics
348
+ else:
349
+ stable = False
350
+ statistics = reference_statistics
351
+
352
+ latest_statistics = statistics
353
+ if not stable:
354
+ result = ParameterOptimizationResult(
355
+ parameter, high, False, iterations, latest_statistics
356
+ )
357
+ results.append(result)
358
+ _emit(
359
+ on_progress,
360
+ OptimizationProgress(
361
+ "parameter_completed",
362
+ f"{parameter.label} is unstable at the optimization start value",
363
+ parameter=parameter.name,
364
+ candidate=high,
365
+ stable=False,
366
+ low=low,
367
+ high=high,
368
+ best=best,
369
+ iteration=iterations,
370
+ statistics=latest_statistics,
371
+ results=tuple(results),
372
+ ),
373
+ )
374
+ continue
375
+
376
+ while high - low > tolerance:
377
+ candidate = (low + high) / 2.0
378
+ candidate_values = {**values, parameter.name: candidate}
379
+ stable, statistics = _is_stable(
380
+ profile,
381
+ preset_id,
382
+ alternate_preset_id,
383
+ reference,
384
+ sample_rate,
385
+ backend_factory,
386
+ candidate_values,
387
+ analysis_options,
388
+ stability_runs,
389
+ stability_tolerance_percent,
390
+ reference_statistics,
391
+ )
392
+ latest_statistics = statistics
393
+ iterations += 1
394
+ if stable:
395
+ best = candidate
396
+ high = candidate
397
+ best_statistics = statistics
398
+ else:
399
+ low = candidate
400
+
401
+ _emit(
402
+ on_progress,
403
+ OptimizationProgress(
404
+ "candidate_completed",
405
+ (
406
+ f"{parameter.label}: {candidate:.6g} s "
407
+ f"{'stable' if stable else 'unstable'} after "
408
+ f"{stability_runs} runs on preset {preset_label}, snapshots 1 and 2"
409
+ ),
410
+ parameter=parameter.name,
411
+ candidate=candidate,
412
+ stable=stable,
413
+ low=low,
414
+ high=high,
415
+ best=best,
416
+ iteration=iterations,
417
+ statistics=latest_statistics,
418
+ results=tuple(results),
419
+ ),
420
+ )
421
+
422
+ values[parameter.name] = best
423
+ proven_stable_statistics = best_statistics
424
+ result = ParameterOptimizationResult(parameter, best, True, iterations, best_statistics)
425
+ results.append(result)
426
+ _emit(
427
+ on_progress,
428
+ OptimizationProgress(
429
+ "parameter_completed",
430
+ (
431
+ f"{parameter.label}: {best:.6g} s; applying this value "
432
+ "to the remaining parameter checks"
433
+ ),
434
+ parameter=parameter.name,
435
+ candidate=best,
436
+ stable=True,
437
+ low=low,
438
+ high=high,
439
+ best=best,
440
+ iteration=iterations,
441
+ statistics=latest_statistics,
442
+ results=tuple(results),
443
+ ),
444
+ )
445
+
446
+ if any(not result.stable for result in results):
447
+ return tuple(results)
448
+
449
+ _emit(
450
+ on_progress,
451
+ OptimizationProgress(
452
+ "final_stability_started",
453
+ (
454
+ f"Verifying final stability proof on preset {preset_label}, "
455
+ "snapshots 1 and 2, with all optimized values"
456
+ ),
457
+ results=tuple(results),
458
+ ),
459
+ )
460
+ final_statistics = proven_stable_statistics
461
+ final_stable = final_statistics is not None
462
+ _emit(
463
+ on_progress,
464
+ OptimizationProgress(
465
+ "final_stability_completed",
466
+ (
467
+ "Final stability check passed with optimized timing values"
468
+ if final_stable
469
+ else "Final stability check failed"
470
+ ),
471
+ stable=final_stable,
472
+ statistics=final_statistics,
473
+ results=tuple(results),
474
+ ),
475
+ )
476
+ if not final_stable:
477
+ raise RuntimeError("Final stability check failed with optimized timing values")
478
+
479
+ return tuple(results)
480
+
481
+
482
+ def optimization_results_toml(device: str, results: tuple[ParameterOptimizationResult, ...]) -> str:
483
+ grouped: dict[tuple[str, ...], list[tuple[str, float]]] = {}
484
+ for result in results:
485
+ table = tuple(device if part == "{device}" else part for part in result.parameter.table)
486
+ grouped.setdefault(table, []).append((result.parameter.key, result.value))
487
+
488
+ lines: list[str] = []
489
+ for table, items in grouped.items():
490
+ if lines:
491
+ lines.append("")
492
+ lines.append(f"[{'.'.join(table)}]")
493
+ for key, value in items:
494
+ lines.append(f"{key} = {_format_float(value)}")
495
+ return "\n".join(lines)
496
+
497
+
498
+ def alternate_preset_id(preset_id: int) -> int:
499
+ return preset_id + 1 if preset_id < 128 else preset_id - 1
500
+
501
+
502
+ def _is_stable(
503
+ profile: DeviceProfile,
504
+ preset_id: int,
505
+ alternate_preset_id: int,
506
+ reference: np.ndarray,
507
+ sample_rate: int,
508
+ backend_factory: BackendFactory,
509
+ values: dict[str, float],
510
+ analysis_options: AnalysisOptions,
511
+ stability_runs: int,
512
+ stability_tolerance_percent: float,
513
+ reference_statistics: StabilityStatistics | None = None,
514
+ ) -> tuple[bool, StabilityStatistics | None]:
515
+ measurements = []
516
+ for _ in range(stability_runs):
517
+ try:
518
+ measurements.append(
519
+ _measure_two_snapshots(
520
+ profile,
521
+ preset_id,
522
+ alternate_preset_id,
523
+ reference,
524
+ sample_rate,
525
+ backend_factory(values),
526
+ _analysis_options(values, analysis_options),
527
+ )
528
+ )
529
+ except MeasurementAnalysisError:
530
+ return False, None
531
+ statistics = _stability_statistics(
532
+ measurements,
533
+ stability_tolerance_percent,
534
+ reference_statistics,
535
+ )
536
+ stable = all(
537
+ deviation <= tolerance
538
+ for deviation, tolerance in (
539
+ (statistics.snapshot1_lufs_max_deviation, statistics.snapshot1_lufs_tolerance),
540
+ (
541
+ statistics.snapshot1_crest_max_deviation,
542
+ statistics.snapshot1_crest_tolerance,
543
+ ),
544
+ (statistics.snapshot2_lufs_max_deviation, statistics.snapshot2_lufs_tolerance),
545
+ (
546
+ statistics.snapshot2_crest_max_deviation,
547
+ statistics.snapshot2_crest_tolerance,
548
+ ),
549
+ )
550
+ )
551
+ return stable, statistics
552
+
553
+
554
+ def _measure_two_snapshots(
555
+ profile: DeviceProfile,
556
+ preset_id: int,
557
+ alternate_preset_id: int,
558
+ reference: np.ndarray,
559
+ sample_rate: int,
560
+ backend: MeasurementBackend,
561
+ analysis_options: AnalysisOptions,
562
+ ) -> tuple[tuple[float, float], tuple[float, float]]:
563
+ if getattr(profile, "max_snapshot_count", None) == 1:
564
+ raise ValueError(f"{profile.display_name} must support at least two snapshots")
565
+
566
+ backend.activate_preset(alternate_preset_id)
567
+ backend.activate_preset(preset_id)
568
+ results = []
569
+ for snapshot in (1, 2):
570
+ backend.reapply_snapshot(snapshot)
571
+ try:
572
+ values = analyze_audio(backend.record(reference), sample_rate, analysis_options)
573
+ except ValueError as exc:
574
+ raise MeasurementAnalysisError(str(exc)) from exc
575
+ results.append((values.short_term_lufs, values.crest_factor_db))
576
+ return results[0], results[1]
577
+
578
+
579
+ def _stability_statistics(
580
+ measurements: list[tuple[tuple[float, float], tuple[float, float]]],
581
+ tolerance_percent: float,
582
+ reference_statistics: StabilityStatistics | None = None,
583
+ ) -> StabilityStatistics:
584
+ values = np.asarray(measurements, dtype=np.float64)
585
+ means = values.mean(axis=0)
586
+ stds = values.std(axis=0)
587
+ reference_means = (
588
+ means
589
+ if reference_statistics is None
590
+ else np.asarray(
591
+ [
592
+ [
593
+ reference_statistics.snapshot1_lufs_mean,
594
+ reference_statistics.snapshot1_crest_mean,
595
+ ],
596
+ [
597
+ reference_statistics.snapshot2_lufs_mean,
598
+ reference_statistics.snapshot2_crest_mean,
599
+ ],
600
+ ],
601
+ dtype=np.float64,
602
+ )
603
+ )
604
+ max_deviations = (
605
+ np.max(np.abs(values - means), axis=0)
606
+ if reference_statistics is None
607
+ else np.abs(means - reference_means)
608
+ )
609
+ tolerances = np.maximum(np.abs(reference_means), 1.0) * tolerance_percent / 100.0
610
+ return StabilityStatistics(
611
+ snapshot1_lufs_mean=float(means[0, 0]),
612
+ snapshot1_lufs_std=float(stds[0, 0]),
613
+ snapshot1_crest_mean=float(means[0, 1]),
614
+ snapshot1_crest_std=float(stds[0, 1]),
615
+ snapshot2_lufs_mean=float(means[1, 0]),
616
+ snapshot2_lufs_std=float(stds[1, 0]),
617
+ snapshot2_crest_mean=float(means[1, 1]),
618
+ snapshot2_crest_std=float(stds[1, 1]),
619
+ tolerance_percent=tolerance_percent,
620
+ snapshot1_lufs_tolerance=float(tolerances[0, 0]),
621
+ snapshot1_lufs_max_deviation=float(max_deviations[0, 0]),
622
+ snapshot1_crest_tolerance=float(tolerances[0, 1]),
623
+ snapshot1_crest_max_deviation=float(max_deviations[0, 1]),
624
+ snapshot2_lufs_tolerance=float(tolerances[1, 0]),
625
+ snapshot2_lufs_max_deviation=float(max_deviations[1, 0]),
626
+ snapshot2_crest_tolerance=float(tolerances[1, 1]),
627
+ snapshot2_crest_max_deviation=float(max_deviations[1, 1]),
628
+ )
629
+
630
+
631
+ def _analysis_options(values: dict[str, float], options: AnalysisOptions) -> AnalysisOptions:
632
+ return replace(
633
+ options,
634
+ window_seconds=values["analysis_window"],
635
+ interval_seconds=values["analysis_interval"],
636
+ )
637
+
638
+
639
+ def _format_float(value: float) -> str:
640
+ text = f"{value:.6f}".rstrip("0").rstrip(".")
641
+ return text if text else "0"
642
+
643
+
644
+ def _emit(callback: ProgressCallback | None, progress: OptimizationProgress) -> None:
645
+ if callback is not None:
646
+ callback(progress)