import os
import re
import lmfit
from typing import Optional, Literal, Any, overload
from numpy import float64, floating, ndarray, ndindex, arange, mean, array, tanh
from resonator import background, base, shunt, reflection
from numpy.typing import ArrayLike, NDArray
from graphinglib import SmartFigure, FitFromFunction, Curve
from warnings import warn
from scipy.constants import hbar, k
from .file_loading import Dataset, File
from .plotting import plot_triptych
from .util import choice
from .typing import _FitResult
def _plot_fit(
result: base.ResonatorFitter,
complex_data: NDArray,
frequency: NDArray,
name: str = "",
savepath: str = "",
nodialog: bool = False,
) -> SmartFigure:
"""
Utilitary function to plot the fit result as a triptych (resonator.see.triptych).
"""
fig = plot_triptych(
frequency,
complex_data,
resonator_fitter=result,
three_ticks=True,
)
filename = os.path.join(savepath, name + ".svg")
if os.path.exists(filename) and not nodialog:
overwrite = choice()
if overwrite:
fig.save(filename)
else:
fig.save(filename)
return fig
def _test_fit(
result: Any,
threshold: Any,
verbose: bool = False,
) -> bool:
"""
Check whether the lmfit result is correct or not. Function credits to Nicolas Bourlet.
"""
tag = True
if not result.errorbars:
tag = False
if verbose:
print("No errorbars were calculated")
return tag
for param in result.params.values():
if threshold * abs(param.value) < abs(param.stderr):
tag = False
if verbose:
print(
"Parameter:{} has its error larger than its value".format(
param.name
)
)
return tag
def _resonator_fitter(
data: NDArray,
freq: NDArray,
power: Optional[float] = None,
params: Optional[lmfit.Parameter] = None,
fit_method: Literal["shunt", "reflection", "kerr_shunt"] = "shunt",
background: base.BackgroundModel = background.MagnitudePhaseDelay(),
) -> tuple[base.ResonatorFitter, ArrayLike]:
"""
Wrapper around resonator library.
"""
if fit_method == "shunt":
result = shunt.LinearShuntFitter(
frequency=freq,
data=data,
params=params,
background_model=background,
)
elif fit_method == "reflection":
result = reflection.LinearReflectionFitter(
frequency=freq,
data=data,
params=params,
background_model=background,
)
elif fit_method == "kerr_shunt":
result = shunt.KerrShuntFitter(
frequency=freq,
data=data,
params=params,
background_model=background,
)
else:
raise NotImplementedError("Fit method unrecognized")
if power is not None:
photon = result.photon_number_from_power(result.f_r, power)
else:
photon = 0
return result, photon
def _file_naming_util(
file: File,
idx: tuple,
frequency: floating,
) -> str:
"""Utilitary to generate file name given the files parameters."""
out = f"{frequency:.0f}GHz_"
for param in file.list_params():
if not param.startswith("s21_") and not param == "VNA Frequency":
attr = param.lower().replace(" ", "_")
value = file.__dict__[attr].range[idx]
unit = file.__dict__[attr].unit
out += f"{attr}{value}{unit}_"
return out
[docs]
class FitResult(_FitResult):
"""
Fit results container for a single file.
Parameters
----------
results : list of resonator.base.ResonatorFitter
List of ResonatorFitter objects from the :class:`Fitter`.
photon_nbr : list of float
List of the computed photon numbers.
magnet_field : list of float, optional
List of magnetic field norm values.
"""
def __init__(
self,
results: list[base.ResonatorFitter],
photon_nbr: list[float],
magnet_field: Optional[list[float]] = None,
) -> None:
if all(isinstance(fitter, base.ResonatorFitter) for fitter in results):
self._results = results
else:
raise TypeError(
"Must provide a list of only resonator.base.ResonatorFitter instances"
)
self._photon_number = photon_nbr
self._magnet_field = magnet_field if magnet_field is not None else []
@property
def photon_nbr(self) -> NDArray:
"""
The computed photon numbers.
"""
return array(self._photon_number)
@property
def magnet_field(self) -> NDArray:
"""
The magnetic field norm in Tesla.
"""
return array(self._magnet_field)
@property
def Q_c(self) -> NDArray:
"""
The coupling quality factors.
"""
return self._get_res("Q_c")
@property
def Q_c_error(self) -> NDArray:
"""
The standard error of the coupling quality factor.
"""
return self._get_res("Q_c_error")
@property
def Q_i(self) -> NDArray:
"""
The internal quality factors.
"""
return self._get_res("Q_i")
@property
def Q_i_error(self) -> NDArray:
"""
The standard error of the internal quality factor.
"""
return self._get_res("Q_i_error")
@property
def Q_t(self) -> NDArray:
"""
The total quality factors.
"""
return self._get_res("Q_t")
@property
def Q_t_error(self) -> NDArray:
"""
The standard error of the total quality factor.
"""
return self._get_res("Q_t_error")
@property
def f_r(self) -> NDArray:
"""
The resonance frequency.
"""
return self._get_res("f_r")
@property
def f_r_error(self) -> NDArray:
"""
The standard error of the resonance frequency.
"""
return self._get_res("f_r_error")
@property
def omega_r(self) -> NDArray:
"""
The resonance angular frequency.
"""
return self._get_res("omega_r")
@property
def omega_r_error(self) -> NDArray:
"""
The standard error of the resonance angular frequency.
"""
return self._get_res("omega_r_error")
@property
def internal_loss(self) -> NDArray:
"""
The internal loss.
"""
return self._get_res("internal_loss")
@property
def internal_loss_error(self) -> NDArray:
"""
The internal loss error.
"""
return self._get_res("internal_loss_error")
@property
def coupling_loss(self) -> NDArray:
"""
The coupling loss.
"""
return self._get_res("coupling_loss")
@property
def coupling_loss_error(self) -> NDArray:
"""
The coupling loss error.
"""
return self._get_res("coupling_loss_error")
def _get_res(self, name: str) -> NDArray:
"""
Gets the requested value from each ResonatorFitter and returns them into a
numpy.array.
"""
out = []
for fitter in self._results:
try:
out.append(fitter.__getattribute__(name))
except AttributeError:
out.append(fitter.__getattr__(name))
return array(out)
[docs]
def append(
self,
results: list[base.ResonatorFitter],
photon_nbr: list[float],
magnet_field: Optional[list[float]] = None,
) -> None:
"""
Append new results to existing FitResult instance.
Parameters
----------
results : list of resonator.base.ResonatorFitter
List of ResonatorFitter objects from the :class:`Fitter`.
photon_nbr : list of float
List of the computed photon numbers.
magnet_field : list of float, optional
List of magnetic field norm values.
"""
if all(isinstance(fitter, base.ResonatorFitter) for fitter in results):
for res in results:
self._results.append(res)
else:
raise TypeError(
"Must provide a list of only resonator.base.ResonatorFitter instances"
)
for pn in photon_nbr:
self._photon_number.append(pn)
if magnet_field:
for m in magnet_field:
self._magnet_field.append(m)
[docs]
class Fitter:
"""
Fitting object. Takes the raw data as input and acts as a container for the
fit results.
.. seealso::
This object is a wrapper of `Daniel Flanigan's resonator
library <https://github.com/danielflanigan/resonator>`_.
Parameters
----------
data : :class:`Dataset` or :class:`File`
Data containing objects.
savepath : str, optional
Where to create the "fit_results" and "fit_images" directories to save
the fit results and images. The default is the current working
directory.
"""
def __init__(
self,
data: Dataset | File,
savepath: Optional[os.PathLike] = None,
) -> None:
self._files: dict[str, File] = {}
self._fit_results: dict[str, FitResult] = {}
if isinstance(data, File):
self._files.update({"0": data})
else:
self._files.update(data.files)
self._savepath = savepath if savepath is not None else os.getcwd()
def _assert_all_files_same_shape(self) -> bool:
"""Utilitary method to check all files have the same data shape."""
if len(self._files) == 1:
return True
else:
first_shape = self._files["0"].shape
return all(file.shape == first_shape for file in self._files.values())
[docs]
def fit(
self,
files: list[int] = [],
background: base.BackgroundModel = background.MagnitudePhaseDelay(),
fit_method: Literal["shunt", "reflection", "kerr_shunt"] = "shunt",
threshold: float | NDArray[float64] = 0.5,
trim_start: int = 0,
trim_jump: int = 10,
save_fig: bool = False,
overwrite_warn: bool = False,
) -> None:
r"""
Fitting specified resonator data.
Parameters
----------
files : list of int, optional
List of indices of files for which to fit the data. Defaults to ``[]``
which fits data for all files.
background : resonator.base.BackgroundModel, optional
Background model from the resonator library.
The default is ``background.MagnitudePhaseDelay()``.
fit_method : str, optional
Fitting method, either ``"shunt"``, ``"reflection"`` or ``"kerr"``.
Defaults to ``"shunt"``.
write : bool, optional
If ``True``, saves the fit results in a .txt file in the "fit_results"
folder in the directory specified by ``savepath``. The default is ``False``.
threshold : float, optional
A value greater than 0 which determine the error tolerance on fit values.
The default is ``0.5`` (50%).
trim_start : int, optional
Where to start trimming the data. The default is ``0``.
trim_jump : int, optional
Step between the trimming of the data to fit better. The default is ``10``.
save_fig : bool, optional
If ``True``, saves the fit plots generated by the function in the "images"
folder in the directory specified by ``savepath``. The default is ``False``.
overwrite_warn : bool, optional
If ``True``, does not display the overwriting warning popup.
The default is ``False``.
Notes
-----
In the **shunt** mode, the resonance is fitted using
.. math:: S_{21}(f) = 1-\frac{\frac{Q}{Q_c}}{1+2iQ\frac{f-f_0}{f_0}}
In the **reflection** mode, the resonance is fitted using
.. math:: S_{21}(f) = -1+\frac{\frac{Q}{Q_c}}{1+2iQ\frac{f-f_0}{f_0}}
The fit itself is performed by the lmfit library.
The `fit` method uses an auxilary method `_test_fit` to verify the maximum error tolerance
on the fit results is respected. If not, the data is trimmed by a specified amount on both
sides and the fits is tried again.
.. seealso:: See the `lmfit library <https://lmfit.github.io/lmfit-py/>`_.
.. note::
Once the data has been fitted using :py:meth:`fit <ResonatorFitter.fit>`
method, the fit result figures (triptychs) are saved in the ``_fit_figures``
dictionnary of the `ResonatorFitter` instance.
"""
# Verify the saving path exist
if save_fig and not os.path.exists(
os.path.join(self._savepath, "results", "fit_images")
):
os.makedirs(os.path.join(self._savepath, "results", "fit_images"))
if not files:
files = list(map(int, self._files.keys()))
# Verify the shape of the threshold matches the shape of the files' data
if isinstance(threshold, ndarray):
if self._assert_all_files_same_shape():
dim = self._files["0"].shape[:-1]
if dim != threshold.shape:
raise ValueError(
"Shape of array-like threshold must match data "
+ f"shape, in this case {dim}"
)
else:
raise ValueError(
"All files must be of the same shape to provide a threshold"
+ "array"
)
fail_count = 0
for file in files:
if str(file) in self._fit_results.keys():
continue
file_obj = self._files[str(file)]
frequency = file_obj.vna_frequency.range
temp = []
temp_photon = []
temp_magnet = []
for idx in ndindex(file_obj.shape[:-1]):
real = file_obj.s21_real.range[idx]
imag = file_obj.s21_imag.range[idx]
complex = real + 1j * imag
input_power = (
file_obj.vna_power.range[idx] + file_obj.cryostat_attenuation
)
if "Variable Attenuator" in file_obj.list_params():
input_power -= file_obj.variable_attenuator.range[idx]
succeeded = False
for ti in arange(trim_start, len(frequency) // 2, trim_jump):
if ti == 0:
complex_trim = complex
frequency_trim = frequency
else:
complex_trim = complex[ti:-ti]
frequency_trim = frequency[ti:-ti]
try:
fitter, photon = _resonator_fitter(
complex_trim,
frequency_trim,
power=input_power,
background=background,
fit_method=fit_method,
)
except ValueError:
continue
if _test_fit(
fitter.result,
verbose=False,
threshold=threshold[idx]
if isinstance(threshold, ndarray)
else threshold,
):
succeeded = True
temp.append(fitter)
temp_photon.append(photon)
if "Magnet" in file_obj.list_params():
temp_magnet.append(file_obj.magnet.range[idx])
if save_fig:
_ = _plot_fit(
fitter,
complex_trim,
frequency_trim,
name=_file_naming_util(
self._files[str(file)], idx, mean(frequency)
),
savepath=os.path.join(
self._savepath, "results", "fit_images"
),
nodialog=not overwrite_warn,
)
break
if not succeeded:
fail_count += 1
if str(file) in self._fit_results.keys():
self._fit_results[str(file)].append(temp, temp_photon, temp_magnet)
else:
self._fit_results.update(
{str(file): FitResult(temp, temp_photon, temp_magnet)}
)
print("{} fit failures".format(fail_count))
def __getattribute__(self, name: str) -> FitResult | Any:
if not name.startswith("__") and re.fullmatch(r"f\d+", name):
warn("Use the indexing syntax instead", DeprecationWarning, stacklevel=2)
try:
return self._fit_results[name.removeprefix("f")]
except KeyError:
raise IndexError(f"No fit result with index {name.removeprefix('f')}")
return super().__getattribute__(name)
@overload
def __getitem__(self, index: int) -> FitResult: ...
@overload
def __getitem__(self, index: slice) -> list[FitResult]: ...
[docs]
def __getitem__(self, index: int | slice) -> FitResult | list[FitResult]:
"""
Returns the FitResult(s) with the given index (or indices).
Parameters
----------
index : int or slice
The index or indices (as a slice) of FitResults to get from this Fitter.
If the start or stop of the slice are left empty, will return all FitResults
with indices inside the given bounds. This means for returning all FitResults,
use slice ``[:]``.
Returns
-------
FitResults or list of FitResults
If a single index is specified, a single FitResult is returned. A list otherwise.
"""
total_files = len(self._files.keys())
if isinstance(index, int):
if not -total_files <= index <= total_files - 1:
raise IndexError(
"index {} out of bounds for number of files {}".format(
index, total_files
)
)
try:
if index < 0:
index = total_files + index
return self._fit_results[str(index)]
except KeyError:
raise IndexError("no results for file with index {}".format(index))
if isinstance(index, slice):
if index.start and not -total_files <= index.start <= total_files - 1:
raise IndexError(
"index [{}, {}] out of bounds for number of files {}".format(
index.start, index.stop, total_files
)
)
if index.stop and not -total_files <= index.stop <= total_files - 1:
raise IndexError(
"index [{}, {}] out of bounds for number of files {}".format(
index.start, index.stop, total_files
)
)
idx_list = list(range(*index.indices(total_files)))
if index.start is None or index.stop is None:
idx_list = set(idx_list).intersection(
map(int, list(self._fit_results.keys()))
)
try:
return [self._fit_results[str(i)] for i in idx_list]
except KeyError as e:
raise IndexError("no results for file with index {}".format(e.args[0]))
raise TypeError("indices must be int or slice, not {}".format(type(index)))
def _qtls_model(
self,
n: NDArray,
Fdelta: float,
omega: float,
T: float,
nc: float,
beta: float,
delta_other: float,
) -> NDArray:
"""
Internal losses model delta_i = delta_TLS + delta_other.
S.Ganjam *et al.*, Nature Communications **15**, 3687 (2024).
"""
return (
Fdelta * tanh(hbar * omega / (2 * k * T)) / (1 + n / nc) ** beta
+ delta_other
)
[docs]
def fit_qtls_model(self, files: list[int] = []) -> list[FitFromFunction]:
r"""
Fits the internal losses :math:`\delta_i` to the theoretical model for TLS losses :math:`\delta_\mathrm{TLS}`
and other power-independent losses :math:`\delta_\mathrm{other}`.
.. attention:: This method cannot be called prior to calling :py:meth:`~ooragan.Fitter.fit`.
Parameters
----------
files : list of int, optional
List of file indices to fit. Defaults to ``[]``, which fits all files.
Returns
-------
list of FitFromFunction
List of :class:`FitFromFunction <graphinglib.FitFromFunction>` instances. The fitted parameters can be extracted with the ``parameters`` attribute.
Notes
-----
The fitting model is the following:
.. math:: \delta_i (\tilde n) = F \delta_\mathrm{TLS}^0 \frac{\mathrm{tanh}\left(\frac{\hbar\omega}{2k_B T}\right)}{\left(1+\frac{\tilde n}{n_c}\right)^\beta}+\delta_\mathrm{other}
S.Ganjam *et al.*, Nature Communications **15**, 3687 (2024).
"""
if not files:
files = list(map(int, self._files.keys()))
res = []
for file in files:
if str(file) not in self._fit_results.keys():
raise RuntimeWarning(
"Cannot fit for TLS losses before fitting raw data. Skipping."
)
continue
fit_result = self._fit_results[str(file)]
delta_i_curve = Curve(fit_result.photon_nbr, fit_result.internal_loss)
fit = FitFromFunction(self._qtls_model, delta_i_curve)
res.append(fit)
return res