Source code for edges.cal.calobs

"""
The main user-facing module of ``edges-cal``.

This module contains wrappers around lower-level functions in other modules, providing
a one-stop interface for everything related to calibration.
"""

import copy
from typing import Any, Self

import attrs
import numpy as np
from astropy import units as un
from astropy.convolution import Gaussian1DKernel, convolve

from .. import types as tp
from ..cached_property import cached_property, safe_property
from ..io import calobsdef, calobsdef3
from ..io.serialization import hickleable
from . import loss
from . import noise_waves as nw
from . import sparams as sp
from .calibrator import Calibrator
from .input_sources import InputSource
from .loss import LossFunctionGivenSparams


[docs] @hickleable @attrs.define(slots=False, kw_only=True, frozen=True) class CalibrationObservation: """ An object representing a full Calibration Observation. Parameters ---------- loads Dictionary of load names mapping to :class:`InputSource` objects. receiver The reflection coefficient of the receiver. """ loads: dict[str, InputSource] = attrs.field() receiver: sp.ReflectionCoefficient = attrs.field( validator=attrs.validators.instance_of(sp.ReflectionCoefficient) ) _raw_receiver: sp.ReflectionCoefficient | None = attrs.field( default=None, validator=attrs.validators.optional( attrs.validators.instance_of(sp.ReflectionCoefficient) ), ) @property def ambient(self) -> InputSource: """The ambient load.""" return self.loads["ambient"] @property def hot_load(self) -> InputSource: """The hot load.""" return self.loads["hot_load"] @property def open(self) -> InputSource: """The open load.""" return self.loads["open"] @property def short(self) -> InputSource: """The short load.""" return self.loads["short"]
[docs] @classmethod def from_edges2_caldef( cls, caldef: calobsdef.CalObsDefEDGES2, *, freq_bin_size: int = 1, spectrum_kwargs: dict[str, dict[str, Any]] | None = None, s11_kwargs: dict[str, dict[str, Any]] | None = None, internal_calkit: sp.Calkit | None = None, external_calkit_internal_switch: sp.Calkit | None = None, f_low: tp.FreqType = 40.0 * un.MHz, f_high: tp.FreqType = np.inf * un.MHz, receiver_kwargs: dict[str, Any] | None = None, restrict_s11_model_freqs: bool = True, loss_models: dict[str, callable] | None = None, loss_model_params: sp.S11ModelParams | None = None, internal_switch_temperature: tp.TemperatureType | None = None, internal_switch_model_params: sp.S11ModelParams | None = sp.internal_switch_model_params(), ) -> Self: """Create the object from an edges-io observation. Parameters ---------- caldef A calibration definition object from which all the data can be read. freq_bin_size The size of each frequency bin (of the spectra) in units of the raw size. spectrum_kwargs Keyword arguments used to instantiate the calibrator :class:`LoadSpectrum` objects. See its documentation for relevant parameters. Parameters specified here are used for _all_ calibrator sources. s11_kwargs Keyword arguments used to instantiate the calibrator :class:`LoadS11` objects. See its documentation for relevant parameters. Parameters specified here are used for _all_ calibrator sources. internal_switch_kwargs Keyword arguments used to instantiate the :class:`~s11.SParams` objects. See its documentation for relevant parameters. The same internal switch is used to calibrate the S11 for each input source. f_low : float Minimum frequency to keep for all loads (and their S11's). If for some reason different frequency bounds are desired per-load, one can pass in full load objects through ``load_spectra``. f_high : float Maximum frequency to keep for all loads (and their S11's). If for some reason different frequency bounds are desired per-load, one can pass in full load objects through ``load_spectra``. sources A sequence of strings specifying which loads to actually use in the calibration. Default is all four standard calibrators. receiver_kwargs Keyword arguments used to instantiate the calibrator :class:`~s11.Receiver` objects. See its documentation for relevant parameters. ``lna_kwargs`` is a deprecated alias. restrict_s11_model_freqs Whether to restrict the S11 modelling (i.e. smoothing) to the given freq range. The final output will be calibrated only between the given freq range, but the S11 models themselves can be fit over a broader set of frequencies. """ receiver_kwargs = receiver_kwargs or {} if "calkit" not in receiver_kwargs: receiver_kwargs["calkit"] = sp.get_calkit( sp.AGILENT_85033E, resistance_of_match=caldef.receiver_s11.calkit_match_resistance if hasattr(caldef.receiver_s11, "calkit_match_resistance") else caldef.receiver_s11[0].calkit_match_resistance, ) loss_models = loss_models or {} if "hot_load" not in loss_models and caldef.hot_load.sparams_file is not None: hot_load_cable_sparams = sp.read_semi_rigid_cable_sparams_file( caldef.hot_load.sparams_file, f_low=f_low, f_high=f_high ) loss_models["hot_load"] = LossFunctionGivenSparams(hot_load_cable_sparams) internal_switch = sp.get_internal_switch_from_caldef( caldef, external_calkit=external_calkit_internal_switch, internal_calkit=internal_calkit, measured_temperature=internal_switch_temperature, ) if internal_switch_model_params is not None: internal_switch = internal_switch.smoothed( internal_switch_model_params, freqs=internal_switch.freqs, ) return cls._from_caldef( caldef=caldef, freq_bin_size=freq_bin_size, spectrum_kwargs=spectrum_kwargs, s11_kwargs=s11_kwargs, internal_switch=internal_switch, f_low=f_low, f_high=f_high, receiver_kwargs=receiver_kwargs, restrict_s11_model_freqs=restrict_s11_model_freqs, loss_models=loss_models, loss_model_params=loss_model_params, )
[docs] @classmethod def from_edges3_caldef( cls, caldef: calobsdef3.CalObsDefEDGES3, *, freq_bin_size: int = 1, spectrum_kwargs: dict[str, dict[str, Any]] | None = None, s11_kwargs: dict[str, dict[str, Any]] | None = None, f_low: tp.FreqType = 40.0 * un.MHz, f_high: tp.FreqType = np.inf * un.MHz, receiver_kwargs: dict[str, Any] | None = None, restrict_s11_model_freqs: bool = True, loss_models: dict[str, callable] | None = None, **kwargs, ) -> Self: """Create the object from an edges-io observation. Parameters ---------- io_obj An calibration observation object from which all the data can be read. freq_bin_size The size of each frequency bin (of the spectra) in units of the raw size. spectrum_kwargs Keyword arguments used to instantiate the calibrator :class:`LoadSpectrum` objects. See its documentation for relevant parameters. Parameters specified here are used for _all_ calibrator sources. s11_kwargs Keyword arguments used to instantiate the calibrator :class:`LoadS11` objects. See its documentation for relevant parameters. Parameters specified here are used for _all_ calibrator sources. internal_switch_kwargs Keyword arguments used to instantiate the :class:`~s11.SParams` objects. See its documentation for relevant parameters. The same internal switch is used to calibrate the S11 for each input source. f_low : float Minimum frequency to keep for all loads (and their S11's). If for some reason different frequency bounds are desired per-load, one can pass in full load objects through ``load_spectra``. f_high : float Maximum frequency to keep for all loads (and their S11's). If for some reason different frequency bounds are desired per-load, one can pass in full load objects through ``load_spectra``. sources A sequence of strings specifying which loads to actually use in the calibration. Default is all four standard calibrators. receiver_kwargs Keyword arguments used to instantiate the calibrator :class:`~s11.Receiver` objects. See its documentation for relevant parameters. ``lna_kwargs`` is a deprecated alias. restrict_s11_model_freqs Whether to restrict the S11 modelling (i.e. smoothing) to the given freq range. The final output will be calibrated only between the given freq range, but the S11 models themselves can be fit over a broader set of frequencies. loss_models A dictionary of loss models for each source. If a particular source has no loss its entry can be missing or None. By default, the only source with loss is the hot_load, which uses a 4" cable. """ loss_models = loss_models or {} if "hot_load" not in loss_models: loss_models["hot_load"] = loss.get_cable_loss_model("UT-141C-SP") receiver_kwargs = receiver_kwargs or {} default_rcv_kw = { "calkit": sp.get_calkit( sp.AGILENT_ALAN, resistance_of_match=49.962 * un.Ohm, ), "cable_length": 4.26 * un.imperial.inch, "cable_loss_percent": -91.5 * un.percent, "cable_dielectric_percent": -1.24 * un.percent, } receiver_kwargs = default_rcv_kw | receiver_kwargs return cls._from_caldef( caldef=caldef, freq_bin_size=freq_bin_size, spectrum_kwargs=spectrum_kwargs, s11_kwargs=s11_kwargs, f_low=f_low, f_high=f_high, receiver_kwargs=receiver_kwargs, restrict_s11_model_freqs=restrict_s11_model_freqs, loss_models=loss_models, )
@classmethod def _from_caldef( cls, caldef: calobsdef3.CalObsDefEDGES3 | calobsdef.CalObsDefEDGES2, *, freq_bin_size: int = 1, spectrum_kwargs: dict[str, dict[str, Any]] | None = None, s11_kwargs: dict[str, dict[str, Any]] | None = None, internal_switch: sp.SParams | None = None, f_low: tp.FreqType = 40.0 * un.MHz, f_high: tp.FreqType = np.inf * un.MHz, receiver_kwargs: dict[str, Any] | None = None, restrict_s11_model_freqs: bool = True, loss_models: dict[str, callable] | None = None, loss_model_params: sp.S11ModelParams | None = None, **kwargs, ) -> Self: """Create a CalibrationObservation from a "definition" of all required paths. Parameters ---------- caldef An calibration observation object from which all the data can be read. freq_bin_size The size of each frequency bin (of the spectra) in units of the raw size. spectrum_kwargs Keyword arguments used to instantiate the calibrator :class:`LoadSpectrum` objects. See its documentation for relevant parameters. Parameters specified here are used for _all_ calibrator sources. s11_kwargs Keyword arguments used to instantiate the calibrator :class:`LoadS11` objects. See its documentation for relevant parameters. Parameters specified here are used for _all_ calibrator sources. internal_switch_kwargs Keyword arguments used to instantiate the :class:`~s11.SParams` objects. See its documentation for relevant parameters. The same internal switch is used to calibrate the S11 for each input source. f_low : float Minimum frequency to keep for all loads (and their S11's). If for some reason different frequency bounds are desired per-load, one can pass in full load objects through ``load_spectra``. f_high : float Maximum frequency to keep for all loads (and their S11's). If for some reason different frequency bounds are desired per-load, one can pass in full load objects through ``load_spectra``. sources A sequence of strings specifying which loads to actually use in the calibration. Default is all four standard calibrators. receiver_kwargs Keyword arguments used to instantiate the calibrator :class:`~s11.Receiver` objects. See its documentation for relevant parameters. ``lna_kwargs`` is a deprecated alias. restrict_s11_model_freqs Whether to restrict the S11 modelling (i.e. smoothing) to the given freq range. The final output will be calibrated only between the given freq range, but the S11 models themselves can be fit over a broader set of frequencies. loss_models A dictionary of loss models for each source. If a particular source has no loss its entry can be missing or None. By default, the only source with loss is the hot_load, which uses a 4" cable. """ if f_high < f_low: raise ValueError("f_high must be larger than f_low!") spectrum_kwargs = spectrum_kwargs or {} s11_kwargs = s11_kwargs or {} receiver_kwargs = receiver_kwargs or {} loss_models = loss_models or {} for v in [spectrum_kwargs, s11_kwargs, receiver_kwargs]: assert isinstance(v, dict) f_low = f_low.to("MHz", copy=False) f_high = f_high.to("MHz", copy=False) rcv_model_params = receiver_kwargs.pop( "model_params", sp.receiver_model_params() ) raw_receiver = sp.get_gamma_receiver_from_filespec(caldef, **receiver_kwargs) if "default" not in spectrum_kwargs: spectrum_kwargs["default"] = {} if "freq_bin_size" not in spectrum_kwargs["default"]: spectrum_kwargs["default"]["freq_bin_size"] = freq_bin_size def get_load(name, ambient_temperature=298 * un.K): return InputSource.from_caldef( caldef=caldef, load_name=name, f_low=f_low, f_high=f_high, s11_kwargs=s11_kwargs, spec_kwargs={ **spectrum_kwargs["default"], **spectrum_kwargs.get(name, {}), }, ambient_temperature=ambient_temperature, restrict_s11_freqs=restrict_s11_model_freqs, loss_model=loss_models.get(name, None), loss_model_params=loss_model_params, internal_switch=internal_switch, ) amb = get_load("ambient") loads = {"ambient": amb} loads |= { src: get_load(src, ambient_temperature=amb.temp_ave) for src in ("hot_load", "open", "short") } # Smooth the receiver s11 receiver = raw_receiver.smoothed(rcv_model_params, freqs=amb.freqs) # Smooth the loss models, if necessary: for name, loss_model in loss_models.items(): if ( isinstance(loss_model, LossFunctionGivenSparams) and loss_model.sparams.freqs.size != amb.freqs.size ): loss_models[name] = attrs.evolve( loss_model, sparams=loss_model.sparams.smoothed( params=sp.hot_load_cable_model_params(), freqs=amb.freqs, ), ) return cls( loads=loads, receiver=receiver, raw_receiver=raw_receiver, **kwargs, )
[docs] @cached_property def freqs(self) -> tp.FreqType: """The frequencies at which spectra were measured.""" return self.loads[next(iter(self.loads.keys()))].freqs
@safe_property def load_names(self) -> tuple[str]: """Names of the loads.""" return tuple(self.loads.keys())
[docs] def averaged_spectrum(self, load: InputSource, t_load_ns: float, t_load: float): """Compute a quick guess at the calibrated spectrum of a given load.""" return load.spectrum.q.data.squeeze() * t_load_ns + t_load
[docs] @cached_property def load_s11_models(self) -> dict[str, np.ndarray]: """Dictionary of S11 correction models, one for each source.""" return { name: source.reflection_coefficient.reflection_coefficient for name, source in self.loads.items() }
[docs] @cached_property def source_thermistor_temps(self) -> dict[str, tp.TemperatureType]: """Dictionary of input source thermistor temperatures.""" return {k: source.temp_ave for k, source in self.loads.items()}
def _load_str_to_load(self, load: InputSource | str): if isinstance(load, str): try: load = self.loads[load] except (AttributeError, KeyError) as e: raise AttributeError( f"load must be a Load object or a string (one of {self.load_names})" ) from e else: assert isinstance(load, InputSource), ( f"load must be a Load instance, got the {load} {type(InputSource)}" ) return load
[docs] def get_K( self, ) -> dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: """Get the source-S11-dependent factors of Monsalve (2017) Eq. 7.""" lna_s11 = self.receiver.s11 return { name: nw.get_K(gamma_rec=lna_s11, gamma_ant=gamma_ant) for name, gamma_ant in self.load_s11_models.items() }
[docs] def get_calibration_residuals( self, calibrator: Calibrator ) -> dict[str, tp.FloatArray]: """Get the residuals of calibrated spectra to the known temperatures.""" return { name: calibrator.calibrate_load(load) - load.temp_ave for name, load in self.loads.items() }
[docs] def get_rms(self, calibrator: Calibrator, smooth: int = 4): """Return a dict of RMS values for each source. Parameters ---------- smooth : int The number of bins over which to smooth residuals before taking the RMS. """ resids = self.get_calibration_residuals(calibrator) out = {} for name, res in resids.items(): if smooth > 1: res = convolve(res, Gaussian1DKernel(stddev=smooth), boundary="extend") out[name] = np.sqrt(np.nanmean(res**2)) return out
[docs] def clone(self, **kwargs): """Clone the instance, updating some parameters. Parameters ---------- kwargs : All parameters to be updated. """ return attrs.evolve(self, **kwargs)
@property def receiver_s11(self) -> sp.ReflectionCoefficient: """The S11 of the receiver.""" return self.receiver.s11
[docs] def inject( self, receiver: np.ndarray = None, source_s11s: dict[str, np.ndarray] | None = None, averaged_q: dict[str, np.ndarray] | None = None, thermistor_temp_ave: dict[str, np.ndarray] | None = None, ) -> Self: """Make a new :class:`CalibrationObservation` based on this, with injections. Returns ------- :class:`CalibrationObservation` A new observation object with the injected models. """ self.freqs.to_value("MHz") kw = {} if receiver is not None: receiver = sp.ReflectionCoefficient( reflection_coefficient=receiver, freqs=self.freqs ) kw["receiver"] = receiver if ( source_s11s is not None or averaged_q is not None or thermistor_temp_ave is not None ): newloads = copy.deepcopy(self.loads) # make a copy if source_s11s is not None: for name, s in source_s11s.items(): newloads[name] = attrs.evolve( newloads[name], reflection_coefficient=sp.ReflectionCoefficient( freqs=self.freqs, reflection_coefficient=s ), ) if averaged_q is not None or thermistor_temp_ave is not None: for name, s in averaged_q.items(): newloads[name] = attrs.evolve( newloads[name], spectrum=attrs.evolve( newloads[name].spectrum, q=newloads[name].spectrum.q.update( data=s[None, None, None] ), temp_ave=thermistor_temp_ave.get( name, newloads[name].temp_ave ), ), ) kw["loads"] = newloads return self.clone(**kw)