Source code for microcalorimetry.analysis._calc_eta

from rmellipse.propagators import RMEProp
from rmellipse.uobjects import RMEMeas

# local packages
from microcalorimetry.math import rfpower, numbers, fitting, rmemeas_extras
from microcalorimetry._helpers._collections import try_sel
from pathlib import Path
import matplotlib.patches as mpatches
import microcalorimetry.configs as configs
import microcalorimetry._gwex as _gwex
import xarray as xr
import numpy as np
import matplotlib.pyplot as plt
import itertools
import microcalorimetry.arrays as arrays

__all__ = [
    'make_eta',
    'review_eta',
    'make_eta_repeatability_model',
    'make_classical_eta_unc_model',
    'dc_lead_correction',
    'apply_uncertainty_model',
]


[docs] def review_eta( eta: configs.EtaLike, historical_data: configs.EtaHistorical = None, repeatability_model: configs.EtaLike = None, k: int = 2, frequency_precision: int = 4, max_hist_legend: int = None, hist_avg: bool = True, ) -> tuple[plt.Figure, plt.Figure]: """ Make review plots of effective efficiency data. Parameters ---------- eta : configs.EtaLike Effective efficiency being reviewed. historical_data : configs.EtaHistorical Historical data to review against. repeatability_model : configs.EtaLike Model of calorimeter's repeatability to plot. k : int, optional Expansion factor, by default 2 frequency_precision : int, optional Precision (decimal places) to round off frequencies to before averaging and comparing measurements. max_hist_legend : int, optional Maxmium number of historical measurmeents to include in the legend. If None, will include all. Many historical measurements will result in large legends. hist_avg : bool, optional If True, include the average of historical measurements in the plot. Returns ------- fig_hist: Figure of effectice efficiency measurement plotted against reference data provided. fig_budget: Figure of uncertainty budget for effective efficiency measurement. """ # get historical data if historical_data is None: historical_data = {} historical_data = configs.EtaHistorical(historical_data) nominals = historical_data.load_nominals() # average frequency points for each measurement # after rounding off frequency values. nominals = { k: numbers.mean_unique_values( v.assign_coords(frequency=np.round(v.frequency, frequency_precision)), dim='frequency', ) for k, v in nominals.items() } # i don't want to deal with this one anymore # so throw it awway del historical_data eta = configs.EtaLike(eta).load() nom = eta.nom new_fgrid = nom.frequency lb = eta.uncbounds(k=-k).cov ub = eta.uncbounds(k=k).cov cmap = plt.cm.winter ydiff_scale = 100 fig, ax = plt.subplots(2, 1, sharex=True) handles = [] labels = [] ref = nom.copy() # plot the new data uncertainties behind everything ax[0].fill_between(new_fgrid, lb[..., 0], ub[..., 0], color='k', alpha=0.2) new_err = ax[1].fill_between( new_fgrid, (lb[..., 0] - nom[:, 0]) * ydiff_scale, (ub[..., 0] - nom[:, 0]) * ydiff_scale, color='k', alpha=0.2, ) (nom_line,) = ax[0].plot( new_fgrid, nom, 'k-', marker='o', label='New Nominal', zorder=1000 ) handles.append((new_err, nom_line)) labels.append(r'$\eta^{\mathrm{\left(new\right)}}$' + f' (k={k})') ax[1].plot( new_fgrid, (nom - ref.sel(frequency=nom.frequency)) * ydiff_scale, 'k', lw=3, zorder=-900, ) # plot average of reference data with std if len(nominals) > 0: average_hist = numbers.greedy_average(*list(nominals.values())) else: average_hist = eta.nom use_freqs = np.intersect1d(average_hist.frequency, ref.frequency) if hist_avg: (hist_avg_line,) = ax[1].plot( use_freqs, (average_hist.sel(frequency=use_freqs) - ref.sel(frequency=use_freqs)) * ydiff_scale, 'r-', zorder=1000, ) handles.append(hist_avg_line) labels.append(f'Average of History') # plot repeatability model centered around # the new measurement if repeatability_model is not None: center = nom - ref.sel(frequency=nom.frequency) repmodel = configs.EtaLike(repeatability_model).load() ub_rep = center + repmodel.stdunc(k=k).cov[:, 0].interp(frequency=nom.frequency) lb_rep = center + repmodel.stdunc(k=-k).cov[:, 0].interp( frequency=nom.frequency ) (rep_line,) = ax[1].plot( ub_rep.frequency, ub_rep * ydiff_scale, '-.', label='Repeatability Model', zorder=1000, color='orange', ) ax[1].plot( lb_rep.frequency, lb_rep * ydiff_scale, '-.', zorder=900, color='orange', ) handles.append(rep_line) labels.append(f'Repeatability Model (k=2)') for a in ax: a.set_prop_cycle(plt.cycler('color', cmap(np.linspace(0, 1, len(nominals))))) marker = itertools.cycle( ('.', 'o', 'v', '^', 'p', '*', 'h', '<', '>', '1', '2', '3', '4', '8', 's') ) rev_nominals = [(name, datamodel) for name, datamodel in nominals.items()][::-1] for i, (name, datamodel) in enumerate(rev_nominals): hdat = nominals[name] # plot differences m = next(marker) ax[0].plot(hdat.frequency, hdat, m, label=name) use_freqs = np.intersect1d(hdat.frequency, ref.frequency) (this_line,) = ax[1].plot( use_freqs, (hdat.sel(frequency=use_freqs) - ref.sel(frequency=use_freqs)) * ydiff_scale, m, label=name, ) if not max_hist_legend: handles.append(this_line) labels.append(name.replace('_', ' ').title()) else: if i < max_hist_legend - 1: handles.append(this_line) labels.append(name.replace('_', ' ').title()) if max_hist_legend: # add ... empty_handle = mpatches.Patch(color='none', label='asdas') handles.append(empty_handle) labels.append('...') # last item handles.append(this_line) labels.append(name.replace('_', ' ').title()) ax[0].set_ylabel(r'$\eta$') ax[1].set_ylabel( r'$\left[\eta-\eta^{\mathrm{\left(new\right)}}\right]\cdot' + f'{ydiff_scale}$' ) ax[1].set_xlabel('Frequency (GHz)') fig.tight_layout() fig.subplots_adjust(right=0.7) fig.legend( handles, labels, loc='upper left', ncol=1, bbox_to_anchor=(0.7, 1.0), bbox_transform=fig.transFigure, ) fig_hist = fig # make the uncertainty budget fig_budget, ax_budget = plt.subplots(1, 1) utot = eta.stdunc(k=1).cov ax_budget.plot(eta.nom.frequency, utot * 100, 'k', lw=2, label='Total (k=1)') ax_budget.set_xlabel('Frequency (GHz)') ax_budget.set_ylabel(r'Uncertainty in $\eta$ (%)') try: eta2 = rmemeas_extras.categorize_by(eta, 'Origin') except KeyError: eta2 = eta for ploc in eta2.umech_id: unc = eta2.usel(umech_id=str(ploc)).stdunc()[0] ax_budget.plot(eta.nom.frequency, unc * 100, '--', lw=2, label=ploc) box = ax_budget.get_position() ax_budget.set_position([box.x0, box.y0, box.width * 0.7, box.height]) ax_budget.legend(loc='center left', bbox_to_anchor=(1, 0.5)) return fig_hist, fig_budget
[docs] def make_classical_eta_unc_model( frequency: np.array, model: configs.PythonFunction, u_type: str, correlate_frequencies: bool = True, make_plots: bool = True, ) -> tuple[RMEMeas[arrays.Eta], plt.Figure]: """ Generate a classical uncertainty model for an eta measurement. uA_model and uB_model are python functions that take in a frequency list and output a standard uncertainty (Type A and B respectively). This model can be used to apply uncertainties to an eta calculate after it has been calculated. Is is zero nominal, so uncertainties are added to an eta measurement by simply adding it to to an effective efficiency measurement. Uncertainties are assumed to be independent across frequency. Parameters ---------- frequency : np.array Frequency in GHz. model : configs.PythonFunction Python function that outputs type B uncertainty. u_type : str Type of uncertainty (A or B) correlate_frequencies : bool, optional Treat uncertainties as correlated if True. The default is True. make_plots : bool, optional If True, make plots. Returns ------- eta_unc : RMEMeas[arrays.Eta] Eta configuration object with zero nominal and uncertainties derived from uA_model and uB_model. fig : plt.Figure | None Matplotlib figure object generated. """ model = configs.PythonFunction(model) u = model(frequency) # make an array of zeros to be the nominal data = xr.DataArray( np.zeros(u.shape), dims=('frequency',), coords={'frequency': frequency} ).expand_dims({'eta': [0]}, axis=-1) data = _gwex.as_format(data, _gwex.eff) # turn into an RMEMeas object with a nominal zero data = RMEMeas.from_nom(f'eta_uncertainty', data) if correlate_frequencies: pert = data.nom.copy() pert[:, 0] += u data.add_umech( model.name, pert, category={'Type': u_type, 'Origin': model.name}, add_uid=True, ) else: for i, f in enumerate(frequency): u_pert = data.nom.copy() u_pert.loc[{'frequency': f}] += u[i] data.add_umech( f'u{u_type}_{f}', u_pert, category={'Type': u_type, 'Origin': model.name}, add_uid=True, ) # add uncertainties fig = None if make_plots: fig, ax = plt.subplots(1, 1) grouped = rmemeas_extras.categorize_by(data, 'Type') for u in grouped.umech_id: unc = grouped.usel(umech_id=[u]).stdunc().cov ax.plot(frequency, unc, label=f'u{u}') ax.plot(frequency, data.stdunc().cov, label='UTot', color='k') ax.set_xlabel('Frequency (GHz)') ax.set_ylabel(r'Uncertainty in $\eta$ (k=1)') ax.legend(loc='best') fig.tight_layout() return data, fig
[docs] def make_eta_repeatability_model( historical_data: dict[configs.EtaHistorical], make_plots: bool = True, dist: str = 'gaussian', coverage: float = 2, ddof: int = 1, min_points: int = 3, model_type: str = 'expanding_quadratic', categories: dict = {'Type': 'A', 'Origin': 'Repeatability'}, umech_id_basename: str = 'Repeatability', frequencies: np.array = None, ) -> tuple[RMEMeas[arrays.Eta], list[plt.Figure], RMEMeas]: """ Generate a historical repeatability model from sensor data. Parameters ---------- historical_data : dict[configs.EtaHistorical] List of EtaHistorical configuration objects for different sensors. The key of each entry is the name of a sensor, and the values are the EtaHistorical objects. make_plots : bool, optional Generate figures if True. dist : str, optional Assume a type of distribution. Options are 'guassian' or 'uniform'. By default 'gaussian'. coverage : int, optional Attempts to have 1 std uncertainty meet this coverage factor. The default is 2. ddof : int, optional Delta degrees of freedom when calculating standard uncertainty frequency by frequency, only relevant for gaussian distributions. min_points : int, optional Minimum number of required samples per frequency point to be included as part of the fit. The default is 3. model_type : str, optional, {'expanding_quadratic','expanding_quadratic_linear','quadratic','constant','line'} Type of repeatability model to use. Expanding models are a constant value up to a cut off frequency, at which point they transition to a increasing function. One of 'expanding_quadratic','expanding_quadratic_linear','quadratic','constant','line'. categories: dict, optional Create category metadata for uncertainy mechnisms. The default is {'Type': 'A', 'Origin':'Repeatability'}. umech_id_basename: str, optional Basename for the umech_id generated. frequencies: np.array, optional If provided, will apply the repeatability model to the provided frequencies. Returns ------- hist_model : RMEMeas RMEMeas object of eff centered at 0 with uncertainties describing historical repeatability. Can be added to an effective efficiency measurement to add repeatability uncertainties. fig : plt.Figure Figure reporting the model. None if no figure coeffs : RMEMeas Fit coefficients generated from the repeatability model. """ # for each sensor, generate a def markers(): out = itertools.cycle( ('.', 'o', 'v', '^', 'p', '*', 'h', '<', '>', '1', '2', '3', '4', '8', 's') ) return out colors = plt.cm.viridis(np.linspace(0, 1, len(historical_data))) colors = itertools.cycle(colors) prp = RMEProp(sensitivity=True) fig = None if make_plots: fig, ax = plt.subplots(1, 1, sharex=True) # values that will be averaged in the end zero_averaged_sensors = {} for i, (sensor_name, sensor_data) in enumerate(historical_data.items()): historical = configs.EtaHistorical(sensor_data) sensor_noms = {k: configs.EtaLike(v).load().nom for k, v in historical.items()} sensor_avg = numbers.greedy_average(*list(sensor_noms.values())) this_sensor_zero = { k: v - sensor_avg.sel(frequency=v.frequency) for k, v in sensor_noms.items() } zero_averaged_sensors |= this_sensor_zero if make_plots: marker_cycle = markers() color = next(colors) for k in sensor_noms: marker = next(marker_cycle) nom = sensor_noms[k] znom = zero_averaged_sensors[k] # drop points = zero, they would be the only point znom = znom.where(znom != 0, drop=True) # included points line = ax.plot( znom.frequency, znom * 100, marker=marker, color=color, ls='', ) keys = list(this_sensor_zero.keys()) line[0].set_label(sensor_name) valid_dists = ['gaussian', 'uniform'] if dist not in valid_dists: raise ValueError(f'Expect dist to be one of {valid_dists} not {dist}') # calculate the uncertainty estimate frequency by frequency # assumes a gaussian distribution if dist == 'gaussian': u = coverage * numbers.greedy_std( *list(zero_averaged_sensors.values()), ddof=ddof, min_points=min_points ) u = u.where(u != 0, drop=True).dropna(dim='frequency') # assumes a uniform distribution elif dist == 'uniform': u = xr.concat(list(zero_averaged_sensors.values()), dim='new dim', join='inner') u = u.where(u != 0, drop=True).dropna(dim='frequency') diff = u.max(dim='new dim') - u.min(dim='new dim') u = diff / np.sqrt(12) * coverage else: raise ValueError(f'Expect dist to be one of {valid_dists} not {dist}') def expanding_quadratic(freq, fstop, quadratic, offset): out = np.zeros(freq.shape) out[freq <= fstop] = offset out[freq > fstop] = quadratic * (freq[freq > fstop] - fstop) ** 2 + offset # print(out) return out def expanding_linear(freq, fstop, slope, offset): out = np.zeros(freq.shape) out[freq <= fstop] = offset out[freq > fstop] = slope * (freq[freq > fstop] - fstop) + offset # print(out) return out def quadratic(freq, offset, quadratic): out = offset + quadratic * freq**2 # print(out) return out def constant(freq, offset): return offset def line(freq, offset, slope): return offset + slope * freq fit_funs = [expanding_quadratic, expanding_linear, quadratic, constant, line] fit_funs = {f.__name__: f for f in fit_funs} spline_funs = ['makima'] if frequencies is None: freqs = u.frequency.data else: freqs = frequencies caught = None if model_type in fit_funs: try: fit = u[:, 0].curvefit('frequency', fit_funs[model_type]) u_fit_data = fit_funs[model_type](freqs, *fit.curvefit_coefficients.data) u_fit = u.copy().interp( frequency=freqs, kwargs=dict(fill_value='extrapolate') ) u_fit[:, 0] = u_fit_data print(fit.curvefit_coefficients) coeffs = fit.curvefit_coefficients except Exception as e: caught = e print('Fit failed for - {e}') elif model_type in spline_funs: u_fit = u.interp(frequency=freqs, method=model_type) coeffs = None else: raise ValueError( f'model_type {model_type} not recognized. Must be one of {[k for k in fit_funs] + spline_funs}' ) # coerce into the expected structure of an eta file model = (u_fit[:, 0] * 0).expand_dims('eta', axis=-1) model = RMEMeas.from_nom('repeatability_model', model) model.add_umech( umech_id_basename, u_fit, dof=len(zero_averaged_sensors), add_uid=True, category=categories, ) pretty_model_name = model_type.replace('_', ' ').title() ub = model.stdunc().cov[..., 0] ax.plot( u.frequency, u * -100, color='red', ) ax.plot( u.frequency, u * 100, color='red', label=f'${coverage}\sigma$ of {dist.title()}' ) ax.fill_between( model.nom.frequency, ub * 2 * 100, ub * -2 * 100, color='k', alpha=0.2, label=f'{pretty_model_name} Model (k=2)', zorder=1000, ) h, l = ax.get_legend_handles_labels() ax.legend(loc='upper left') ax.set_xlabel('Frequency (GHz)') ax.set_ylabel(r'$\left[\eta - \bar{\eta}_{\mathrm{sensor}}\right]\cdot100$') fig.tight_layout() if caught is not None: raise caught from caught return model, fig, coeffs
[docs] def dc_lead_correction( eta: configs.EtaLike, R_lead: float, R_bolo: float, R_lead_min_coverage: float = 0.14, ) -> tuple[RMEMeas[arrays.Eta]]: """ Applies a dc lead correction. Corrects for heat lost between the dc connector and the 4-wire connection of a resistor inside the sensor. See ERROR IN CALORIMETRIC EFFECTIVE EFFICIENCY MEASUREMENTS DUE TO DC LOSSES, T.P Crowley and B.F. Riddle for more details. Parameters ---------- eta : configs.EtaLike Path to an effective efficiency measurement to apply the correction to. R_lead : float Sum of resistance for Force and Sense leads of sensor, positive and negative sides. R_bolo : float Bolometer resistance, usually 200 ohms for bolometer sensors. R_lead_min_coverage : float, optional Uncertainties applied as a result of the dc lead correction will cover a minimun lead resistance of this value. If the lead resistance is greater than this value, then the associated uncertainty will be calculated useing the provided R_lead. The default value is 0.14 Ohms. Returns ------- eta : RMEMeas[arrays.Eta] Corrected eta value """ eta = configs.EtaLike(eta).load() # set up the propagator linear = RMEProp(sensitivity=True) bias_correct = linear.propagate(rfpower.dcbias_eta_correction) corrected = bias_correct(eta, R_lead, R_bolo) use_value = R_lead_min_coverage if R_lead_min_coverage < R_lead: use_value = R_lead uncertainty = use_value / 2 / R_bolo / np.sqrt(3) corrected.add_umech( name='eta_dclead_excess', value=corrected.nom + uncertainty, dof=np.inf, category={ 'Type': 'A', 'Origin': 'DC Lead Resistance', }, # classified as Type A for hisorical reasons add_uid=True, ) return corrected
[docs] def make_eta( gc: configs.GCLike, s11: configs.S11Like, parsed_rfsweep: configs.ParsedRFSweep, clrm_sensitivity: configs.KDCLike = None, repeatability_model: configs.EtaLike = None, extra_eta_uncertainties: list[Path] = None, historical_data: configs.EtaHistorical = None, legacy_uncertainties: bool = False, make_plots: bool = True, ) -> tuple[list[plt.Figure] | None, RMEMeas[arrays.Eta]]: """ Make an effective efficiency measurement. Parameters ---------- gc : configs.GCLike Calorimetric correction factor terms. s11 : configs.S11Like Reflection coefficient of the sensor. parsed_rfsweep : configs.ParsedRFSweep Parsed RF sweep output. clrm_sensitivity : configs.KDCLike, optional If provided, assumes the correction factor is weighted by the calorimeters sensitivity and provide in units of (V/W). These coefficents will be used to calculate the dimensionless correction factor. The default is None. repeatability_model : configs.EtaLike, optional Supply a repeatability model of the microcalorimeter to apply as uncertainty mechanisms and use in review charts. The default is None. extra_eta_uncertainties : list[Path], optional Supply additional uncertainty models of eta that should be applied after the correction. The default is None. historical_data : configs.EtaHistorical, optional Supply a historical Eta configuration object to use a reference measurements in the review charts. The default is None. legacy_uncertainties : bool, optional If legacy uncertainties are selected, than uncertainties will not be carried forward from the supplied reflection coefficient, correction factor, dc measurements, and or sensitivity model. All uncertainties are expected to be supplied via the extra_eta_uncertainties or repeatability_model options. This is to support legacy microcalorimeter systems which do not forward propagate uncertainties from their measurement. The default is False. make_plots : bool, optional Make plots during the analsysis,otherwise figs will be an empty list. The default is True. Plots are made by passing off to the review eta function. Returns ------- fig : List[plt.Figure] | None Any figures generated. eta : RMEMeas[arrays.Eta] Effective efficiency. """ # cast as my special dictionary just to be safe if historical_data is None: historical_data = {} historical_data = configs.EtaHistorical(historical_data) # set up the propagator # if not using legacy uncertainties, then propagate # uncertainties forward. basic = RMEProp(sensitivity=not legacy_uncertainties) effective_efficiency = basic.propagate(rfpower.effective_efficiency) mean_unique = basic.propagate(numbers.mean_unique_values) calc_te_power = basic.propagate(rfpower.openloop_thermoelectric_power) polyval = basic.propagate(fitting.polyval2) # load the models into memory from the containers parsed_rfsweep = configs.ParsedRFSweep(parsed_rfsweep) s11 = configs.S11Like(s11).load() gc = configs.GCLike(gc).load() zeta = configs.RFSweepLike(parsed_rfsweep['zeta']).load() # average repeats for a single connect, zeta repeatability # will be taken care of with historical repeatability # models, and there is unlikely to be enough frequency points # to make a claim about uncertainty at this point. e_on = configs.RFSweepLike(parsed_rfsweep['e_on']).load() e_off = configs.RFSweepLike(parsed_rfsweep['e_off']).load() zeta = mean_unique(zeta, dim='frequency') e_on = mean_unique(e_on, dim='frequency') e_off = mean_unique(e_off, dim='frequency') # select/interp everything down to zeta's grid fgrid = zeta.cov.frequency.data s11 = try_sel(s11, 'S11', fgrid) gc = try_sel(gc, 'gc', fgrid) if clrm_sensitivity: sense = configs.KDCLike(clrm_sensitivity).load() # # Old version from when coefficients were e as function of p estimate coefficients # # if k.attrs['p_of_e']: # k = 1 / k.sel(deg=1, drop=True) # else: # k = k.sel(deg=1, drop=True) # # k = np.abs(k) # p_off_check = calc_te_power( sense, e_off[1], sense.attrs['p_of_e'] ).nom.values.tolist() # determine what e value to use for estimating sensitivity # if the off power estimate > 10 MW, dc substitution mode and # use the on values as the estimate of k if p_off_check > 0.01: e_k_est = e_on # other wise we are in an open loop mode and off values are estimates # of e_0 the time varying offset else: e_k_est = e_on - e_off k = polyval(sense, e_k_est) # k[sensor_type] = clrm_coeffs[sensor_type].sel(deg = 0, drop = True) # if k is units of W/V, then invert it if sense.attrs['p_of_e']: k = 1 / k # print(k) gc = gc / k eta_new = effective_efficiency(zeta, s11, gc) # apply model of statistical repeatability if repeatability_model: eta_new = apply_uncertainty_model( eta_new, repeatability_model, make_unique=True ) if extra_eta_uncertainties: for eu in extra_eta_uncertainties: eta_new = apply_uncertainty_model(eta_new, eu, make_unique=False) # cast as a DataModelContainer and maybe # generate review plots fig = None if make_plots: fig = review_eta( eta_new, historical_data, repeatability_model=repeatability_model ) fig = list(fig) return fig, eta_new
[docs] def apply_uncertainty_model( eta: configs.EtaLike, model: configs.EtaLike, make_unique: bool = False ) -> RMEMeas[arrays.Eta]: """ Applies uncertainty models of effective efficiency measurements. Uncertainties are applied by adding model to eta, assuming model is zero nominal. Parameters ---------- eta : configs.EtaLike Eta that will have unertainties applied. model : configs.EtaLike Model of uncertainties that will be applied to eta. Should be nominaly zero. make_unique : bool, optional If True, renames all uncertainty mechanisms so they are unique and independent from all other uncertainty mechanisms. Should be set to True for repeatability models. Returns ------- eta : RMEMeas[arrays.Eta] Same nominal as the input eta, but with new uncertainties. """ basic = RMEProp(sensitivity=True, set_active=False) @basic.propagate def add(x, y): return x + y eta = configs.EtaLike(eta).load() model = configs.EtaLike(model).load() model = model.interp( frequency=eta.nom.frequency, method='linear', kwargs={'fill_value': 'extrapolate'}, ) if make_unique: model.make_umechs_unique(same_uid=True) # add together eta = add(eta, model) return eta