Source code for ooragan.ppms_analysis

"""
In order to make OORAGAN lighter, the QD_Data object and necessary functions have been
directly borrowed from the pyHegel library developped by Christian Lupien at 
Université de Sherbrooke.
"""

import time
import glob
import csv
import datetime
import dateutil.tz
import io
import numpy as np
import graphinglib as gl
import os

from numpy import genfromtxt, isnan, where, array
from tabulate import tabulate
from lmfit.models import LinearModel
from loess.loess_1d import loess_1d
from scipy.constants import pi, hbar, k, e, h
from numpy.typing import ArrayLike
from typing import Optional, Literal
from graphinglib import Figure
from seaborn import color_palette


def timestamp_offset(year=None):
    """Returns the timestamp offset to add to the timestamp column
    to obtain a proper time for resistance data
    This offset is wrong when daylight saving is active.
    Because of the way it is created, it is not possible to know exactly
    when the calculation is wrong.
    """
    # MultiVu (dynacool) probably uses GetTickCount data
    # so it is not immediately affected by clock change or daylight savings.
    # but since it lasts at most 49.7 days, it must reset at some point.
    # Multivu itself does not calculate the date correctly (offset by 1) and
    # the time is also eventually wrong after daylight saving.
    # The time might be readjusted after deactivating and reactivating the resistivity option.
    #  Here dynacool multivu seems to decode the timestamp ts as
    #    datetime.timedelta(ts//86400, ts%86400)+datetime.datetime(1999,12,31)
    if year is None:
        year = time.localtime().tm_year
    offset = time.mktime(time.strptime("{}/12/31".format(year - 1), "%Y/%m/%d"))
    return offset


def timestamp_offset_log():
    """Returns the timestamp offset to add to the timestamp column
    to obtain a proper time for log data.
    This offset is wrong when daylight saving is active.
    """
    # The multivu(dynacool) timestamp calculation is wrong
    # It jumps by 3600 s when the daylight savings starts and repeats 3600 of time
    # when it turns off. It probably does time calculations assuming a day has
    # 24*3600 = 86400 s (which is not True when daylight is used).
    # The number is based on local date/time from 1899-12-30 excel, lotus epoch.
    # dynacool multivu seems to be using the following algorithms
    #  timestamp = (datetime.datetime.now()- datetime.datetime(1899,12,30)).total_seconds()
    #    note that that assumes 86400 s per day
    #  for example (datetime.datetime.now()- datetime.datetime.fromtimestamp(0)).total_seconds() can be different from
    #    time.time()
    #  going the other way:
    #    datetime.timedelta(timestamp//86400, timestamp%86400) + datetime.datetime(1899,12,30)
    unix_epoch = datetime.datetime.fromtimestamp(0)  # this returns a local time.
    t0 = datetime.datetime(1899, 12, 30)
    offset = unix_epoch - t0
    return -offset.total_seconds()


