sqil-core 0.1.0__py3-none-any.whl → 1.0.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 (36) hide show
  1. sqil_core/__init__.py +1 -0
  2. sqil_core/config_log.py +42 -0
  3. sqil_core/experiment/__init__.py +11 -0
  4. sqil_core/experiment/_analysis.py +95 -0
  5. sqil_core/experiment/_events.py +25 -0
  6. sqil_core/experiment/_experiment.py +553 -0
  7. sqil_core/experiment/data/plottr.py +778 -0
  8. sqil_core/experiment/helpers/_function_override_handler.py +111 -0
  9. sqil_core/experiment/helpers/_labone_wrappers.py +12 -0
  10. sqil_core/experiment/instruments/__init__.py +2 -0
  11. sqil_core/experiment/instruments/_instrument.py +190 -0
  12. sqil_core/experiment/instruments/drivers/SignalCore_SC5511A.py +515 -0
  13. sqil_core/experiment/instruments/local_oscillator.py +205 -0
  14. sqil_core/experiment/instruments/server.py +175 -0
  15. sqil_core/experiment/instruments/setup.yaml +21 -0
  16. sqil_core/experiment/instruments/zurich_instruments.py +55 -0
  17. sqil_core/fit/__init__.py +22 -0
  18. sqil_core/fit/_core.py +179 -31
  19. sqil_core/fit/_fit.py +490 -81
  20. sqil_core/fit/_guess.py +232 -0
  21. sqil_core/fit/_models.py +32 -1
  22. sqil_core/fit/_quality.py +266 -0
  23. sqil_core/resonator/__init__.py +2 -0
  24. sqil_core/resonator/_resonator.py +256 -74
  25. sqil_core/utils/__init__.py +36 -13
  26. sqil_core/utils/_analysis.py +123 -0
  27. sqil_core/utils/_const.py +74 -18
  28. sqil_core/utils/_formatter.py +126 -55
  29. sqil_core/utils/_plot.py +272 -6
  30. sqil_core/utils/_read.py +178 -95
  31. sqil_core/utils/_utils.py +147 -0
  32. {sqil_core-0.1.0.dist-info → sqil_core-1.0.0.dist-info}/METADATA +9 -1
  33. sqil_core-1.0.0.dist-info/RECORD +36 -0
  34. {sqil_core-0.1.0.dist-info → sqil_core-1.0.0.dist-info}/WHEEL +1 -1
  35. sqil_core-0.1.0.dist-info/RECORD +0 -19
  36. {sqil_core-0.1.0.dist-info → sqil_core-1.0.0.dist-info}/entry_points.txt +0 -0
sqil_core/fit/_fit.py CHANGED
@@ -1,11 +1,25 @@
1
+ from __future__ import annotations
2
+
1
3
  import warnings
4
+ from typing import Callable
2
5
 
3
6
  import numpy as np
4
- from scipy.optimize import curve_fit, fsolve, least_squares, leastsq
7
+ from scipy.optimize import curve_fit, fsolve, least_squares, leastsq, minimize
5
8
 
6
9
  import sqil_core.fit._models as _models
10
+ from sqil_core.utils._utils import fill_gaps, has_at_least_one, make_iterable
7
11
 
8
12
  from ._core import FitResult, fit_input, fit_output
13
+ from ._guess import (
14
+ decaying_oscillations_bounds,
15
+ decaying_oscillations_guess,
16
+ gaussian_bounds,
17
+ gaussian_guess,
18
+ lorentzian_bounds,
19
+ lorentzian_guess,
20
+ oscillations_bounds,
21
+ oscillations_guess,
22
+ )
9
23
 
10
24
 
11
25
  @fit_input
@@ -14,7 +28,7 @@ def fit_lorentzian(
14
28
  x_data: np.ndarray,
15
29
  y_data: np.ndarray,
16
30
  guess: list = None,
17
- bounds: list[tuple[float]] | tuple = (-np.inf, np.inf),
31
+ bounds: list[tuple[float]] | tuple = None,
18
32
  ) -> FitResult:
