sqil-core 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqil_core/fit/_fit.py ADDED
@@ -0,0 +1,782 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+ from scipy.optimize import curve_fit, fsolve, least_squares, leastsq
5
+
6
+ import sqil_core.fit._models as _models
7
+
8
+ from ._core import FitResult, fit_input, fit_output
9
+
10
+
11
+ @fit_input
12
+ @fit_output
13
+ def fit_lorentzian(
14
+ x_data: np.ndarray,
15
+ y_data: np.ndarray,
16
+ guess: list = None,
17
+ bounds: list[tuple[float]] | tuple = (-np.inf, np.inf),
18
+ ) -> FitResult:
19
+ r"""
20
+ Fits a Lorentzian function to the provided data. The function estimates the
21
+ amplitude (A), center (x0), full width at half maximum (FWHM), and baseline (y0)
22
+ of the Lorentzian function.
23
+
24
+ L(x) = A * (|FWHM| / 2) / ((x - x0)^2 + (FWHM^2 / 4)) + y0
25
+
26
+ $$L(x) = A \frac{\left| \text{FWHM} \right|}{2} \frac{1}{(x - x_0)^2 + \frac{\text{FWHM}^2}{4}} + y_0$$
27
+
28
+ Parameters
29
+ ----------
30
+ x_data : np.ndarray
31
+ The independent variable (e.g., x values of the data).
32
+
33
+ y_data : np.ndarray
34
+ The dependent variable (e.g., y values of the data).
35
+
36
+ guess : list, optional
37
+ Initial guesses for the fit parameters [A, x0, fwhm, y0]. If not provided,
38
+ defaults are calculated based on the data.
39
+
40
+ bounds : list[tuple[float]], optional
41
+ The bounds for the fit parameters in the format [(min, max), ...].
42
+ If not provided, defaults are calculated.
43
+
44
+ fixed_params : list[int], optional, default: None
45
+ A list of indices representing parameters in the initial guess that should
46
+ remain unchanged during the fitting process.
47
+
48
+ Returns
49
+ -------
50
+ FitResult
51
+ A `FitResult` object containing:
52
+ - Fitted parameters (`params`).
53
+ - Standard errors (`std_err`).
54
+ - Goodness-of-fit metrics (`rmse`, root mean squared error).
55
+ - A callable `predict` function for generating fitted responses.
56
+ """
57
+
58
+ x, y = x_data, y_data
59
+
60
+ # 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]
83
+
84
+ # Default bounds if not provided
85
+ 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
+ )
90
+
91
+ res = curve_fit(_models.lorentzian, x, y, p0=guess, bounds=bounds, full_output=True)
92
+
93
+ return res, {
94
+ "param_names": ["A", "x0", "fwhm", "y0"],
95
+ "predict": _models.lorentzian,
96
+ }
97
+
98
+
99
+ @fit_input
100
+ @fit_output
101
+ def fit_gaussian(
102
+ x_data: np.ndarray,
103
+ y_data: np.ndarray,
104
+ guess: list = None,
105
+ bounds: list[tuple[float]] | tuple = (-np.inf, np.inf),
106
+ ) -> FitResult:
107
+ r"""
108
+ Fits a Gaussian function to the provided data. The function estimates the
109
+ amplitude, mean, standard deviation (sigma), and baseline of the Gaussian
110
+ function, and computes the full width at half maximum (FWHM).
111
+
112
+ G(x) = A / (|σ| * sqrt(2π)) * exp(- (x - x0)^2 / (2σ^2)) + y0
113
+
114
+ $$G(x) = A \frac{1}{\left| \sigma \right| \sqrt{2\pi}} \exp\left( -\frac{(x - x_0)^2}{2\sigma^2} \right) + y_0$$
115
+
116
+ Parameters
117
+ ----------
118
+ x_data : np.ndarray
119
+ The independent variable (e.g., x values of the data).
120
+
121
+ y_data : np.ndarray
122
+ The dependent variable (e.g., y values of the data).
123
+
124
+ guess : list, optional
125
+ Initial guesses for the fit parameters [A, x0, sigma, y0]. If not provided,
126
+ defaults are calculated based on the data.
127
+
128
+ bounds : list[tuple[float]], optional
129
+ The bounds for the fit parameters in the format [(min, max), ...].
130
+ If not provided, defaults are calculated.
131
+
132
+ fixed_params : list[int], optional, default: None
133
+ A list of indices representing parameters in the initial guess that should
134
+ remain unchanged during the fitting process.
135
+
136
+ Returns
137
+ -------
138
+ FitResult
139
+ A `FitResult` object containing:
140
+ - Fitted parameters (`params`).
141
+ - Standard errors (`std_err`).
142
+ - Goodness-of-fit metrics (`rmse`, root mean squared error).
143
+ - A callable `predict` function for generating fitted responses.
144
+ - A metadata dictionary containing the FWHM.
145
+ """
146
+
147
+ x, y = x_data, y_data
148
+
149
+ # 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
+
174
+ # Default bounds if not provided
175
+ 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
+ )
180
+
181
+ res = curve_fit(_models.gaussian, x, y, p0=guess, bounds=bounds, full_output=True)
182
+
183
+ # Compute FWHM from sigma
184
+ _, _, sigma, _ = res[0]
185
+ fwhm = 2 * np.sqrt(2 * np.log(2)) * sigma
186
+
187
+ return res, {
188
+ "param_names": ["A", "x0", "sigma", "y0"],
189
+ "predict": _models.gaussian,
190
+ "fwhm": fwhm,
191
+ }
192
+
193
+
194
+ @fit_input
195
+ @fit_output
196
+ def fit_decaying_exp(
197
+ x_data: np.ndarray,
198
+ y_data: np.ndarray,
199
+ guess: list = None,
200
+ bounds: list[tuple[float]] | tuple = (-np.inf, np.inf),
201
+ ) -> FitResult:
202
+ r"""
203
+ Fits a decaying exponential function to the provided data. The function estimates
204
+ the amplitude (A), decay time constant (tau), and baseline (y0) of the decaying
205
+ exponential function.
206
+
207
+ f(x) = A * exp(-x / τ) + y0
208
+
209
+ $$f(x) = A \exp\left( -\frac{x}{\tau} \right) + y_0$$
210
+
211
+ Parameters
212
+ ----------
213
+ x_data : np.ndarray
214
+ The independent variable (e.g., x values of the data).
215
+
216
+ y_data : np.ndarray
217
+ The dependent variable (e.g., y values of the data).
218
+
219
+ guess : list, optional
220
+ Initial guesses for the fit parameters [A, tau, y0]. If not provided,
221
+ defaults are calculated based on the data.
222
+
223
+ bounds : list[tuple[float]], optional
224
+ The bounds for the fit parameters in the format [(min, max), ...].
225
+ If not provided, defaults are calculated.
226
+
227
+ fixed_params : list[int], optional, default: None
228
+ A list of indices representing parameters in the initial guess that should
229
+ remain unchanged during the fitting process.
230
+
231
+ Returns
232
+ -------
233
+ FitResult
234
+ A `FitResult` object containing:
235
+ - Fitted parameters (`params`).
236
+ - Standard errors (`std_err`).
237
+ - Goodness-of-fit metrics (`rmse`, root mean squared error).
238
+ - A callable `predict` function for generating fitted responses.
239
+ """
240
+ x, y = x_data, y_data
241
+
242
+ # Default initial guess if not provided
243
+ if guess is None:
244
+ max_y = np.max(y)
245
+ min_y = np.min(y)
246
+ half = 0.5 * (max_y + min_y)
247
+
248
+ if y[0] > y[-1]:
249
+ tau0_idx = np.argmax(y < half)
250
+ else:
251
+ tau0_idx = np.argmax(y > half)
252
+
253
+ b0 = x[tau0_idx] if tau0_idx != 0 else 0.5 * (x[0] + x[-1])
254
+ guess = [y[0] - y[-1], b0, y[-1]]
255
+
256
+ # Default bounds if not provided
257
+ if bounds is None:
258
+ span_y = np.max(y) - np.min(y)
259
+ c0_min = np.min(y) - 100.0 * span_y
260
+ c0_max = np.max(y) + 100.0 * span_y
261
+ bounds = (
262
+ [-100.0 * span_y, 0.0, c0_min],
263
+ [100.0 * span_y, 100.0 * (np.max(x) - np.min(x)), c0_max],
264
+ )
265
+
266
+ res = curve_fit(
267
+ _models.decaying_exp, x, y, p0=guess, bounds=bounds, full_output=True
268
+ )
269
+
270
+ return res, {
271
+ "param_names": ["A", "tau", "y0"],
272
+ "predict": _models.decaying_exp,
273
+ }
274
+
275
+
276
+ @fit_input
277
+ @fit_output
278
+ def fit_qubit_relaxation_qp(
279
+ x_data: np.ndarray,
280
+ y_data: np.ndarray,
281
+ guess: list[float] | None = None,
282
+ bounds: list[tuple[float]] | tuple = (-np.inf, np.inf),
283
+ maxfev: int = 10000,
284
+ ftol: float = 1e-11,
285
+ ) -> FitResult:
286
+ r"""
287
+ Fits a qubit relaxation model with quasiparticle (QP) effects using a
288
+ biexponential decay function. The fitting procedure starts with an initial
289
+ guess derived from a single exponential fit.
290
+
291
+ f(x) = A * exp(|nQP| * (exp(-x / T1QP) - 1)) * exp(-x / T1R) + y0
292
+
293
+ $$f(x) = A \exp\left( |\text{n}_{\text{QP}}| \left( \exp\left(-\frac{x}{T_{1QP}}\right)
294
+ - 1 \right) \right) \exp\left(-\frac{x}{T_{1R}}\right) + y_0$$
295
+
296
+ Parameters
297
+ ----------
298
+ x_data : np.ndarray
299
+ Time data points for the relaxation curve.
300
+
301
+ y_data : np.ndarray
302
+ Measured relaxation data.
303
+
304
+ guess : list[float], optional
305
+ Initial parameter guesses. If None, a default guess is computed
306
+ using a single exponential fit.
307
+
308
+ bounds : tuple[list[float], list[float]], optional
309
+ The bounds for the fit parameters in the format [(min, max), ...].
310
+ If None, reasonable bounds based on the initial guess are applied.
311
+
312
+ maxfev : int, optional, default=10000
313
+ Maximum number of function evaluations allowed for the curve fitting.
314
+
315
+ ftol : float, optional, default=1e-11
316
+ Relative tolerance for convergence in the least-squares optimization.
317
+
318
+ fixed_params : list[int], optional, default: None
319
+ A list of indices representing parameters in the initial guess that should
320
+ remain unchanged during the fitting process.
321
+
322
+ Returns
323
+ -------
324
+ FitResult
325
+ A `FitResult` object containing:
326
+ - Fitted parameters (`params`).
327
+ - Standard errors (`std_err`).
328
+ - Goodness-of-fit metrics (`rmse`, root mean squared error).
329
+ - A callable `predict` function for generating fitted responses.
330
+ """
331
+
332
+ # Use a single exponential fit for initial parameter guesses
333
+ from scipy.optimize import curve_fit
334
+
335
+ def single_exp(x, a, tau, c):
336
+ return a * np.exp(-x / tau) + c
337
+
338
+ single_guess = [y_data[0] - y_data[-1], np.mean(x_data), y_data[-1]]
339
+ single_popt, _ = curve_fit(single_exp, x_data, y_data, p0=single_guess)
340
+
341
+ a_guess, T1R_guess, c_guess = single_popt
342
+ T1QP_guess = 0.1 * T1R_guess
343
+ nQP_guess = 1.0
344
+
345
+ # Default initial guess
346
+ if guess is None:
347
+ guess = [a_guess * np.exp(1.0), 2.0 * T1R_guess, c_guess, T1QP_guess, nQP_guess]
348
+
349
+ # Default parameter bounds
350
+ if bounds is None:
351
+ bounds = (
352
+ [
353
+ -20.0 * np.abs(a_guess),
354
+ 1.0e-1 * T1R_guess,
355
+ -10.0 * np.abs(c_guess),
356
+ 1.0e-4 * T1R_guess,
357
+ 0.0,
358
+ ],
359
+ [
360
+ 20.0 * np.abs(a_guess),
361
+ 1.0e3 * T1R_guess,
362
+ 10.0 * np.abs(c_guess),
363
+ 10.0 * T1R_guess,
364
+ 1.0e3,
365
+ ],
366
+ )
367
+
368
+ res = curve_fit(
369
+ _models.qubit_relaxation_qp,
370
+ x_data,
371
+ y_data,
372
+ p0=guess,
373
+ bounds=bounds,
374
+ maxfev=maxfev,
375
+ ftol=ftol,
376
+ full_output=True,
377
+ )
378
+
379
+ return res, {
380
+ "param_names": ["A", "T1R", "y0", "T1QP", "nQP"],
381
+ "predict": _models.qubit_relaxation_qp,
382
+ }
383
+
384
+
385
+ @fit_output
386
+ def fit_decaying_oscillations(
387
+ x_data: np.ndarray, y_data: np.ndarray, num_init: int = 10
388
+ ) -> FitResult:
389
+ r"""
390
+ Fits a decaying oscillation model to data. The function estimates key features
391
+ like the oscillation period and phase, and tries multiple initial guesses for
392
+ the optimization process.
393
+
394
+ f(x) = A * exp(-x / τ) * cos(2π * (x - φ) / T) + y0
395
+
396
+ $$f(x) = A \exp\left( -\frac{x}{\tau} \right) \cos\left( 2\pi \frac{x - \phi}{T} \right) + y_0$$
397
+
398
+ Parameters
399
+ ----------
400
+ x_data : np.ndarray
401
+ The independent variable (e.g., time) of the data.
402
+
403
+ 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.
408
+
409
+ Returns
410
+ -------
411
+ FitResult
412
+ A `FitResult` object containing:
413
+ - Fitted parameters (`params`).
414
+ - Standard errors (`std_err`).
415
+ - Goodness-of-fit metrics (`rmse`, root mean squared error).
416
+ - A callable `predict` function for generating fitted responses.
417
+ - A metadata dictionary containing the pi_time and its standard error.
418
+ """
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)
423
+
424
+ best_fit = None
425
+ best_popt = None
426
+
427
+ # 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]
431
+
432
+ try:
433
+ with warnings.catch_warnings():
434
+ 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
444
+ except:
445
+ if best_fit is None:
446
+
447
+ def _decaying_osc_res(p, x, y):
448
+ return _models.decaying_oscillations(x, *p) - y
449
+
450
+ result = least_squares(
451
+ _decaying_osc_res,
452
+ p0,
453
+ loss="soft_l1",
454
+ f_scale=0.1,
455
+ args=(x_data, y_data),
456
+ )
457
+ best_fit, best_popt = result, result.x
458
+
459
+ # Compute π-time (half-period + phase offset)
460
+ pi_time_raw = 0.5 * best_popt[4] + best_popt[3]
461
+ while pi_time_raw > 0.75 * np.abs(best_popt[4]):
462
+ pi_time_raw -= 0.5 * np.abs(best_popt[4])
463
+ while pi_time_raw < 0.25 * np.abs(best_popt[4]):
464
+ pi_time_raw += 0.5 * np.abs(best_popt[4])
465
+
466
+ def _get_pi_time_std_err(sqil_dict):
467
+ if sqil_dict["std_err"] is not None:
468
+ phi_err = sqil_dict["std_err"][3]
469
+ T_err = sqil_dict["std_err"][4]
470
+ if np.isfinite(T_err) and np.isfinite(phi_err):
471
+ return np.sqrt((T_err / 2) ** 2 + phi_err**2)
472
+ return np.nan
473
+
474
+ # Metadata dictionary
475
+ metadata = {
476
+ "param_names": ["A", "tau", "y0", "phi", "T"],
477
+ "predict": _models.decaying_oscillations,
478
+ "pi_time": pi_time_raw,
479
+ "@pi_time_std_err": _get_pi_time_std_err,
480
+ }
481
+
482
+ return best_fit, metadata
483
+
484
+
485
+ @fit_output
486
+ def fit_circle_algebraic(x_data: np.ndarray, y_data: np.ndarray) -> FitResult:
487
+ """Fits a circle in the xy plane and returns the radius and the position of the center.
488
+
489
+ Reference: https://arxiv.org/abs/1410.3365
490
+ This function uses an algebraic method to fit a circle to the provided data points.
491
+ The algebraic approach is generally faster and more precise than iterative methods,
492
+ but it can be more sensitive to noise in the data.
493
+
494
+ Parameters
495
+ ----------
496
+ x : np.ndarray
497
+ Array of x-coordinates of the data points.
498
+ y : np.ndarray
499
+ Array of y-coordinates of the data points.
500
+
501
+ Returns
502
+ -------
503
+ FitResult
504
+ A `FitResult` object containing:
505
+ - Fitted parameters (`params`).
506
+ - Standard errors (`std_err`).
507
+ - Goodness-of-fit metrics (`rmse`, root mean squared error).
508
+ - A callable `predict` function for generating fitted responses.
509
+
510
+ Examples
511
+ --------
512
+ >>> fit_result = fit_circle_algebraic(x_data, y_data)
513
+ >>> fit_result.summary()
514
+ """
515
+ z_data = x_data + 1j * y_data
516
+
517
+ def calc_moments(z_data):
518
+ xi = z_data.real
519
+ xi_sqr = xi * xi
520
+ yi = z_data.imag
521
+ yi_sqr = yi * yi
522
+ zi = xi_sqr + yi_sqr
523
+ Nd = float(len(xi))
524
+ xi_sum = xi.sum()
525
+ yi_sum = yi.sum()
526
+ zi_sum = zi.sum()
527
+ xiyi_sum = (xi * yi).sum()
528
+ xizi_sum = (xi * zi).sum()
529
+ yizi_sum = (yi * zi).sum()
530
+ return np.array(
531
+ [
532
+ [(zi * zi).sum(), xizi_sum, yizi_sum, zi_sum],
533
+ [xizi_sum, xi_sqr.sum(), xiyi_sum, xi_sum],
534
+ [yizi_sum, xiyi_sum, yi_sqr.sum(), yi_sum],
535
+ [zi_sum, xi_sum, yi_sum, Nd],
536
+ ]
537
+ )
538
+
539
+ M = calc_moments(z_data)
540
+
541
+ a0 = (
542
+ (
543
+ (M[2][0] * M[3][2] - M[2][2] * M[3][0]) * M[1][1]
544
+ - M[1][2] * M[2][0] * M[3][1]
545
+ - M[1][0] * M[2][1] * M[3][2]
546
+ + M[1][0] * M[2][2] * M[3][1]
547
+ + M[1][2] * M[2][1] * M[3][0]
548
+ )
549
+ * M[0][3]
550
+ + (
551
+ M[0][2] * M[2][3] * M[3][0]
552
+ - M[0][2] * M[2][0] * M[3][3]
553
+ + M[0][0] * M[2][2] * M[3][3]
554
+ - M[0][0] * M[2][3] * M[3][2]
555
+ )
556
+ * M[1][1]
557
+ + (
558
+ M[0][1] * M[1][3] * M[3][0]
559
+ - M[0][1] * M[1][0] * M[3][3]
560
+ - M[0][0] * M[1][3] * M[3][1]
561
+ )
562
+ * M[2][2]
563
+ + (-M[0][1] * M[1][2] * M[2][3] - M[0][2] * M[1][3] * M[2][1]) * M[3][0]
564
+ + (
565
+ (M[2][3] * M[3][1] - M[2][1] * M[3][3]) * M[1][2]
566
+ + M[2][1] * M[3][2] * M[1][3]
567
+ )
568
+ * M[0][0]
569
+ + (
570
+ M[1][0] * M[2][3] * M[3][2]
571
+ + M[2][0] * (M[1][2] * M[3][3] - M[1][3] * M[3][2])
572
+ )
573
+ * M[0][1]
574
+ + (
575
+ (M[2][1] * M[3][3] - M[2][3] * M[3][1]) * M[1][0]
576
+ + M[1][3] * M[2][0] * M[3][1]
577
+ )
578
+ * M[0][2]
579
+ )
580
+ a1 = (
581
+ (
582
+ (M[3][0] - 2.0 * M[2][2]) * M[1][1]
583
+ - M[1][0] * M[3][1]
584
+ + M[2][2] * M[3][0]
585
+ + 2.0 * M[1][2] * M[2][1]
586
+ - M[2][0] * M[3][2]
587
+ )
588
+ * M[0][3]
589
+ + (
590
+ 2.0 * M[2][0] * M[3][2]
591
+ - M[0][0] * M[3][3]
592
+ - 2.0 * M[2][2] * M[3][0]
593
+ + 2.0 * M[0][2] * M[2][3]
594
+ )
595
+ * M[1][1]
596
+ + (-M[0][0] * M[3][3] + 2.0 * M[0][1] * M[1][3] + 2.0 * M[1][0] * M[3][1])
597
+ * M[2][2]
598
+ + (-M[0][1] * M[1][3] + 2.0 * M[1][2] * M[2][1] - M[0][2] * M[2][3]) * M[3][0]
599
+ + (M[1][3] * M[3][1] + M[2][3] * M[3][2]) * M[0][0]
600
+ + (M[1][0] * M[3][3] - 2.0 * M[1][2] * M[2][3]) * M[0][1]
601
+ + (M[2][0] * M[3][3] - 2.0 * M[1][3] * M[2][1]) * M[0][2]
602
+ - 2.0 * M[1][2] * M[2][0] * M[3][1]
603
+ - 2.0 * M[1][0] * M[2][1] * M[3][2]
604
+ )
605
+ a2 = (
606
+ (2.0 * M[1][1] - M[3][0] + 2.0 * M[2][2]) * M[0][3]
607
+ + (2.0 * M[3][0] - 4.0 * M[2][2]) * M[1][1]
608
+ - 2.0 * M[2][0] * M[3][2]
609
+ + 2.0 * M[2][2] * M[3][0]
610
+ + M[0][0] * M[3][3]
611
+ + 4.0 * M[1][2] * M[2][1]
612
+ - 2.0 * M[0][1] * M[1][3]
613
+ - 2.0 * M[1][0] * M[3][1]
614
+ - 2.0 * M[0][2] * M[2][3]
615
+ )
616
+ a3 = -2.0 * M[3][0] + 4.0 * M[1][1] + 4.0 * M[2][2] - 2.0 * M[0][3]
617
+ a4 = -4.0
618
+
619
+ def func(x):
620
+ return a0 + a1 * x + a2 * x * x + a3 * x * x * x + a4 * x * x * x * x
621
+
622
+ def d_func(x):
623
+ return a1 + 2 * a2 * x + 3 * a3 * x * x + 4 * a4 * x * x * x
624
+
625
+ x0 = fsolve(func, 0.0, fprime=d_func)
626
+
627
+ def solve_eq_sys(val, M):
628
+ # prepare
629
+ M[3][0] = M[3][0] + 2 * val
630
+ M[0][3] = M[0][3] + 2 * val
631
+ M[1][1] = M[1][1] - val
632
+ M[2][2] = M[2][2] - val
633
+ return np.linalg.svd(M)
634
+
635
+ U, s, Vt = solve_eq_sys(x0[0], M)
636
+
637
+ A_vec = Vt[np.argmin(s), :]
638
+
639
+ xc = -A_vec[1] / (2.0 * A_vec[0])
640
+ yc = -A_vec[2] / (2.0 * A_vec[0])
641
+ # the term *sqrt term corrects for the constraint, because it may be altered due to numerical inaccuracies during calculation
642
+ r0 = (
643
+ 1.0
644
+ / (2.0 * np.absolute(A_vec[0]))
645
+ * np.sqrt(A_vec[1] * A_vec[1] + A_vec[2] * A_vec[2] - 4.0 * A_vec[0] * A_vec[3])
646
+ )
647
+
648
+ std_err = _compute_circle_fit_errors(x_data, y_data, xc, yc, r0)
649
+ return {
650
+ "params": [xc, yc, r0],
651
+ "std_err": std_err,
652
+ "metrics": _compute_circle_fit_metrics(x_data, y_data, xc, yc, r0),
653
+ "predict": lambda theta: (xc + r0 * np.cos(theta), yc + r0 * np.sin(theta)),
654
+ "output": {},
655
+ "param_names": ["xc", "yc", "r0"],
656
+ }
657
+
658
+
659
+ def _compute_circle_fit_errors(x, y, xc, yc, r0):
660
+ """Compute the standard errors for the algebraic circle fit"""
661
+ # Residuals: distance from each point to the fitted circle
662
+ distances = np.sqrt((x - xc) ** 2 + (y - yc) ** 2)
663
+ residuals = distances - r0
664
+
665
+ # Estimate variance of the residuals
666
+ dof = len(x) - 3 # Degrees of freedom: N - number of parameters
667
+ variance = np.sum(residuals**2) / dof
668
+
669
+ # Jacobian matrix of residuals with respect to (xc, yc, r0)
670
+ J = np.zeros((len(x), 3))
671
+ J[:, 0] = (xc - x) / distances # ∂residual/∂xc
672
+ J[:, 1] = (yc - y) / distances # ∂residual/∂yc
673
+ J[:, 2] = -1 # ∂residual/∂r0
674
+
675
+ # Covariance matrix approximation: variance * (JᵗJ)⁻¹
676
+ JTJ_inv = np.linalg.inv(J.T @ J)
677
+ pcov = variance * JTJ_inv
678
+
679
+ # Standard errors are the square roots of the diagonal of the covariance matrix
680
+ standard_errors = np.sqrt(np.diag(pcov))
681
+
682
+ return standard_errors
683
+
684
+
685
+ def _compute_circle_fit_metrics(x_data, y_data, xc, yc, r0):
686
+ """Computed metrics for the algebraic circle fit"""
687
+ # Compute the distance of each data point to the fitted circle center
688
+ r_data = np.sqrt((x_data - xc) ** 2 + (y_data - yc) ** 2)
689
+
690
+ # Compute residuals
691
+ residuals = r_data - r0
692
+
693
+ # Calculate R-squared (R²)
694
+ ssr = np.sum(residuals**2)
695
+ sst = np.sum((r_data - np.mean(r_data)) ** 2)
696
+ r2 = 1 - (ssr / sst) if sst > 0 else 0
697
+
698
+ # Compute RMSE
699
+ rmse = np.sqrt(np.mean(residuals**2))
700
+
701
+ # Return results
702
+ return {"rmse": rmse}
703
+
704
+
705
+ @fit_output
706
+ def fit_skewed_lorentzian(x_data: np.ndarray, y_data: np.ndarray):
707
+ r"""
708
+ Fits a skewed Lorentzian model to the given data using least squares optimization.
709
+
710
+ This function performs a two-step fitting process to find the best-fitting parameters for a skewed Lorentzian model.
711
+ The first fitting step provides initial estimates for the parameters, and the second step refines those estimates
712
+ using a full model fit.
713
+
714
+ L(f) = A1 + A2 * (f - fr) + (A3 + A4 * (f - fr)) / [1 + (2 * Q_tot * ((f / fr) - 1))²]
715
+
716
+ $$L(f) = A_1 + A_2 \cdot (f - f_r)+ \frac{A_3 + A_4 \cdot (f - f_r)}{1
717
+ + 4 Q_{\text{tot}}^2 \left( \frac{f - f_r}{f_r} \right)^2}$$
718
+
719
+ Parameters
720
+ ----------
721
+ x_data : np.ndarray
722
+ A 1D numpy array containing the x data points for the fit.
723
+
724
+ y_data : np.ndarray
725
+ A 1D numpy array containing the y data points for the fit.
726
+
727
+ Returns
728
+ -------
729
+ FitResult
730
+ A `FitResult` object containing:
731
+ - Fitted parameters (`params`).
732
+ - Standard errors (`std_err`).
733
+ - Goodness-of-fit metrics (`red_chi2`).
734
+ - A callable `predict` function for generating fitted responses.
735
+
736
+ Examples
737
+ --------
738
+ >>> fit_result = fit_skewed_lorentzian(x_data, y_data)
739
+ >>> fit_result.summary()
740
+ """
741
+ A1a = np.minimum(y_data[0], y_data[-1])
742
+ A3a = -np.max(y_data)
743
+ fra = x_data[np.argmin(y_data)]
744
+
745
+ # First fit to get initial estimates for the more complex fit
746
+ def residuals(p, x, y):
747
+ A2, A4, Q_tot = p
748
+ err = y - (
749
+ A1a
750
+ + A2 * (x - fra)
751
+ + (A3a + A4 * (x - fra)) / (1.0 + 4.0 * Q_tot**2 * ((x - fra) / fra) ** 2)
752
+ )
753
+ return err
754
+
755
+ p0 = [0.0, 0.0, 1e3]
756
+ p_final, _ = leastsq(residuals, p0, args=(np.array(x_data), np.array(y_data)))
757
+ A2a, A4a, Q_tota = p_final
758
+
759
+ # Full parameter fit
760
+ def residuals2(p, x, y):
761
+ A1, A2, A3, A4, fr, Q_tot = p
762
+ err = y - (
763
+ A1
764
+ + A2 * (x - fr)
765
+ + (A3 + A4 * (x - fr)) / (1.0 + 4.0 * Q_tot**2 * ((x - fr) / fr) ** 2)
766
+ )
767
+ return err
768
+
769
+ p0 = [A1a, A2a, A3a, A4a, fra, Q_tota]
770
+ popt, pcov, infodict, errmsg, ier = leastsq(
771
+ residuals2, p0, args=(np.array(x_data), np.array(y_data)), full_output=True
772
+ )
773
+ # Since Q_tot is always present as a square it may turn out negative
774
+ popt[-1] = np.abs(popt[-1])
775
+
776
+ return (
777
+ (popt, pcov, infodict, errmsg, ier),
778
+ {
779
+ "predict": lambda x: _models.skewed_lorentzian(x, *popt),
780
+ "param_names": ["A1", "A2", "A3", "A4", "fr", "Q_tot"],
781
+ },
782
+ )