def timestamp_log_conv(timestamp):
    """Does a full conversion of all the timestamp data (can be a vector)
    to unix time.
    """
    single = False
    if not isinstance(timestamp, (list, tuple, np.ndarray)):
        single = True
        timestamp = [timestamp]
    timestamp_flat = np.asarray(timestamp).ravel()
    base = datetime.datetime(1899, 12, 30)
    day = 3600 * 24
    ret = np.empty(len(timestamp_flat))
    for i, ts in enumerate(timestamp_flat):
        if np.isnan(ts):
            ret[i] = np.nan
        else:
            dt = datetime.timedelta(ts // day, ts % day) + base
            ret[i] = time.mktime(dt.timetuple()) + dt.microsecond / 1e6
    if single:
        ret = ret[0]
    elif isinstance(timestamp, np.ndarray) and timestamp.ndim > 1:
        ret.shape = timestamp.shape
    return ret


def pick_not_nan(data):
    """For data, provide a single row of data.
    It will return the list of columns where the data is not NaN
    """
    sel = where(isnan(data) == False)[0]
    return sel


def quoted_split(string):
    """Split on , unless quoted with " """
    reader = csv.reader([string])
    return list(reader)[0]


def read_one_ppms_dat(filename, sel_i=0, nbcols=None, encoding="latin1"):
    hdrs = []
    titles = []
    i = 0
    kwargs = {}
    if nbcols is not None:
        kwargs["usecols"] = list(range(nbcols))
        kwargs["invalid_raise"] = False
    with io.open(filename, "r", encoding=encoding) as f:
        while True:
            line = f.readline().rstrip()
            i += 1
            hdrs.append(line)
            if line == "[Data]":
                break
            if i > 40:
                break
        line = f.readline().rstrip()
        i += 1
        hdrs.append(line)
        titles = quoted_split(line)
    titles = np.array(titles)
    v = genfromtxt(
        filename, skip_header=i, delimiter=",", encoding=encoding, **kwargs
    ).T
    if v.ndim == 1:
        # There was only one line:
        v = v[:, np.newaxis]
    if sel_i is None:
        sel = None
    else:
        sel = pick_not_nan(v[:, sel_i])
    return v, titles, hdrs, sel


def _glob(filename):
    if not isinstance(filename, (list, tuple, np.ndarray)):
        filename = [filename]
    filelist = []
    for fglob in filename:
        fl = glob.glob(fglob)
        fl.sort()
        filelist.extend(fl)
    if len(filelist) == 0:
        print("No file found")
        return None, False
    elif len(filelist) > 1:
        print("Found %i files" % len(filelist))
        multi = True
    else:
        multi = False
    return filelist, multi


# Instead of: v2 = genfromtxt('cooldown.dat', skip_header=31, names=None, delimiter=',').T
# could have used: v = loadtxt('cooldown.dat', skiprows=31, delimiter=',', converters={i:(lambda s: float(s.strip() or np.nan)) for i in range(23) }).T
# if the file has 23 columns
# Or if the colums to load is known: v2 = loadtxt('cooldowntransition.dat', skiprows=31, delimiter=',', usecols=sel).T
#  where sel was [1,3,4,5,6,7,8,9,10,11,14,15,16,18,19,20,21]

# To make it look completely like an ndarray,
# see:
#  https://numpy.org/doc/stable/user/basics.dispatch.html
#  https://numpy.org/doc/stable/reference/generated/numpy.lib.mixins.NDArrayOperatorsMixin.html
#  https://numpy.org/doc/stable/user/basics.subclassing.html


class QD_Data(object):
    def __init__(
        self,
        filename_or_data,
        sel_i=0,
        titles=None,
        qd_data=None,
        concat=False,
        nbcols=None,
        timestamp="auto",
        encoding="latin1",
    ):
        """provide either a numpy data array a filename or a list of filenames,
        of a Quantum Design .dat file. The filenames can have glob patterns (*,?).
        When multiple files are provided, either they are concatenated if
        concat is True (last axis) or they are combined in a 3D array
        with the middle dimension the file number, but only if they
        all have the same shape.
        When providing data, you should also provide titles
        sel_i, when not None, will select columns that are not NaN.
         It is only applied on the first file.
        The object returned can be indexed directly,
        you can also use the v attribute which refers the the data
          with selected colunmns or vr which is the raw data.
        The t attribute will be the converted timestamp.
        The trel attribute is the timestamp column minus the first value.
        timestamp is the parameter used for do_timestamp.
        titles is the selected columns,
        titles_raw is the full column names.
        headers is the full headers.
        qd_data when given, will be used for headers, titles, sel_i defaults when
          data is ndarray.
        nbcols when not None, forces to load that particular number of column and
                skip lines without enough elements.
                Use it if you receive ValueError with showing the wrong number of columns.

        Use show_titles to see the selected columns.
        Use do_sel and do_timestamp to change the column selection or the t attribute.
        """
        super(QD_Data, self).__init__()
        if isinstance(filename_or_data, np.ndarray):
            self.filenames = None
            self.vr = filename_or_data
            if qd_data is not None:
                self.filenames = qd_data.filenames
                self.headers = qd_data.headers
                self.headers_all = qd_data.headers_all
                if titles is None:
                    titles = qd_data.titles_raw
                if sel_i is None:
                    sel_i = qd_data._sel
            else:
                self.headers = None
        else:
            filenames, multi = _glob(filename_or_data)
            if filenames is None:
                return
            self.filenames = filenames
            first = True
            hdrs_all = []
            vr_all = []
            for f in filenames:
                v, _titles, hdrs, sel = read_one_ppms_dat(
                    f, sel_i=None, nbcols=nbcols, encoding=encoding
                )
                if titles is None:
                    titles = _titles
                if first:
                    self.headers = hdrs
                    v_first = v
                    first = False
                else:
                    if not concat and v.shape != v_first.shape:
                        raise RuntimeError(
                            "Files don't have the same shape. Maybe use the concat option."
                        )
                    elif concat and v.shape[:-1] != v_first.shape[:-1]:
                        raise RuntimeError(
                            "Files don't have the same number of columns shape."
                        )
                vr_all.append(v)
                hdrs_all.append(hdrs)
                if any(_titles != titles):
                    raise RuntimeError("All files do not have the same titles.")
            self.headers_all = hdrs_all
            if concat:
                v = np.concatenate(vr_all, axis=-1)
            elif not multi:
                v = vr_all[0]
            else:
                v = np.array(vr_all).swapaxes(0, 1).copy()
            self.vr = v
            self.headers = hdrs
        if titles is None:
            self.titles_raw = array(["Col_%i" % i for i in range(self.vr.shape[0])])
        else:
            self.titles_raw = array(titles)
        self._t_cache = None
        self._trel_cache = None
        self._t_conv_auto = timestamp
        self.do_sel(sel_i)

    def do_sel(self, row=0):
        """select columns for v and titles according to row content not being NaN,
        unless it is None.
        When row is a list/tuple/ndarray it will be used to select the columns.
        """
        if self.vr.ndim < 2:
            # No data, disable selection
            row = None
        if row is None:
            self._sel = row
            self.v = self.vr.copy()
            self.titles = self.titles_raw
        elif isinstance(row, slice):
            self._sel = row
            self.v = self.vr[row].copy()
            self.titles = self.titles_raw[row]
        elif isinstance(row, (list, tuple, np.ndarray)):
            self._sel = row
            self.v = self.vr[row]
            self.titles = self.titles_raw[row]
        else:
            vr = self.vr
            if len(vr.shape) == 3:
                vr = vr[:, 0]  # pick first file only.
            sel = pick_not_nan(vr[:, row])
            self._sel = sel
            self.v = self.vr[sel]
            self.titles = self.titles_raw[sel]
        self._t_cache = None
        self._trel_cache = None

    def do_timestamp(self, year="auto"):
        """generates the proper t attribute (and also returns it) from the timestamp data (column 0)
        if year is given or None, the value is used with timestamp_offset.
        if year is 'auto_year', it will try and search the header for a year,
                               if it fails it will use the current year.
        if year is 'auto' (default), it will try either timestamp_offset or timestamp_offset_log
         depending on the value. For timestamp_offset it will behave like 'auto_year'.
        if year is 'log' the timestamp_offset_log is used.
        """
        t = self[0]
        is_log = False
        if year is None:
            offset = timestamp_offset()
        elif year == "log":
            is_log = True
            offset = timestamp_offset_log()
        elif year in ["auto", "auto_year"]:
            # do not use t.min, I have seen missing time datapoints
            #   cause by an empty line in the data logs (wrapped BRlog)
            if year == "auto" and np.nanmin(t) > 10 * 365 * 24 * 3600:
                is_log = True
                offset = timestamp_offset_log()
            else:  # auto_year
                year = None
                for h in self.headers:
                    if h.startswith("FILEOPENTIME"):
                        # looks like: FILEOPENTIME,1636641706.00,11/11/2021,9:41 AM
                        # or for brlog:
                        #  FILEOPENTIME, 3846454070.154991 11/19/2021, 3:27:44 AM
                        year = int(h.split(",")[-2].split("/")[-1])
                        break
                offset = timestamp_offset(year)
        elif 1970 < year:
            offset = timestamp_offset(year)
        else:
            raise ValueError("Invalid parameter for year.")
        if is_log:
            # This is wrong, it does not handle daylight saving correctly
            # tconv = t + timestamp_offset_log()
            # But this work (however it is slower)
            tconv = timestamp_log_conv(t)
        else:
            tconv = t + offset
            # Now try to improve (it will not always be correct) for daylight savings
            lcl = time.localtime(tconv[0])
            if lcl.tm_isdst:
                tz = dateutil.tz.gettz()
                dst_offset = tz.dst(datetime.datetime(*lcl[:6])).total_seconds()
                tconv -= dst_offset
        self._t_cache = tconv
        return self._t_cache

    def show_titles(self, raw=False):
        if raw:
            t = self.titles_raw
        else:
            t = self.titles
        return list(enumerate(t))

    def __getitem__(self, indx):
        return self.v[indx]

    def __setitem__(self, indx, val):
        self.v[indx] = val

    def __iter__(self):
        return iter(self.v)

    # bring in all methods/attributes of ndarray here (except specials functions like __add__
    #  that work differently when used with + operator, those need to be added directly)
    def __getattr__(self, name):
        return getattr(self.v, name)

    @property
    def shape(self):
        return self.v.shape

    @shape.setter
    def shape(self, val):
        self.v.shape = val

    def __add__(self, val):
        """add will concatenate to data sets"""
        if not isinstance(val, QD_Data):
            raise ValueError("Can only add two Qd_Data")
        v = np.concatenate((self.vr, val.vr), axis=-1)
        nd = QD_Data(v, qd_data=self)
        nd.filenames = self.filenames + val.filenames
        nd.headers_all = [self.headers, val.headers]
        return nd

    @property
    def t(self):
        if self._t_cache is None:
            self.do_timestamp(self._t_conv_auto)
        return self._t_cache

    @property
    def trel(self):
        if self._trel_cache is None:
            self._trel_cache = self[0] - self[0, 0]
        return self._trel_cache


[docs] class PPMSAnalysis: """ Loads PPMS data and implements methods to analyse the data. Parameters ---------- path : str Path to the .dat file to analyse. start_temp : float, optional Start temperature of the sweep in Kelvin. Defaults to 18 K. end_temp : float, optional End temperature of the sweep in Kelvin. Defaults to 3 K. temp_tolerance : float, optional Tolerance on temperature precision when trying to find the temperature range. Defaults to 0 K. savepath : str, optional Path where to save the PPMSAnalysis results. Defaults to the current working directory. fname : str, optional File name given to all files saved. Defaults to ``None``. """ def __init__( self, path: str, start_temp: float = 18, end_temp: float = 3, temp_tolerance: float = 0, savepath: Optional[str] = None, fname: Optional[str] = None, ) -> None: self._data = QD_Data(filename_or_data=path) self._temp_dict = {} for title, val in zip(self._data.titles, self._data.v): self._temp_dict[f"{title}"] = val self.time_stamp = self._data.trel self._index = self._separator( temperature=self._temp_dict["Temperature (K)"], start=start_temp, end=end_temp, tolerance=temp_tolerance, ) self._savepath = savepath if savepath is not None else os.getcwd() self._fname = ( fname if fname is not None else datetime.datetime.today().strftime("%Y-%m-%d") ) self.temperature = {} self.resistance = {} self.std_dev = {} self.magnetic_field = {} for sweep, index in self._index.items(): self.temperature[sweep] = self._temp_dict["Temperature (K)"][ index[0] : index[1] ] self.resistance[sweep] = { "bridge1": self._temp_dict["Bridge 1 Resistance (Ohms)"][ index[0] : index[1] ], "bridge2": self._temp_dict["Bridge 2 Resistance (Ohms)"][ index[0] : index[1] ], "bridge3": self._temp_dict["Bridge 3 Resistance (Ohms)"][ index[0] : index[1] ], } self.std_dev[sweep] = { "bridge1": self._temp_dict["Bridge 1 Std. Dev. (Ohm-m)"][ index[0] : index[1] ], "bridge2": self._temp_dict["Bridge 2 Std. Dev. (Ohm-m)"][ index[0] : index[1] ], "bridge3": self._temp_dict["Bridge 3 Std. Dev. (Ohm-m)"][ index[0] : index[1] ], } self.magnetic_field[sweep] = self._temp_dict["Magnetic Field (Oe)"][ index[0] : index[1] ] def _separator( self, temperature: ArrayLike, start: float, end: float, tolerance: float ) -> dict: """ Finds the indices of the different temperature sweeps and stores them in a dictionnary. Parameters ---------- temperature : ArrayLike Array of temperatures from PPMS file. start : int or float, optional Start of temperature sweep. The default is 18. end : int or float, optional End of temperature sweep. The default is 3. tolerance : int or float, optional Tolerance on temperature precision when trying to find the range. The default is 0. """ index_dict = {} index_lst = [] vrai = [] for index, temp in enumerate(temperature): if start - tolerance <= np.round(temp, 1) <= start + tolerance: for index_end, temp_end in enumerate(temperature[index:]): if end - tolerance <= np.round(temp_end, 1) <= end + tolerance: index_lst.append([index, index_end + index]) break for i, item in enumerate(index_lst): if i == 0: vrai.append(item) elif i != 0: if item[0] - index_lst[i - 1][0] < 2: continue else: if item[1] - index_lst[i - 1][1] < 2: continue else: vrai.append(item) index_dict[f"Sweep {len(index_dict) + 1}"] = vrai[-1] return index_dict
[docs] def find_Tc( self, trim_index: Optional[tuple[int]] = None, method: str = "50", print_out: bool = True, save_to_file: bool = False, ) -> dict: """ Finds the critical temperature using specified method. Parameters ---------- trim_index : tuple of int, optional Indices at which to trim the data. Defaults to ``None``. method : str, optional Method of finding the critical temperature. Can be one of {"10", "50", "90", "maxgrad"}. Defaults to ``"50"``. print_out : bool, optional If ``True``, prints the results in a table. Defaults to ``True``. save_to_file : bool, optional If ``True``, saves the result in a txt file. Defaults to ``False``. """ self.Tc = {} for sweep, temp in self.temperature.items(): sweep_temp = {} for bridge, resist in self.resistance[sweep].items(): trim_temp = ( temp[trim_index[0] : trim_index[1]] if trim_index is not None else temp ) trim_resist = ( resist[trim_index[0] : trim_index[1]] if trim_index is not None else resist ) r_sheet = trim_resist[0] if method == "10": idx_tc = (np.abs(trim_resist - r_sheet * 0.1)).argmin() sweep_temp[bridge] = trim_temp[idx_tc] elif method == "50": idx_tc = (np.abs(trim_resist - r_sheet * 0.5)).argmin() sweep_temp[bridge] = trim_temp[idx_tc] elif method == "90": idx_tc = (np.abs(trim_resist - r_sheet * 0.9)).argmin() sweep_temp[bridge] = trim_temp[idx_tc] elif method == "maxgrad": x, y, err = loess_1d(trim_temp, trim_resist, degree=0, frac=0.02) grad = np.gradient(y) xmax = x[np.where(grad == grad.min())][0] sweep_temp[bridge] = xmax else: raise ValueError( f'Method {method} is invalid, choose either "10", "50", "90" or "maxgrad"' ) self.Tc[sweep] = sweep_temp if print_out: b1 = ["Bridge 1"] b2 = ["Bridge 2"] b3 = ["Bridge 3"] head = ["Critical temperature (K)"] for sweep, vals in self.Tc.items(): b1.append(vals["bridge1"]) b2.append(vals["bridge2"]) b3.append(vals["bridge3"]) head.append(f"{int(np.mean(self.magnetic_field[sweep])/1e4)} T") print(tabulate([b1, b2, b3], headers=head)) if save_to_file: if not os.path.exists(os.path.join(self._savepath, "ppms_analysis")): os.mkdir(os.path.join(self._savepath, "ppms_analysis")) b1 = [val["bridge1"] for _, val in self.Tc.items()] b2 = [val["bridge2"] for _, val in self.Tc.items()] b3 = [val["bridge3"] for _, val in self.Tc.items()] arr = np.array([b1, b2, b3]) header = [ f"{int(np.mean(self.magnetic_field[sweep]) / 1e4)}T" for sweep in self.magnetic_field.keys() ] np.savetxt( os.path.join( self._savepath, "ppms_analysis", f"ppms_Tc_{self._fname}.txt" ), arr, header="\t".join(header), delimiter="\t", ) return self.Tc
[docs] def calculate_Lk( self, squares: Optional[float] = None, units: Optional[Literal["pH", "nH"]] = None, trim_index: Optional[tuple] = None, print_out: bool = True, save_to_file: bool = False, ) -> dict: """ Calculates the kinetic inductance from the sheet resistance and critical temperature using BCS theory. Parameters ---------- squares : float, optional Number of squares contained in the measured structure. If left ``None``, a four-point measurement on a blanket sample is assumed. Defaults to ``None``. units : str, optional Units in which to output the kinetic inductance. Either ``"pH"``, ``"nH"`` or ``None`` for no conversion (given in H). Defaults to ``None``. trim_index : tuple, optional Indices at which to trim the data. Defaults to ``None``. print_out : bool, optional If ``True``, prints the results in a table. Defaults to ``True``. save_to_file : bool, optional If ``True``, saves the result in a txt file. Defaults to ``False``. """ self.Lk = {} for sweep, bridges in self.Tc.items(): Lk_temp = {} for bridge, tc in bridges.items(): if squares is None: r = ( self.resistance[sweep][bridge][0] if trim_index is None else self.resistance[sweep][bridge][trim_index[0]] ) Lk_temp[bridge] = (hbar * pi / np.log(2) * r) / ( pi * 1.764 * k * tc ) else: r = ( self.resistance[sweep][bridge][0] if trim_index is None else self.resistance[sweep][bridge][trim_index[0]] ) Lk_temp[bridge] = (hbar * r / squares) / (1.764 * pi * k * tc) if units is not None: if units == "pH": Lk_temp[bridge] *= 1e12 elif units == "nH": Lk_temp[bridge] *= 1e9 else: raise ValueError('Units can be "pH", "nH" or None') self.Lk[sweep] = Lk_temp if print_out: b1 = ["Bridge 1"] b2 = ["Bridge 2"] b3 = ["Bridge 3"] head = ( [f"Kinetic inductance ({units})"] if units is not None else ["Kinetic inductance (H)"] ) for sweep, vals in self.Lk.items(): b1.append(vals["bridge1"]) b2.append(vals["bridge2"]) b3.append(vals["bridge3"]) head.append(f"{int(np.mean(self.magnetic_field[sweep])/1e4)} T") print(tabulate([b1, b2, b3], headers=head)) if save_to_file: if not os.path.exists(os.path.join(self._savepath, "ppms_analysis")): os.mkdir(os.path.join(self._savepath, "ppms_analysis")) b1 = [val["bridge1"] for _, val in self.Lk.items()] b2 = [val["bridge2"] for _, val in self.Lk.items()] b3 = [val["bridge3"] for _, val in self.Lk.items()] arr = np.array([b1, b2, b3]) header = [ f"{int(np.mean(self.magnetic_field[sweep]) / 1e4)}T" for sweep in self.magnetic_field.keys() ] np.savetxt( os.path.join( self._savepath, "ppms_analysis", f"ppms_Lk_{self._fname}.txt" ), arr, header="\t".join(header), delimiter="\t", ) return self.Lk
[docs] def plot_resist_vs_temp( self, R_unit: Literal["ohm", "kohm", "Mohm"] = "ohm", x_label: Optional[str] = None, y_label: Optional[str] = None, title: Optional[str] = None, size: tuple | Literal["default"] = "default", legend_loc: tuple | str = "best", legend_cols: int = 1, figure_style: str = "default", save: bool = False, image_type: str = "svg", ) -> Figure: """ Plots the resistance as a function of temperature as measured by the PPMS. Parameters ---------- R_unit : {'ohm', 'kohm', 'Mohm'} """ if not os.path.exists(os.path.join(self._savepath, "ppms_analysis")): os.mkdir(os.path.join(self._savepath, "ppms_analysis")) x_label = "Temperature (K)" if x_label is None else x_label y_label = ( f"Resistance ({R_unit})".replace("ohm", r"$\Omega$") if y_label is None else y_label ) figures = [] for bridge in ["bridge1", "bridge2", "bridge3"]: resist = [val[bridge] for _, val in self.resistance.items()] stddev = [val[bridge] for _, val in self.std_dev.items()] figure = gl.Figure( x_label=x_label, y_label=y_label, title=title, size=size, figure_style=figure_style, ) for i, r in enumerate(resist): t = self.temperature[f"Sweep {i+1}"] mag_field_mean = np.mean(self.magnetic_field["Sweep {}".format(i + 1)]) if np.round(mag_field_mean / 10, 1) == 0: label = "0 T" elif mag_field_mean / 1e4 < 0.999: label = "{} mT".format(int(mag_field_mean / 10)) else: label = "{} T".format(np.round(mag_field_mean / 1e4, 1)) if R_unit == "ohm": curve = gl.Curve(t, r, label=label) curve.add_errorbars(y_error=stddev[i]) elif R_unit == "kohm": curve = gl.Curve(t, r / 1e3, label=label) curve.add_errorbars(y_error=stddev[i] / 1e3) elif R_unit == "Mohm": curve = gl.Curve(t, r / 1e6, label=label) curve.add_errorbars(y_error=stddev[i] / 1e6) else: raise ValueError(f"Resistance unit {R_unit} unaccepted") figure.add_elements(curve) figure.set_visual_params( color_cycle=list( color_palette("flare_r", n_colors=len(figure._elements)) ) ) figures.append(figure) if save: figure.save( os.path.join( self._savepath, "ppms_analysis", f"R_vs_T_{self._fname}_{bridge}.{image_type}", ), legend_loc=legend_loc, legend_cols=legend_cols, ) else: figure.show(legend_loc=legend_loc, legend_cols=legend_cols) return figures