19
33
  r"""
20
34
  Fits a Lorentzian function to the provided data. The function estimates the
@@ -58,35 +72,16 @@ def fit_lorentzian(
58
72
  x, y = x_data, y_data
59
73
 
60
74
  # Default intial guess if not provided
61
- if guess is None:
62
- median_y = np.median(y)
63
- max_y, min_y = np.max(y), np.min(y)
64
-
65
- # Determine A, x0, y0 based on peak prominence
66
- if max_y - median_y >= median_y - min_y:
67
- y0 = min_y
68
- idx = np.argmax(y)
69
- A = 1 / (max_y - median_y)
70
- else:
71
- y0 = max_y
72
- idx = np.argmin(y)
73
- A = 1 / (min_y - median_y)
74
-
75
- x0 = x[idx]
76
- half = y0 + A / 2.0
77
- dx = np.abs(np.diff(x[np.argsort(np.abs(y - half))]))
78
- dx_min = np.abs(np.diff(x))
79
- dx = dx[dx >= 2.0 * dx_min]
80
-
81
- fwhm = dx[0] / 2.0 if dx.size else dx_min
82
- guess = [A, x0, fwhm, y0]
75
+ if has_at_least_one(guess, None):
76
+ guess = fill_gaps(guess, lorentzian_guess(x_data, y_data))
83
77
 
84
78
  # Default bounds if not provided
85
79
  if bounds is None:
86
- bounds = (
87
- [-5.0 * np.abs(guess[0]), np.min(x), guess[2] / 100.0, np.min(y)],
88
- [5.0 * np.abs(guess[0]), np.max(x), 10.0 * guess[2], np.max(y)],
89
- )
80
+ bounds = ([None] * len(guess), [None] * len(guess))
81
+ if has_at_least_one(bounds[0], None) or has_at_least_one(bounds[1], None):
82
+ lower, upper = bounds
83
+ lower_guess, upper_guess = lorentzian_bounds(x_data, y_data, guess)
84
+ bounds = (fill_gaps(lower, lower_guess), fill_gaps(upper, upper_guess))
90
85
 
91
86
  res = curve_fit(_models.lorentzian, x, y, p0=guess, bounds=bounds, full_output=True)
92
87
 
@@ -96,13 +91,80 @@ def fit_lorentzian(
96
91
  }
97
92
 
98
93
 
94
+ @fit_input
95
+ @fit_output
96
+ def fit_two_lorentzians_shared_x0(
97
+ x_data_1,
98
+ y_data_1,
99
+ x_data_2,
100
+ y_data_2,
101
+ guess: list = None,
102
+ bounds: list[tuple[float]] | tuple = None,
103
+ ):
104
+ y_all = np.concatenate([y_data_1, y_data_2])
105
+
106
+ if has_at_least_one(guess, None):
107
+ guess_1 = lorentzian_guess(x_data_1, y_data_1)
108
+ guess_2 = lorentzian_guess(x_data_2, y_data_2)
109
+ x01, x02 = guess_1[1], guess_2[1]
110
+ x0 = np.mean([x01, x02])
111
+ guess = fill_gaps(
112
+ guess, np.concatenate([np.delete(guess_1, 1), np.delete(guess_2, 1), [x0]])
113
+ )
114
+
115
+ if bounds == None:
116
+ bounds = [[None] * len(guess), [None] * len(guess)]
117
+ if has_at_least_one(bounds[0], None) or has_at_least_one(bounds[1], None):
118
+ lower, upper = bounds
119
+ lower_guess_1, upper_guess_1 = lorentzian_bounds(x_data_1, y_data_1, guess_1)
120
+ lower_guess_2, upper_guess_2 = lorentzian_bounds(x_data_2, y_data_2, guess_2)
121
+ # Combine bounds for 1 and 2
122
+ lower_guess = np.concatenate(
123
+ [
124
+ np.delete(lower_guess_1, 1),
125
+ np.delete(lower_guess_2, 1),
126
+ [np.min([lower_guess_1, lower_guess_2])],
127
+ ]
128
+ )
129
+ upper_guess = np.concatenate(
130
+ [
131
+ np.delete(upper_guess_1, 1),
132
+ np.delete(upper_guess_2, 1),
133
+ [np.max([upper_guess_1, upper_guess_2])],
134
+ ]
135
+ )
136
+ lower = fill_gaps(lower, lower_guess)
137
+ upper = fill_gaps(upper, upper_guess)
138
+ bounds = (lower, upper)
139
+
140
+ res = curve_fit(
141
+ lambda _, A1, fwhm1, y01, A2, fwhm2, y02, x0: _models.two_lorentzians_shared_x0(
142
+ x_data_1, x_data_2, A1, fwhm1, y01, A2, fwhm2, y02, x0
143
+ ),
144
+ xdata=np.zeros_like(y_all), # dummy x, since x1 and x2 are fixed via closure
145
+ ydata=y_all,
146
+ p0=guess,
147
+ # bounds=bounds,
148
+ full_output=True,
149
+ )
150
+
151
+ return res, {
152
+ "param_names": ["A1", "fwhm1", "y01", "A2", "fwhm2", "y02", "x0"],
153
+ "predict": _models.two_lorentzians_shared_x0,
154
+ "fit_output_vars": {
155
+ "x_data": np.concatenate([x_data_1, x_data_2]),
156
+ "y_data": y_all,
157
+ },
158
+ }
159
+
160
+
99
161
  @fit_input
100
162
  @fit_output
101
163
  def fit_gaussian(
102
164
  x_data: np.ndarray,
103
165
  y_data: np.ndarray,
104
166
  guess: list = None,
105
- bounds: list[tuple[float]] | tuple = (-np.inf, np.inf),
167
+ bounds: list[tuple[float]] | tuple = None,
106
168
  ) -> FitResult:
107
169
  r"""
108
170
  Fits a Gaussian function to the provided data. The function estimates the
@@ -147,36 +209,15 @@ def fit_gaussian(
147
209
  x, y = x_data, y_data
148
210
 
149
211
  # Default initial guess if not provided
150
- if guess is None:
151
- median_y = np.median(y)
152
- max_x, min_x = np.max(x), np.min(x)
153
- max_y, min_y = np.max(y), np.min(y)
154
-
155
- # Determine A, x0, y0 based on peak prominence
156
- if max_y - median_y >= median_y - min_y:
157
- y0 = min_y
158
- idx = np.argmax(y)
159
- A = max_y - median_y
160
- else:
161
- y0 = max_y
162
- idx = np.argmin(y)
163
- A = min_y - median_y
164
-
165
- x0 = x[idx]
166
- half = y0 + A / 2.0
167
- dx = np.abs(np.diff(x[np.argsort(np.abs(y - half))]))
168
- dx_min = np.abs(np.diff(x))
169
- dx = dx[dx >= 2.0 * dx_min]
170
-
171
- sigma = dx[0] / 2.0 if dx.size else dx_min
172
- guess = [A, x0, sigma, y0]
173
-
212
+ if has_at_least_one(guess, None):
213
+ guess = fill_gaps(guess, gaussian_guess(x_data, y_data))
174
214
  # Default bounds if not provided
175
215
  if bounds is None:
176
- bounds = (
177
- [-5.0 * np.abs(guess[0]), np.min(x), guess[2] / 100.0, np.min(y)],
178
- [5.0 * np.abs(guess[0]), np.max(x), 10.0 * guess[2], np.max(y)],
179
- )
216
+ bounds = ([None] * len(guess), [None] * len(guess))
217
+ if has_at_least_one(bounds[0], None) or has_at_least_one(bounds[1], None):
218
+ lower, upper = bounds
219
+ lower_guess, upper_guess = gaussian_bounds(x_data, y_data, guess)
220
+ bounds = (fill_gaps(lower, lower_guess), fill_gaps(upper, upper_guess))
180
221
 
181
222
  res = curve_fit(_models.gaussian, x, y, p0=guess, bounds=bounds, full_output=True)
182
223
 
@@ -191,6 +232,73 @@ def fit_gaussian(
191
232
  }
192
233
 
193
234
 
235
+ @fit_input
236
+ @fit_output
237
+ def fit_two_gaussians_shared_x0(
238
+ x_data_1,
239
+ y_data_1,
240
+ x_data_2,
241
+ y_data_2,
242
+ guess: list = None,
243
+ bounds: list[tuple[float]] | tuple = None,
244
+ ):
245
+ y_all = np.concatenate([y_data_1, y_data_2])
246
+
247
+ if has_at_least_one(guess, None):
248
+ guess_1 = gaussian_guess(x_data_1, y_data_1)
249
+ guess_2 = gaussian_guess(x_data_2, y_data_2)
250
+ x01, x02 = guess_1[1], guess_2[1]
251
+ x0 = np.mean([x01, x02])
252
+ guess = fill_gaps(
253
+ guess, np.concatenate([np.delete(guess_1, 1), np.delete(guess_2, 1), [x0]])
254
+ )
255
+
256
+ if bounds == None:
257
+ bounds = [[None] * len(guess), [None] * len(guess)]
258
+ if has_at_least_one(bounds[0], None) or has_at_least_one(bounds[1], None):
259
+ lower, upper = bounds
260
+ lower_guess_1, upper_guess_1 = gaussian_bounds(x_data_1, y_data_1, guess_1)
261
+ lower_guess_2, upper_guess_2 = gaussian_bounds(x_data_2, y_data_2, guess_2)
262
+ # Combine bounds for 1 and 2
263
+ lower_guess = np.concatenate(
264
+ [
265
+ np.delete(lower_guess_1, 1),
266
+ np.delete(lower_guess_2, 1),
267
+ [np.min([lower_guess_1, lower_guess_2])],
268
+ ]
269
+ )
270
+ upper_guess = np.concatenate(
271
+ [
272
+ np.delete(upper_guess_1, 1),
273
+ np.delete(upper_guess_2, 1),
274
+ [np.max([upper_guess_1, upper_guess_2])],
275
+ ]
276
+ )
277
+ lower = fill_gaps(lower, lower_guess)
278
+ upper = fill_gaps(upper, upper_guess)
279
+ bounds = (lower, upper)
280
+
281
+ res = curve_fit(
282
+ lambda _, A1, fwhm1, y01, A2, fwhm2, y02, x0: _models.two_gaussians_shared_x0(
283
+ x_data_1, x_data_2, A1, fwhm1, y01, A2, fwhm2, y02, x0
284
+ ),
285
+ xdata=np.zeros_like(y_all), # dummy x, since x1 and x2 are fixed via closure
286
+ ydata=y_all,
287
+ p0=guess,
288
+ # bounds=bounds,
289
+ full_output=True,
290
+ )
291
+
292
+ return res, {
293
+ "param_names": ["A1", "fwhm1", "y01", "A2", "fwhm2", "y02", "x0"],
294
+ "predict": _models.two_gaussians_shared_x0,
295
+ "fit_output_vars": {
296
+ "x_data": np.concatenate([x_data_1, x_data_2]),
297
+ "y_data": y_all,
298
+ },
299
+ }
300
+
301
+
194
302
  @fit_input
195
303
  @fit_output
196
304
  def fit_decaying_exp(
@@ -382,9 +490,14 @@ def fit_qubit_relaxation_qp(
382
490
  }
383
491
 
384
492
 
493
+ @fit_input
385
494
  @fit_output
386
495
  def fit_decaying_oscillations(
387
- x_data: np.ndarray, y_data: np.ndarray, num_init: int = 10
496
+ x_data: np.ndarray,
497
+ y_data: np.ndarray,
498
+ guess: list[float] | None = None,
499
+ bounds: list[tuple[float]] | tuple = None,
500
+ num_init: int = 10,
388
501
  ) -> FitResult:
389
502
  r"""
390
503
  Fits a decaying oscillation model to data. The function estimates key features
@@ -398,13 +511,15 @@ def fit_decaying_oscillations(
398
511
  Parameters
399
512
  ----------
400
513
  x_data : np.ndarray
401
- The independent variable (e.g., time) of the data.
402
-
514
+ Independent variable array (e.g., time or frequency).
403
515
  y_data : np.ndarray
404
- The dependent variable (e.g., signal) of the data.
405
-
406
- num_init : int, optional, default=10
407
- The number of initial guesses for the phase to use in the fitting process.
516
+ Dependent variable array representing the measured signal.
517
+ guess : list[float] or None, optional
518
+ Initial parameter estimates [A, tau, y0, phi, T]. Missing values are automatically filled.
519
+ bounds : list[tuple[float]] or tuple, optional
520
+ Lower and upper bounds for parameters during fitting, by default no bounds.
521
+ num_init : int, optional
522
+ Number of phase values to try when guessing, by default 10.
408
523
 
409
524
  Returns
410
525
  -------
@@ -416,31 +531,49 @@ def fit_decaying_oscillations(
416
531
  - A callable `predict` function for generating fitted responses.
417
532
  - A metadata dictionary containing the pi_time and its standard error.
418
533
  """
419
- # Extract key features from the data
420
- min_y, max_y = np.min(y_data), np.max(y_data)
421
- period_guess = 2.0 * np.abs(x_data[np.argmax(y_data)] - x_data[np.argmin(y_data)])
422
- time_span = np.max(x_data) - np.min(x_data)
534
+ # Default intial guess if not provided
535
+ if has_at_least_one(guess, None):
536
+ guess = fill_gaps(guess, decaying_oscillations_guess(x_data, y_data, num_init))
537
+
538
+ # Default bounds if not provided
539
+ if bounds is None:
540
+ bounds = ([None] * len(guess), [None] * len(guess))
541
+ if has_at_least_one(bounds[0], None) or has_at_least_one(bounds[1], None):
542
+ lower, upper = bounds
543
+ lower_guess, upper_guess = decaying_oscillations_bounds(x_data, y_data, guess)
544
+ bounds = (fill_gaps(lower, lower_guess), fill_gaps(upper, upper_guess))
545
+
546
+ A, tau, y0, phi, T = guess
547
+ phi = make_iterable(phi)
548
+ y0 = make_iterable(y0)
423
549
 
424
550
  best_fit = None
425
551
  best_popt = None
552
+ best_nrmse = np.inf
553
+
554
+ @fit_output
555
+ def _curve_fit_osc(x_data, y_data, p0, bounds):
556
+ return curve_fit(
557
+ _models.decaying_oscillations,
558
+ x_data,
559
+ y_data,
560
+ p0,
561
+ bounds=bounds,
562
+ full_output=True,
563
+ )
426
564
 
427
565
  # Try multiple initializations
428
- for phi_guess in np.linspace(0.0, np.pi * period_guess, num_init):
429
- for factor in [y_data[-1], np.mean(y_data)]:
430
- p0 = [y_data[0] - y_data[-1], time_span, factor, phi_guess, period_guess]
566
+ for phi_guess in phi:
567
+ for offset in y0:
568
+ p0 = [A, tau, offset, phi_guess, T]
431
569
 
432
570
  try:
433
571
  with warnings.catch_warnings():
434
572
  warnings.simplefilter("ignore")
435
- fit_output = curve_fit(
436
- _models.decaying_oscillations,
437
- x_data,
438
- y_data,
439
- p0,
440
- full_output=True,
441
- )
442
- popt = fit_output[0]
443
- best_fit, best_popt = fit_output, popt
573
+ fit_res = _curve_fit_osc(x_data, y_data, p0, bounds)
574
+ if fit_res.metrics["nrmse"] < best_nrmse:
575
+ best_fit, best_popt = fit_res.output, fit_res.params
576
+ best_nrmse = fit_res.metrics["nrmse"]
444
577
  except:
445
578
  if best_fit is None:
446
579
 
@@ -452,11 +585,15 @@ def fit_decaying_oscillations(
452
585
  p0,
453
586
  loss="soft_l1",
454
587
  f_scale=0.1,
588
+ bounds=bounds,
455
589
  args=(x_data, y_data),
456
590
  )
457
591
  best_fit, best_popt = result, result.x
458
592
 
459
- # Compute π-time (half-period + phase offset)
593
+ if best_fit is None:
594
+ return None
595
+
596
+ # Compute pi-time (half-period + phase offset)
460
597
  pi_time_raw = 0.5 * best_popt[4] + best_popt[3]
461
598
  while pi_time_raw > 0.75 * np.abs(best_popt[4]):
462
599
  pi_time_raw -= 0.5 * np.abs(best_popt[4])
@@ -482,6 +619,135 @@ def fit_decaying_oscillations(
482
619
  return best_fit, metadata
483
620
 
484
621
 
622
+ @fit_input
623
+ @fit_output
624
+ def fit_oscillations(
625
+ x_data: np.ndarray,
626
+ y_data: np.ndarray,
627
+ guess: list[float] | None = None,
628
+ bounds: list[tuple[float]] | tuple = None,
629
+ num_init: int = 10,
630
+ ) -> FitResult:
631
+ r"""
632
+ Fits an oscillation model to data. The function estimates key features
633
+ like the oscillation period and phase, and tries multiple initial guesses for
634
+ the optimization process.
635
+
636
+ f(x) = A * cos(2π * (x - φ) / T) + y0
637
+
638
+ $$f(x) = A \cos\left( 2\pi \frac{x - \phi}{T} \right) + y_0$$
639
+
640
+ Parameters
641
+ ----------
642
+ x_data : np.ndarray
643
+ Independent variable array (e.g., time or frequency).
644
+ y_data : np.ndarray
645
+ Dependent variable array representing the measured signal.
646
+ guess : list[float] or None, optional
647
+ Initial parameter estimates [A, y0, phi, T]. Missing values are automatically filled.
648
+ bounds : list[tuple[float]] or tuple, optional
649
+ Lower and upper bounds for parameters during fitting, by default no bounds.
650
+ num_init : int, optional
651
+ Number of phase values to try when guessing, by default 10.
652
+
653
+ Returns
654
+ -------
655
+ FitResult
656
+ A `FitResult` object containing:
657
+ - Fitted parameters (`params`).
658
+ - Standard errors (`std_err`).
659
+ - Goodness-of-fit metrics (`rmse`, root mean squared error).
660
+ - A callable `predict` function for generating fitted responses.
661
+ - A metadata dictionary containing the pi_time and its standard error.
662
+ """
663
+ # Default intial guess if not provided
664
+ if has_at_least_one(guess, None):
665
+ guess = fill_gaps(guess, oscillations_guess(x_data, y_data, num_init))
666
+
667
+ # Default bounds if not provided
668
+ if bounds is None:
669
+ bounds = ([None] * len(guess), [None] * len(guess))
670
+ if has_at_least_one(bounds[0], None) or has_at_least_one(bounds[1], None):
671
+ lower, upper = bounds
672
+ lower_guess, upper_guess = oscillations_bounds(x_data, y_data, guess)
673
+ bounds = (fill_gaps(lower, lower_guess), fill_gaps(upper, upper_guess))
674
+
675
+ A, y0, phi, T = guess
676
+ phi = make_iterable(phi)
677
+ y0 = make_iterable(y0)
678
+
679
+ best_fit = None
680
+ best_popt = None
681
+ best_nrmse = np.inf
682
+
683
+ @fit_output
684
+ def _curve_fit_osc(x_data, y_data, p0, bounds):
685
+ return curve_fit(
686
+ _models.oscillations,
687
+ x_data,
688
+ y_data,
689
+ p0,
690
+ bounds=bounds,
691
+ full_output=True,
692
+ )
693
+
694
+ # Try multiple initializations
695
+ for phi_guess in phi:
696
+ for offset in y0:
697
+ p0 = [A, offset, phi_guess, T]
698
+
699
+ try:
700
+ with warnings.catch_warnings():
701
+ warnings.simplefilter("ignore")
702
+ fit_res = _curve_fit_osc(x_data, y_data, p0, bounds)
703
+ if fit_res.metrics["nrmse"] < best_nrmse:
704
+ best_fit, best_popt = fit_res.output, fit_res.params
705
+ best_nrmse = fit_res.metrics["nrmse"]
706
+ except:
707
+ if best_fit is None:
708
+
709
+ def _oscillations_res(p, x, y):
710
+ return _models.oscillations(x, *p) - y
711
+
712
+ result = least_squares(
713
+ _oscillations_res,
714
+ p0,
715
+ loss="soft_l1",
716
+ f_scale=0.1,
717
+ bounds=bounds,
718
+ args=(x_data, y_data),
719
+ )
720
+ best_fit, best_popt = result, result.x
721
+
722
+ if best_fit is None:
723
+ return None
724
+
725
+ # Compute pi-time (half-period + phase offset)
726
+ pi_time_raw = 0.5 * best_popt[3] + best_popt[2]
727
+ while pi_time_raw > 0.75 * np.abs(best_popt[3]):
728
+ pi_time_raw -= 0.5 * np.abs(best_popt[3])
729
+ while pi_time_raw < 0.25 * np.abs(best_popt[3]):
730
+ pi_time_raw += 0.5 * np.abs(best_popt[3])
731
+
732
+ def _get_pi_time_std_err(sqil_dict):
733
+ if sqil_dict["std_err"] is not None:
734
+ phi_err = sqil_dict["std_err"][2]
735
+ T_err = sqil_dict["std_err"][3]
736
+ if np.isfinite(T_err) and np.isfinite(phi_err):
737
+ return np.sqrt((T_err / 2) ** 2 + phi_err**2)
738
+ return np.nan
739
+
740
+ # Metadata dictionary
741
+ metadata = {
742
+ "param_names": ["A", "y0", "phi", "T"],
743
+ "predict": _models.oscillations,
744
+ "pi_time": pi_time_raw,
745
+ "@pi_time_std_err": _get_pi_time_std_err,
746
+ }
747
+
748
+ return best_fit, metadata
749
+
750
+
485
751
  @fit_output
486
752
  def fit_circle_algebraic(x_data: np.ndarray, y_data: np.ndarray) -> FitResult:
487
753
  """Fits a circle in the xy plane and returns the radius and the position of the center.
@@ -780,3 +1046,146 @@ def fit_skewed_lorentzian(x_data: np.ndarray, y_data: np.ndarray):
780
1046
  "param_names": ["A1", "A2", "A3", "A4", "fr", "Q_tot"],
781
1047
  },
782
1048
  )
1049
+
1050
+
1051
+ def transform_data(
1052
+ data: np.ndarray,
1053
+ transform_type: str = "optm",
1054
+ params: list = None,
1055
+ deg: bool = True,
1056
+ inv_transform: bool = False,
1057
+ full_output: bool = False,
1058
+ ) -> (
1059
+ np.ndarray
1060
+ | tuple[np.ndarray, Callable]
1061
+ | tuple[np.ndarray, Callable, list, np.ndarray]
1062
+ ):
1063
+ """
1064
+ Transforms complex-valued data using various transformation methods, including
1065
+ optimization-based alignment, real/imaginary extraction, amplitude, and phase.
1066
+
1067
+ Parameters
1068
+ ----------
1069
+ data : np.ndarray
1070
+ The complex-valued data to be transformed.
1071
+
1072
+ transform_type : str, optional
1073
+ The type of transformation to apply. Options include:
1074
+ - 'optm' (default): Optimized translation and rotation.
1075
+ - 'trrt': Translation and rotation using provided params.
1076
+ - 'real': Extract the real part.
1077
+ - 'imag': Extract the imaginary part.
1078
+ - 'ampl': Compute the amplitude.
1079
+ - 'angl': Compute the phase (in degrees if `deg=True`).
1080
+
1081
+ params : list, optional
1082
+ Transformation parameters [x0, y0, phi]. If None and `transform_type='optm'`,
1083
+ parameters are estimated automatically.
1084
+
1085
+ deg : bool, optional
1086
+ If True, phase transformations return values in degrees (default: True).
1087
+
1088
+ inv_transform : bool, optional
1089
+ If true returns transformed data and the function to perform the inverse transform.
1090
+
1091
+ full_output : bool, optional
1092
+ If True, returns transformed data, the function to perform the inverse transform,
1093
+ transformation parameters, and residuals.
1094
+
1095
+ Returns
1096
+ -------
1097
+ np.ndarray
1098
+ The transformed data.
1099
+
1100
+ tuple[np.ndarray, list, np.ndarray] (if `full_output=True`)
1101
+ Transformed data, transformation parameters, and residuals.
1102
+
1103
+ Notes
1104
+ -----
1105
+ - The function applies different transformations based on `transform_type`.
1106
+ - If `optm` is selected and `params` is not provided, an optimization routine
1107
+ is used to determine the best transformation parameters.
1108
+
1109
+ Example
1110
+ -------
1111
+ >>> data = np.array([1 + 1j, 2 + 2j, 3 + 3j])
1112
+ >>> transformed, params, residuals = transform_data(data, full_output=True)
1113
+ >>> print(transformed, params, residuals)
1114
+ """
1115
+
1116
+ def transform(data, x0, y0, phi):
1117
+ return (data - x0 - 1.0j * y0) * np.exp(1.0j * phi)
1118
+
1119
+ def _inv_transform(data, x0, y0, phi):
1120
+ return data * np.exp(-1.0j * phi) + x0 + 1.0j * y0
1121
+
1122
+ def opt_transform(data):
1123
+ """Finds optimal transformation parameters."""
1124
+
1125
+ def transform_err(x):
1126
+ return np.sum((transform(data, x[0], x[1], x[2]).imag) ** 2)
1127
+
1128
+ res = minimize(
1129
+ fun=transform_err,
1130
+ method="Nelder-Mead",
1131
+ x0=[
1132
+ np.mean(data.real),
1133
+ np.mean(data.imag),
1134
+ -np.arctan2(np.std(data.imag), np.std(data.real)),
1135
+ ],
1136
+ options={"maxiter": 1000},
1137
+ )
1138
+
1139
+ params = res.x
1140
+ transformed_data = transform(data, *params)
1141
+ if transformed_data[0] < transformed_data[-1]:
1142
+ params[2] += np.pi
1143
+ return params
1144
+
1145
+ # Normalize transform_type
1146
+ transform_type = str(transform_type).lower()
1147
+ if transform_type.startswith(("op", "pr")):
1148
+ transform_type = "optm"
1149
+ elif transform_type.startswith("translation+rotation"):
1150
+ transform_type = "trrt"
1151
+ elif transform_type.startswith(("re", "qu")):
1152
+ transform_type = "real"
1153
+ elif transform_type.startswith(("im", "in")):
1154
+ transform_type = "imag"
1155
+ elif transform_type.startswith("am"):
1156
+ transform_type = "ampl"
1157
+ elif transform_type.startswith(("ph", "an")):
1158
+ transform_type = "angl"
1159
+
1160
+ # Compute parameters if needed
1161
+ if transform_type == "optm" and params is None:
1162
+ params = opt_transform(data)
1163
+
1164
+ # Apply transformation
1165
+ if transform_type in ["optm", "trrt"]:
1166
+ transformed_data = transform(data, *params).real
1167
+ residual = transform(data, *params).imag
1168
+ elif transform_type == "real":
1169
+ transformed_data = data.real
1170
+ residual = data.imag
1171
+ elif transform_type == "imag":
1172
+ transformed_data = data.imag
1173
+ residual = data.real
1174
+ elif transform_type == "ampl":
1175
+ transformed_data = np.abs(data)
1176
+ residual = np.unwrap(np.angle(data))
1177
+ if deg:
1178
+ residual = np.degrees(residual)
1179
+ elif transform_type == "angl":
1180
+ transformed_data = np.unwrap(np.angle(data))
1181
+ residual = np.abs(data)
1182
+ if deg:
1183
+ transformed_data = np.degrees(transformed_data)
1184
+
1185
+ inv_transform_fun = lambda data: _inv_transform(data, *params)
1186
+
1187
+ if full_output:
1188
+ return np.array(transformed_data), inv_transform_fun, params, residual
1189
+ if inv_transform:
1190
+ return np.array(transformed_data), inv_transform_fun
1191
+ return np.array(transformed_data)