Skip to content

furax.interfaces

Modules:

furax.interfaces.lineax

Functions:

as_lineax_operator(operator, tags=OperatorTag.NONE)

Wrap a furax operator for use with lineax solvers.

This function creates a lineax-compatible wrapper that stores the furax operator as a pytree field, ensuring JIT compatibility. Tags from the furax operator are automatically copied.

Parameters:

  • operator (AbstractLinearOperator) –

    A furax AbstractLinearOperator instance

  • tags (OperatorTag, default: NONE ) –

    Extra operator tags to attach to the wrapper, combined with those copied from the furax operator.

Returns:

  • AbstractLinearOperator

    A lineax AbstractLinearOperator that wraps the furax operator

Examples:

>>> import furax as fx
>>> import lineax as lx
>>> import jax.numpy as jnp
>>> op = fx.IdentityOperator(in_structure=fx.tree.as_structure(jnp.ones(10)))
>>> lx_op = fx.as_lineax_operator(op)
>>> solution = lx.linear_solve(lx_op, jnp.ones(10))
Source code in src/furax/interfaces/lineax.py
def as_lineax_operator(
    operator: AbstractLinearOperator, tags: OperatorTag = OperatorTag.NONE
) -> lx.AbstractLinearOperator:
    """Wrap a furax operator for use with lineax solvers.

    This function creates a lineax-compatible wrapper that stores the furax
    operator as a pytree field, ensuring JIT compatibility. Tags from the furax
    operator are automatically copied.

    Args:
        operator: A furax AbstractLinearOperator instance
        tags: Extra operator tags to attach to the wrapper, combined with those copied
            from the furax operator.

    Returns:
        A lineax AbstractLinearOperator that wraps the furax operator

    Examples:
        >>> import furax as fx
        >>> import lineax as lx
        >>> import jax.numpy as jnp
        >>> op = fx.IdentityOperator(in_structure=fx.tree.as_structure(jnp.ones(10)))
        >>> lx_op = fx.as_lineax_operator(op)
        >>> solution = lx.linear_solve(lx_op, jnp.ones(10))
    """
    return _FuraxLinearOperator(
        operator,
        _get_lineax_tags(operator, tags),
    )

furax.interfaces.litebird_sim

Modules:

Classes:

LazyLBSObservation

Bases: FileBackedLazyObservation[Observation]

Attributes:

Source code in src/furax/interfaces/litebird_sim/observation.py
class LazyLBSObservation(FileBackedLazyObservation[lbs.Observation]):
    interface_class = LBSObservation

interface_class = LBSObservation class-attribute instance-attribute

LBSObservation

Bases: AbstractSatelliteObservation[Observation]

Methods:

Attributes:

Source code in src/furax/interfaces/litebird_sim/observation.py
class LBSObservation(AbstractSatelliteObservation[lbs.Observation]):
    @classmethod
    def from_file(
        cls, filename: str | Path, requested_fields: Collection[str] | None = None
    ) -> LBSObservation:
        # check that file exists
        file = Path(filename)
        if not file.exists():
            raise FileNotFoundError(f'File {filename} does not exist')

        # TODO: support requested_fields
        obs = lbs.io.read_one_observation(file)
        if obs is None:
            raise RuntimeError(f'could not read {file} with litebird_sim')
        return cls(obs)

    @property
    def name(self) -> str:
        jdtime_min = round(self.data.start_time.jd * 60)
        return f'obs_{jdtime_min}_lbs'

    @property
    def telescope(self) -> str:
        return 'LB'

    @property
    def n_samples(self) -> int:
        return self.data.n_samples  # type: ignore[no-any-return]

    @property
    def detectors(self) -> list[str]:
        # assumes we have all the detectors
        return [det['name'] for det in self.data.detectors_global]

    @property
    def n_detectors(self) -> int:
        return self.data.n_detectors  # type: ignore[no-any-return]

    @property
    def sample_rate(self) -> float:
        return self.data.sampling_rate_hz  # type: ignore[no-any-return]

    def get_tods(self) -> Float[np.ndarray, 'dets samps']:
        tods = np.asarray(self.data.tod, dtype=np.float64)
        return np.atleast_2d(tods)

    def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
        return np.asarray(self.data.pol_angle_rad, dtype=np.float64)

    def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
        return np.asarray(self.data.get_hwp_angle(), dtype=np.float64)

    def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
        # TODO: take into account global and local flags
        return np.ones((self.n_detectors, self.n_samples), dtype=bool)

    def get_timestamps(self) -> Float[np.ndarray, ' a']:
        return np.asarray(
            self.data.get_times(normalize=False, astropy_times=False), dtype=np.float64
        )

    def get_wcs_shape_and_kernel(
        self,
        resolution_arcmin: float,
        projection: ProjectionType = ProjectionType.CAR,
    ) -> tuple[tuple[int, int], wcs.WCS]:
        raise NotImplementedError

    def get_pointing_and_spin_angles(
        self, landscape: StokesLandscape
    ) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
        if not isinstance(landscape, HealpixLandscape):
            raise RuntimeError('only healpix is supported')
        pointings, _hwp_angles = self.data.get_pointings()
        # pointings have shape (N_det, N_samples, 3)
        theta, phi, psi = np.moveaxis(pointings, -1, 0)
        pixel_indices = landscape.world2index(theta, phi)
        spin_angles = jnp.array(psi, dtype=jnp.float64)
        return pixel_indices, spin_angles

    def get_noise_model(self) -> None | NoiseModel:
        return AtmosphericNoiseModel(
            sigma=jnp.array(self.data.net_ukrts * 1e-6),
            alpha=jnp.array(-self.data.alpha),
            fk=jnp.array(self.data.fknee_mhz * 1e-3),
            f0=jnp.array(self.data.fmin_hz),
        )

    def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
        # TODO: coordinate system
        #
        # interpolate to the actual sampling rate
        qbore = self.data.pointing_provider.bore2ecliptic_quats
        qbore_full = qbore.slerp(self.data.start_time, self.sample_rate, self.n_samples)
        return np.asarray(qbore_full, dtype=np.float64)

    def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
        return np.concatenate([q.quats for q in self.data.quat], dtype=np.float64)

name property

telescope property

n_samples property

detectors property

n_detectors property

sample_rate property

from_file(filename, requested_fields=None) classmethod

Source code in src/furax/interfaces/litebird_sim/observation.py
@classmethod
def from_file(
    cls, filename: str | Path, requested_fields: Collection[str] | None = None
) -> LBSObservation:
    # check that file exists
    file = Path(filename)
    if not file.exists():
        raise FileNotFoundError(f'File {filename} does not exist')

    # TODO: support requested_fields
    obs = lbs.io.read_one_observation(file)
    if obs is None:
        raise RuntimeError(f'could not read {file} with litebird_sim')
    return cls(obs)

get_tods()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_tods(self) -> Float[np.ndarray, 'dets samps']:
    tods = np.asarray(self.data.tod, dtype=np.float64)
    return np.atleast_2d(tods)

get_detector_offset_angles()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
    return np.asarray(self.data.pol_angle_rad, dtype=np.float64)

get_hwp_angles()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
    return np.asarray(self.data.get_hwp_angle(), dtype=np.float64)

get_sample_mask()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
    # TODO: take into account global and local flags
    return np.ones((self.n_detectors, self.n_samples), dtype=bool)

get_timestamps()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_timestamps(self) -> Float[np.ndarray, ' a']:
    return np.asarray(
        self.data.get_times(normalize=False, astropy_times=False), dtype=np.float64
    )

get_wcs_shape_and_kernel(resolution_arcmin, projection=ProjectionType.CAR)

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_wcs_shape_and_kernel(
    self,
    resolution_arcmin: float,
    projection: ProjectionType = ProjectionType.CAR,
) -> tuple[tuple[int, int], wcs.WCS]:
    raise NotImplementedError

get_pointing_and_spin_angles(landscape)

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_pointing_and_spin_angles(
    self, landscape: StokesLandscape
) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
    if not isinstance(landscape, HealpixLandscape):
        raise RuntimeError('only healpix is supported')
    pointings, _hwp_angles = self.data.get_pointings()
    # pointings have shape (N_det, N_samples, 3)
    theta, phi, psi = np.moveaxis(pointings, -1, 0)
    pixel_indices = landscape.world2index(theta, phi)
    spin_angles = jnp.array(psi, dtype=jnp.float64)
    return pixel_indices, spin_angles

get_noise_model()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_noise_model(self) -> None | NoiseModel:
    return AtmosphericNoiseModel(
        sigma=jnp.array(self.data.net_ukrts * 1e-6),
        alpha=jnp.array(-self.data.alpha),
        fk=jnp.array(self.data.fknee_mhz * 1e-3),
        f0=jnp.array(self.data.fmin_hz),
    )

get_boresight_quaternions()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
    # TODO: coordinate system
    #
    # interpolate to the actual sampling rate
    qbore = self.data.pointing_provider.bore2ecliptic_quats
    qbore_full = qbore.slerp(self.data.start_time, self.sample_rate, self.n_samples)
    return np.asarray(qbore_full, dtype=np.float64)

get_detector_quaternions()

Source code in src/furax/interfaces/litebird_sim/observation.py
def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
    return np.concatenate([q.quats for q in self.data.quat], dtype=np.float64)

observation

Classes:

LBSObservation

Bases: AbstractSatelliteObservation[Observation]

Methods:

Attributes:

Source code in src/furax/interfaces/litebird_sim/observation.py
class LBSObservation(AbstractSatelliteObservation[lbs.Observation]):
    @classmethod
    def from_file(
        cls, filename: str | Path, requested_fields: Collection[str] | None = None
    ) -> LBSObservation:
        # check that file exists
        file = Path(filename)
        if not file.exists():
            raise FileNotFoundError(f'File {filename} does not exist')

        # TODO: support requested_fields
        obs = lbs.io.read_one_observation(file)
        if obs is None:
            raise RuntimeError(f'could not read {file} with litebird_sim')
        return cls(obs)

    @property
    def name(self) -> str:
        jdtime_min = round(self.data.start_time.jd * 60)
        return f'obs_{jdtime_min}_lbs'

    @property
    def telescope(self) -> str:
        return 'LB'

    @property
    def n_samples(self) -> int:
        return self.data.n_samples  # type: ignore[no-any-return]

    @property
    def detectors(self) -> list[str]:
        # assumes we have all the detectors
        return [det['name'] for det in self.data.detectors_global]

    @property
    def n_detectors(self) -> int:
        return self.data.n_detectors  # type: ignore[no-any-return]

    @property
    def sample_rate(self) -> float:
        return self.data.sampling_rate_hz  # type: ignore[no-any-return]

    def get_tods(self) -> Float[np.ndarray, 'dets samps']:
        tods = np.asarray(self.data.tod, dtype=np.float64)
        return np.atleast_2d(tods)

    def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
        return np.asarray(self.data.pol_angle_rad, dtype=np.float64)

    def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
        return np.asarray(self.data.get_hwp_angle(), dtype=np.float64)

    def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
        # TODO: take into account global and local flags
        return np.ones((self.n_detectors, self.n_samples), dtype=bool)

    def get_timestamps(self) -> Float[np.ndarray, ' a']:
        return np.asarray(
            self.data.get_times(normalize=False, astropy_times=False), dtype=np.float64
        )

    def get_wcs_shape_and_kernel(
        self,
        resolution_arcmin: float,
        projection: ProjectionType = ProjectionType.CAR,
    ) -> tuple[tuple[int, int], wcs.WCS]:
        raise NotImplementedError

    def get_pointing_and_spin_angles(
        self, landscape: StokesLandscape
    ) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
        if not isinstance(landscape, HealpixLandscape):
            raise RuntimeError('only healpix is supported')
        pointings, _hwp_angles = self.data.get_pointings()
        # pointings have shape (N_det, N_samples, 3)
        theta, phi, psi = np.moveaxis(pointings, -1, 0)
        pixel_indices = landscape.world2index(theta, phi)
        spin_angles = jnp.array(psi, dtype=jnp.float64)
        return pixel_indices, spin_angles

    def get_noise_model(self) -> None | NoiseModel:
        return AtmosphericNoiseModel(
            sigma=jnp.array(self.data.net_ukrts * 1e-6),
            alpha=jnp.array(-self.data.alpha),
            fk=jnp.array(self.data.fknee_mhz * 1e-3),
            f0=jnp.array(self.data.fmin_hz),
        )

    def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
        # TODO: coordinate system
        #
        # interpolate to the actual sampling rate
        qbore = self.data.pointing_provider.bore2ecliptic_quats
        qbore_full = qbore.slerp(self.data.start_time, self.sample_rate, self.n_samples)
        return np.asarray(qbore_full, dtype=np.float64)

    def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
        return np.concatenate([q.quats for q in self.data.quat], dtype=np.float64)
name property
telescope property
n_samples property
detectors property
n_detectors property
sample_rate property
from_file(filename, requested_fields=None) classmethod
Source code in src/furax/interfaces/litebird_sim/observation.py
@classmethod
def from_file(
    cls, filename: str | Path, requested_fields: Collection[str] | None = None
) -> LBSObservation:
    # check that file exists
    file = Path(filename)
    if not file.exists():
        raise FileNotFoundError(f'File {filename} does not exist')

    # TODO: support requested_fields
    obs = lbs.io.read_one_observation(file)
    if obs is None:
        raise RuntimeError(f'could not read {file} with litebird_sim')
    return cls(obs)
get_tods()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_tods(self) -> Float[np.ndarray, 'dets samps']:
    tods = np.asarray(self.data.tod, dtype=np.float64)
    return np.atleast_2d(tods)
get_detector_offset_angles()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
    return np.asarray(self.data.pol_angle_rad, dtype=np.float64)
get_hwp_angles()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
    return np.asarray(self.data.get_hwp_angle(), dtype=np.float64)
get_sample_mask()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
    # TODO: take into account global and local flags
    return np.ones((self.n_detectors, self.n_samples), dtype=bool)
get_timestamps()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_timestamps(self) -> Float[np.ndarray, ' a']:
    return np.asarray(
        self.data.get_times(normalize=False, astropy_times=False), dtype=np.float64
    )
get_wcs_shape_and_kernel(resolution_arcmin, projection=ProjectionType.CAR)
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_wcs_shape_and_kernel(
    self,
    resolution_arcmin: float,
    projection: ProjectionType = ProjectionType.CAR,
) -> tuple[tuple[int, int], wcs.WCS]:
    raise NotImplementedError
get_pointing_and_spin_angles(landscape)
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_pointing_and_spin_angles(
    self, landscape: StokesLandscape
) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
    if not isinstance(landscape, HealpixLandscape):
        raise RuntimeError('only healpix is supported')
    pointings, _hwp_angles = self.data.get_pointings()
    # pointings have shape (N_det, N_samples, 3)
    theta, phi, psi = np.moveaxis(pointings, -1, 0)
    pixel_indices = landscape.world2index(theta, phi)
    spin_angles = jnp.array(psi, dtype=jnp.float64)
    return pixel_indices, spin_angles
get_noise_model()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_noise_model(self) -> None | NoiseModel:
    return AtmosphericNoiseModel(
        sigma=jnp.array(self.data.net_ukrts * 1e-6),
        alpha=jnp.array(-self.data.alpha),
        fk=jnp.array(self.data.fknee_mhz * 1e-3),
        f0=jnp.array(self.data.fmin_hz),
    )
get_boresight_quaternions()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
    # TODO: coordinate system
    #
    # interpolate to the actual sampling rate
    qbore = self.data.pointing_provider.bore2ecliptic_quats
    qbore_full = qbore.slerp(self.data.start_time, self.sample_rate, self.n_samples)
    return np.asarray(qbore_full, dtype=np.float64)
get_detector_quaternions()
Source code in src/furax/interfaces/litebird_sim/observation.py
def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
    return np.concatenate([q.quats for q in self.data.quat], dtype=np.float64)

LazyLBSObservation

Bases: FileBackedLazyObservation[Observation]

Attributes:

Source code in src/furax/interfaces/litebird_sim/observation.py
class LazyLBSObservation(FileBackedLazyObservation[lbs.Observation]):
    interface_class = LBSObservation
interface_class = LBSObservation class-attribute instance-attribute

furax.interfaces.sotodlib

Modules:

Classes:

LazyPreprocSOTODLibObservation

Bases: AbstractLazyObservation[AxisManager]

Lazy observation backed by the preprocessing database (no intermediate file).

Loads on demand via SOTODLibObservation.from_preproc_group, so observations are streamed straight from the preproc archive at mapmaking time and never copied to disk.

Methods:

Attributes:

Source code in src/furax/interfaces/sotodlib/observation.py
class LazyPreprocSOTODLibObservation(AbstractLazyObservation[AxisManager]):
    """Lazy observation backed by the preprocessing database (no intermediate file).

    Loads on demand via ``SOTODLibObservation.from_preproc_group``, so observations are
    streamed straight from the preproc archive at mapmaking time and never copied to disk.
    """

    interface_class = SOTODLibObservation

    def __init__(
        self,
        observation_id: str,
        init_config: str | Path,
        proc_config: str | Path | None = None,
        detector_selection: dict[str, str] | None = None,
        downsample: int = 1,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> None:
        self.observation_id = observation_id
        self.init_config = Path(init_config).resolve()
        self.proc_config = Path(proc_config).resolve() if proc_config else None
        self.detector_selection = detector_selection
        self.downsample = downsample
        self._sotodlib_config = sotodlib_config

    @property
    def name(self) -> str:
        return self.observation_id

    def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
        # A preproc load cannot be field-subset: the full observation is always returned,
        # which satisfies any requested fields. The cheap shape path lives in probe_shape.
        return SOTODLibObservation.from_preproc_group(
            self.observation_id,
            self.init_config,
            self.proc_config,
            self.detector_selection,
            downsample=self.downsample,
            sotodlib_config=self._sotodlib_config,
        )

    def probe_shape(self, intervals: bool = False) -> ObservationBufferShapes:
        """Returns an upper bound on the variable padded-buffer dimensions for ``fields``.

        All values are read from the init preprocess metadata: no heavy I/O work is done.

        The value is only an *upper bound*, not the exact post-pipeline shape:

        - ``n_detectors`` is the count after applying ``detector_selection`` (wafer slot / bandpass)
          but before the process pipeline, which only ever restricts detectors further.
        - ``n_samples`` is ``ceil(meta.samps.count / downsample)``. The process pipeline only trims
          samples (e.g. edge cuts) before downsampling, so this bounds the actual samps count.
        """
        _enable_preproc_context_cache()
        # call get_preprocess_context through the module so the cached get_preprocess_context is used
        # (str, not Path: sotodlib and the cache key expect a posix string)
        _, context = pu.get_preprocess_context(self.init_config.as_posix())
        meta = context.get_meta(self.observation_id, dets=self.detector_selection)
        n_samps_ub = -(-meta.samps.count // self.downsample)  # ceil, matches downsample_obs
        return ObservationBufferShapes(
            meta.dets.count,
            n_samps_ub,
            meta.subscans.count if intervals else 0,
        )

interface_class = SOTODLibObservation class-attribute instance-attribute

observation_id = observation_id instance-attribute

init_config = Path(init_config).resolve() instance-attribute

proc_config = Path(proc_config).resolve() if proc_config else None instance-attribute

detector_selection = detector_selection instance-attribute

downsample = downsample instance-attribute

name property

__init__(observation_id, init_config, proc_config=None, detector_selection=None, downsample=1, sotodlib_config=None)

Source code in src/furax/interfaces/sotodlib/observation.py
def __init__(
    self,
    observation_id: str,
    init_config: str | Path,
    proc_config: str | Path | None = None,
    detector_selection: dict[str, str] | None = None,
    downsample: int = 1,
    sotodlib_config: SotodlibConfig | None = None,
) -> None:
    self.observation_id = observation_id
    self.init_config = Path(init_config).resolve()
    self.proc_config = Path(proc_config).resolve() if proc_config else None
    self.detector_selection = detector_selection
    self.downsample = downsample
    self._sotodlib_config = sotodlib_config

get_data(requested_fields=None)

Source code in src/furax/interfaces/sotodlib/observation.py
def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
    # A preproc load cannot be field-subset: the full observation is always returned,
    # which satisfies any requested fields. The cheap shape path lives in probe_shape.
    return SOTODLibObservation.from_preproc_group(
        self.observation_id,
        self.init_config,
        self.proc_config,
        self.detector_selection,
        downsample=self.downsample,
        sotodlib_config=self._sotodlib_config,
    )

probe_shape(intervals=False)

Returns an upper bound on the variable padded-buffer dimensions for fields.

All values are read from the init preprocess metadata: no heavy I/O work is done.

The value is only an upper bound, not the exact post-pipeline shape:

  • n_detectors is the count after applying detector_selection (wafer slot / bandpass) but before the process pipeline, which only ever restricts detectors further.
  • n_samples is ceil(meta.samps.count / downsample). The process pipeline only trims samples (e.g. edge cuts) before downsampling, so this bounds the actual samps count.
Source code in src/furax/interfaces/sotodlib/observation.py
def probe_shape(self, intervals: bool = False) -> ObservationBufferShapes:
    """Returns an upper bound on the variable padded-buffer dimensions for ``fields``.

    All values are read from the init preprocess metadata: no heavy I/O work is done.

    The value is only an *upper bound*, not the exact post-pipeline shape:

    - ``n_detectors`` is the count after applying ``detector_selection`` (wafer slot / bandpass)
      but before the process pipeline, which only ever restricts detectors further.
    - ``n_samples`` is ``ceil(meta.samps.count / downsample)``. The process pipeline only trims
      samples (e.g. edge cuts) before downsampling, so this bounds the actual samps count.
    """
    _enable_preproc_context_cache()
    # call get_preprocess_context through the module so the cached get_preprocess_context is used
    # (str, not Path: sotodlib and the cache key expect a posix string)
    _, context = pu.get_preprocess_context(self.init_config.as_posix())
    meta = context.get_meta(self.observation_id, dets=self.detector_selection)
    n_samps_ub = -(-meta.samps.count // self.downsample)  # ceil, matches downsample_obs
    return ObservationBufferShapes(
        meta.dets.count,
        n_samps_ub,
        meta.subscans.count if intervals else 0,
    )

LazySOTODLibObservation

Bases: FileBackedLazyObservation[AxisManager]

Methods:

Attributes:

Source code in src/furax/interfaces/sotodlib/observation.py
class LazySOTODLibObservation(FileBackedLazyObservation[AxisManager]):
    interface_class = SOTODLibObservation

    def __init__(self, filename: str | Path, sotodlib_config: SotodlibConfig | None = None) -> None:
        super().__init__(filename)
        self._sotodlib_config = sotodlib_config

    def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
        return SOTODLibObservation.from_file(
            self.file, requested_fields, sotodlib_config=self._sotodlib_config
        )

interface_class = SOTODLibObservation class-attribute instance-attribute

__init__(filename, sotodlib_config=None)

Source code in src/furax/interfaces/sotodlib/observation.py
def __init__(self, filename: str | Path, sotodlib_config: SotodlibConfig | None = None) -> None:
    super().__init__(filename)
    self._sotodlib_config = sotodlib_config

get_data(requested_fields=None)

Source code in src/furax/interfaces/sotodlib/observation.py
def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
    return SOTODLibObservation.from_file(
        self.file, requested_fields, sotodlib_config=self._sotodlib_config
    )

SOTODLibObservation

Bases: AbstractGroundObservation[AxisManager]

Class for interfacing with sotodlib's AxisManager.

Methods:

Attributes:

Source code in src/furax/interfaces/sotodlib/observation.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
class SOTODLibObservation(AbstractGroundObservation[AxisManager]):
    """Class for interfacing with sotodlib's AxisManager."""

    def __init__(self, data: AxisManager, sotodlib_config: SotodlibConfig | None = None) -> None:
        super().__init__(data)
        self._sotodlib_config = sotodlib_config or SotodlibConfig()

    @classmethod
    def from_file(
        cls,
        filename: str | Path,
        requested_fields: Collection[str] | None = None,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> SOTODLibObservation:
        # check that file exists
        if not Path(filename).exists():
            raise FileNotFoundError(f'File {filename} does not exist')
        if isinstance(filename, Path):
            filename = filename.as_posix()

        config = sotodlib_config or SotodlibConfig()
        if requested_fields is None:
            # default is to load everything
            data = AxisManager.load(filename, fields=None)
            return cls(data, config)

        requested = set(requested_fields)
        # minimum information needed to determine buffer shapes
        fields = {'dets', 'samp'}
        # translate request to sotodlib subfield names (sets dedup overlapping requests)
        if ReaderField.METADATA in requested:
            fields.add('obs_info')
        if ReaderField.SAMPLE_DATA in requested:
            if config.demodulated:
                fields |= {'dsT', 'demodQ', 'demodU'}
            else:
                fields.add('signal')
        if ReaderField.VALID_SAMPLE_MASKS in requested:
            fields.add('flags.glitch_flags')
        if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
            fields.add('preprocess.turnaround_flags')
        if ReaderField.LEFT_SCAN_MASK in requested:
            fields.add('flags.left_scan')
        if ReaderField.RIGHT_SCAN_MASK in requested:
            fields.add('flags.right_scan')
        if {ReaderField.AZIMUTH, ReaderField.ELEVATION} & requested:
            fields.add('boresight')
        if ReaderField.TIMESTAMPS in requested:
            fields.add('timestamps')
        if ReaderField.HWP_ANGLES in requested:
            fields.add('hwp_angle')
        if ReaderField.BORESIGHT_QUATERNIONS in requested:
            fields |= {'boresight', 'timestamps'}
            if config.wobble_correction:
                fields |= {'wobble_params', 'det_info', 'hwp_angle'}
        if ReaderField.DETECTOR_QUATERNIONS in requested:
            fields.add('focal_plane')
        if ReaderField.NOISE_MODEL_FITS in requested:
            if config.noise_source == 'mapmaking':
                fields.add('preprocess.noiseQ_mapmaking')
            else:
                fields |= {'preprocess.noiseT', 'preprocess.noiseQ', 'preprocess.noiseU'}

        data = AxisManager.load(filename, fields=list(fields))
        return cls(data, config)

    @classmethod
    def from_preprocess(
        cls,
        preprocess_config: str | Path | dict[str, Any],
        observation_id: str,
        detector_selection: dict[str, str] | None = None,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> SOTODLibObservation:
        """Loads and preprocesses an observation.

        Args:
            preprocess_config: Preprocessing configuration as a path to a yaml file or dictionary.
            observation_id: Observation id.
                (e.g. 'obs_1714550584_satp3_1111111').
            detector_selection: Optional dictionary to select a subset of detectors
                (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
            sotodlib_config: Optional sotodlib-specific configuration.

        Returns:
            An instance of SOTODLibObservation.
        """
        if isinstance(preprocess_config, dict):
            config = preprocess_config
        else:
            # load the preprocessing config from a yaml file
            with open(preprocess_config) as file:
                config = yaml.safe_load(file)

        data = pu.load_and_preprocess(observation_id, config, dets=detector_selection)
        return cls(data, sotodlib_config)

    @classmethod
    def from_preproc_group(
        cls,
        observation_id: str,
        init_config: str | Path,
        proc_config: str | Path | None = None,
        detector_selection: dict[str, str] | None = None,
        downsample: int = 1,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> SOTODLibObservation:
        """Loads a (already preprocessed) observation directly from the preprocessing db.

        This reads the saved preprocessing products from the archive and re-applies the
        process pipeline, without writing any intermediate file. It mirrors the load path
        of ``preproc_or_load_group`` (as used by the ``furax-so-prepare`` tool) but skips
        the archive/proc-aman saving, so the result is identical to mapping a binary file
        produced by that tool.

        Args:
            observation_id: Observation id (e.g. 'obs_1714550584_satp3_1111111').
            init_config: Base-layer preprocessing config (path or posix string).
            proc_config: Optional second-layer preprocessing config. If given, the
                two-layer (init+proc) load path is used.
            detector_selection: Optional detector restriction
                (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
            downsample: Integer downsampling factor applied after preprocessing.
            sotodlib_config: Optional sotodlib-specific configuration.

        Returns:
            An instance of SOTODLibObservation.

        Raises:
            RuntimeError: If no detectors remain after cuts (nothing to load).
        """
        _enable_preproc_context_cache()
        init_config = Path(init_config).as_posix()
        if proc_config is not None:
            aman = pu.multilayer_load_and_preprocess(
                observation_id,
                init_config,
                Path(proc_config).as_posix(),
                dets=detector_selection,
            )
        else:
            # load_and_preprocess returns (aman, full_aman); we only need the restricted one
            result = pu.load_and_preprocess(observation_id, init_config, dets=detector_selection)
            aman = result[0] if isinstance(result, tuple) else result
        if aman is None:
            raise RuntimeError(f'no detectors left after cuts for {observation_id}')
        if downsample > 1:
            aman = downsample_obs(aman, downsample)
        return cls(aman, sotodlib_config)

    @property
    def name(self) -> str:
        return self.data.obs_info.obs_id  # type: ignore[no-any-return]

    @property
    def telescope(self) -> str:
        return self.data.obs_info.get('telescope')  # type: ignore[no-any-return]

    @property
    def n_samples(self) -> int:
        return self.data.samps.count  # type: ignore[no-any-return]

    @property
    def detectors(self) -> list[str]:
        return self.data.dets.vals  # type: ignore[no-any-return]

    @property
    def n_detectors(self) -> int:
        return self.data.dets.count  # type: ignore[no-any-return]

    @property
    def sample_rate(self) -> float:
        """Returns the sampling rate (in Hz) of the data."""
        duration: float = self.data.timestamps[-1] - self.data.timestamps[0]
        return (self.n_samples - 1) / duration

    def get_tods(self) -> Float[np.ndarray, 'dets samps']:
        """Returns the timestream data."""
        # furax's LinearPolarizerOperator assumes power, sotodlib assumes temperature
        tods = np.asarray(self.data.signal, dtype=np.float64)
        return 0.5 * np.atleast_2d(tods)

    @overload
    def get_demodulated_tods(self, stokes: Literal['I']) -> StokesI: ...
    @overload
    def get_demodulated_tods(self, stokes: Literal['QU']) -> StokesQU: ...
    @overload
    def get_demodulated_tods(self, stokes: Literal['IQU']) -> StokesIQU: ...
    @overload
    def get_demodulated_tods(self, stokes: Literal['IQUV']) -> StokesIQUV: ...
    def get_demodulated_tods(self, stokes: ValidStokesType = 'IQU') -> StokesType:
        """Returns the demodulated timestream data as a Stokes pytree.

        'IQUV' is not supported.
        """
        if stokes == 'IQUV':
            raise NotImplementedError
        kls = Stokes.class_for(stokes)
        tods = [self._get_demodulated_tod(s) for s in stokes]  # type: ignore[arg-type]
        return kls.from_array(np.stack(tods, axis=0))

    def _get_demodulated_tod(self, stoke: Literal['I', 'Q', 'U']) -> NDArray[np.float64]:
        attr = {'I': 'dsT', 'Q': 'demodQ', 'U': 'demodU'}[stoke]
        tod = np.asarray(getattr(self.data, attr), dtype=np.float64)
        return 0.5 * np.atleast_2d(tod)

    def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
        """Returns the detector offset angles."""
        return np.asarray(self.data.focal_plane['gamma'])

    def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
        """Returns the HWP angles."""
        return np.asarray(self.data.hwp_angle)

    def get_scanning_intervals(self, det_ind: int = 0) -> NDArray[Any]:
        """Returns scanning intervals of the chosen detector.

        The output is a list of the starting and ending sample indices
        """
        return np.array(
            self.data.preprocess.turnaround_flags.turnarounds.ranges[det_ind].complement().ranges()
        )

    def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
        try:
            return np.asarray((~self.data.flags.glitch_flags).mask(), dtype=bool)
        except KeyError as e:
            raise RuntimeError('Glitch flags unavailable in the observation') from e

    def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        try:
            # Stored per-detector (RangesMatrix), but scan direction is shared across the
            # focal plane; collapse to the boresight 1D mask (cf. get_scanning_intervals).
            return np.asarray(self.data.flags.left_scan.mask()[0], dtype=bool)
        except KeyError as e:
            raise RuntimeError('Scan mask unavailable in the observation') from e

    def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        try:
            return np.asarray(self.data.flags.right_scan.mask()[0], dtype=bool)
        except KeyError as e:
            raise RuntimeError('Scan mask unavailable in the observation') from e

    def get_azimuth(self) -> Float[np.ndarray, ' a']:
        """Returns the azimuth of the boresight for each sample."""
        return np.asarray(self.data.boresight.az)

    def get_elevation(self) -> Float[np.ndarray, ' a']:
        """Returns the elevation of the boresight for each sample."""
        return np.asarray(self.data.boresight.el)

    def get_wcs_shape_and_kernel(
        self,
        resolution_arcmin: float,
        projection: ProjectionType = ProjectionType.CAR,
    ) -> tuple[tuple[int, int], WCS]:
        """Returns astropy WCS kernel object corresponding to the observed sky."""
        res = resolution_arcmin * pixell.utils.arcmin
        wcs_kernel_init = coords.get_wcs_kernel(projection.name.lower(), 0, 0, res=res)
        wcs_shape, wcs_kernel = coords.get_footprint(self.data, wcs_kernel_init)

        return wcs_shape, wcs_kernel

    def get_pointing_and_spin_angles(
        self, landscape: StokesLandscape
    ) -> tuple[Integer[Array, 'dets samps 2'], Float[Array, 'dets samps']]:
        """Obtain pointing information and spin angles from the observation."""
        # Projection Matrix class instance for the observation
        if isinstance(landscape, AstropyWCSLandscape):
            # TODO: pass 'cuts' keyword here for time slices (glitches etc)?
            P = coords.P.for_tod(self.data, wcs_kernel=landscape.wcs, comps='TQU', hwp=True)
        elif isinstance(landscape, HealpixLandscape):
            hp_geom = coords.healpix_utils.get_geometry(nside=landscape.nside, ordering='RING')
            P = coords.P.for_tod(self.data, geom=hp_geom)
        else:
            raise NotImplementedError(f'Landscape {landscape} not supported')

        # Projectionist object from P
        proj = P._get_proj()

        # Assembly containing the focal plane and boresight information
        assembly = P._get_asm()

        # Get the pixel indices as before, but also obtain
        # the spin projection factors of size (n_samps,n_comps) for each detector,
        # which have 1, cos(2*p), sin(2*p) where p is the parallactic angle
        pixel_inds, spin_proj = proj.get_pointing_matrix(assembly)

        # TODO: check if this could be jnp array directly
        pixel_inds = np.array(pixel_inds)

        spin_proj = jnp.array(spin_proj, dtype=landscape.dtype)
        spin_ang = jnp.arctan2(spin_proj[..., 2], spin_proj[..., 1]) / 2.0

        return pixel_inds, spin_ang  # type: ignore[return-value]

    def get_timestamps(self) -> Float[np.ndarray, ' a']:
        """Returns timestamps (sec) of the samples."""
        return np.asarray(self.data.timestamps)

    def get_scanning_mask(self) -> Bool[np.ndarray, ' samp']:
        # Assume that all detectors have the same scanning intervals
        return np.asarray(
            self.data.preprocess.turnaround_flags.turnarounds.ranges[0].complement().mask(),
            dtype=bool,
        )

    def get_noise_model(self) -> None | NoiseModel:
        """Load precomputed noise model from the data, if present. Otherwise, return None."""
        try:
            fit = self._get_noise_fit_for_stoke('I')
        except ValueError:
            return None
        return AtmosphericNoiseModel(*fit)

    def get_demodulated_noise_model(self, stokes: ValidStokesType = 'IQU') -> NoiseModel:
        """Returns a single noise model covering every requested Stokes leg.

        Each Stokes leg is fit independently (I/Q/U noise properties genuinely differ), so the
        per-detector parameters carry a leading Stokes axis rather than being separate models.
        """
        if stokes == 'IQUV':
            raise NotImplementedError
        fits = np.stack([self._get_noise_fit_for_stoke(s) for s in stokes], axis=1)  # type: ignore[arg-type]
        return AtmosphericNoiseModel(*fits)

    def _get_noise_fit_for_stoke(self, stoke: Literal['I', 'Q', 'U']) -> NDArray[np.floating]:
        """Returns the (sigma, alpha, fk, f0) fit rows for one Stokes leg, shape ``(4, dets)``."""
        preproc = self.data.get('preprocess')
        if preproc is None:
            raise ValueError('No preprocess data available')
        if self._sotodlib_config.noise_source == 'mapmaking':
            sigma = np.asarray(preproc['noiseQ_mapmaking.white_noise'])
            ones = np.ones_like(sigma)  # fake alpha, fk, f0
            return np.stack([sigma, ones, ones, ones], axis=0)
        attr = {'I': 'noiseT', 'Q': 'noiseQ', 'U': 'noiseU'}[stoke]
        if attr not in preproc:
            raise ValueError(f'No {attr} noise model available')
        fit = getattr(preproc, attr)
        # sotodlib fit's columns: (w, fknee, alpha), with
        # psd = wn**2 * (1 + (fknee / f) ** alpha)
        # Note the difference in the sign of alpha
        expected = ['white_noise', 'fknee', 'alpha']
        actual = list(fit.noise_model_coeffs.vals)
        if actual != expected:
            raise ValueError(f'Unexpected noise model coefficients: {actual}, expected {expected}')
        sigma = np.asarray(fit.fit[:, 0])
        alpha = np.asarray(-fit.fit[:, 2])
        fk = np.asarray(fit.fit[:, 1])
        f0 = 1e-5 * np.ones_like(fk)
        return np.stack([sigma, alpha, fk, f0], axis=0)

    '''
    def get_noise_fits(self, fmin: float) -> NDArray[np.float64]:
        """Returns fitted values of the noise psd with 1/f and white noise,
        either using the fitted parameters from the preprocessing,
        or fitting the model directly.
        #TODO: reimplement below by calling get_noise_model() instead
        """
        preproc = self.observation.preprocess

        if 'psdT' in preproc:
            f = preproc.psdT.freqs
            fit = preproc.noiseT_fit.fit  # columns: (fknee, w, alpha)
        elif 'Pxx_raw' in preproc:
            f = preproc.Pxx_raw.freqs
            fit = preproc.noise_signal_fit.fit  # columns: (fknee, w, alpha)
        else:
            # Estimate psd
            raise NotImplementedError('Self-psd evaluation not implemented')
        imin = np.argmin(np.abs(f - fmin))
        noiseT_fit_eval = np.zeros((fit.shape[0], f.size), dtype=float)  # (dets, freqs)
        noiseT_fit_eval[:, imin:] = fit[:, [1]] * (
            1 + (fit[:, [0]] / f[None, imin:]) ** fit[:, [2]]
        )
        noiseT_fit_eval[:, :imin] = noiseT_fit_eval[:, [imin]]

        return np.array(noiseT_fit_eval)

    def get_white_noise_fit(self) -> NDArray[np.float64]:
        """Returns fitted values of the white noise,
        obtained as a result of a 1/f + white noise model fitting.
        Uses either the fitted parameters from the preprocessing,
        or fitting the model directly.
        """

        preproc = self.observation.preprocess
        if 'psdT' in preproc:
            fit = preproc.noiseT_fit.fit  # columns: (fknee, w, alpha)
        elif 'Pxx_raw' in preproc:
            fit = preproc.noise_signal_fit.fit  # columns: (fknee, w, alpha)
        else:
            # Estimate psd
            raise NotImplementedError('Self-psd evaluation not implemented')
        return fit[:, 1]  # type: ignore[no-any-return]
    '''

    def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
        """Returns the boresight quaternions at each time sample."""
        site = self._sotodlib_config.site
        weather = self._sotodlib_config.weather
        if self._sotodlib_config.wobble_correction:
            wobble = self.data.get('wobble_params')
            if wobble is None:
                raise ValueError('wobble_params not found in observation data')
            csl = get_deflected_sightline(self.data, wobble, site=site, weather=weather)
        else:
            csl = so3g.proj.CelestialSightLine.az_el(
                self.data.timestamps,
                self.data.boresight.az,
                self.data.boresight.el,
                roll=self.data.boresight.roll,
                site=site,
                weather=weather,
            )
        return np.asarray(csl.Q, dtype=np.float64)

    def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
        """Returns the quaternion offsets of the detectors."""
        # Use so3g's CPU implementation so the result stays on host
        # quaternion convention is the same ordering (1,i,j,k)
        fp = self.data.focal_plane
        quats = so3g.proj.quat.rotation_xieta(fp.xi, fp.eta, fp.gamma)
        return np.atleast_2d(np.asarray(quats, dtype=np.float64))

name property

telescope property

n_samples property

detectors property

n_detectors property

sample_rate property

Returns the sampling rate (in Hz) of the data.

__init__(data, sotodlib_config=None)

Source code in src/furax/interfaces/sotodlib/observation.py
def __init__(self, data: AxisManager, sotodlib_config: SotodlibConfig | None = None) -> None:
    super().__init__(data)
    self._sotodlib_config = sotodlib_config or SotodlibConfig()

from_file(filename, requested_fields=None, sotodlib_config=None) classmethod

Source code in src/furax/interfaces/sotodlib/observation.py
@classmethod
def from_file(
    cls,
    filename: str | Path,
    requested_fields: Collection[str] | None = None,
    sotodlib_config: SotodlibConfig | None = None,
) -> SOTODLibObservation:
    # check that file exists
    if not Path(filename).exists():
        raise FileNotFoundError(f'File {filename} does not exist')
    if isinstance(filename, Path):
        filename = filename.as_posix()

    config = sotodlib_config or SotodlibConfig()
    if requested_fields is None:
        # default is to load everything
        data = AxisManager.load(filename, fields=None)
        return cls(data, config)

    requested = set(requested_fields)
    # minimum information needed to determine buffer shapes
    fields = {'dets', 'samp'}
    # translate request to sotodlib subfield names (sets dedup overlapping requests)
    if ReaderField.METADATA in requested:
        fields.add('obs_info')
    if ReaderField.SAMPLE_DATA in requested:
        if config.demodulated:
            fields |= {'dsT', 'demodQ', 'demodU'}
        else:
            fields.add('signal')
    if ReaderField.VALID_SAMPLE_MASKS in requested:
        fields.add('flags.glitch_flags')
    if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
        fields.add('preprocess.turnaround_flags')
    if ReaderField.LEFT_SCAN_MASK in requested:
        fields.add('flags.left_scan')
    if ReaderField.RIGHT_SCAN_MASK in requested:
        fields.add('flags.right_scan')
    if {ReaderField.AZIMUTH, ReaderField.ELEVATION} & requested:
        fields.add('boresight')
    if ReaderField.TIMESTAMPS in requested:
        fields.add('timestamps')
    if ReaderField.HWP_ANGLES in requested:
        fields.add('hwp_angle')
    if ReaderField.BORESIGHT_QUATERNIONS in requested:
        fields |= {'boresight', 'timestamps'}
        if config.wobble_correction:
            fields |= {'wobble_params', 'det_info', 'hwp_angle'}
    if ReaderField.DETECTOR_QUATERNIONS in requested:
        fields.add('focal_plane')
    if ReaderField.NOISE_MODEL_FITS in requested:
        if config.noise_source == 'mapmaking':
            fields.add('preprocess.noiseQ_mapmaking')
        else:
            fields |= {'preprocess.noiseT', 'preprocess.noiseQ', 'preprocess.noiseU'}

    data = AxisManager.load(filename, fields=list(fields))
    return cls(data, config)

from_preprocess(preprocess_config, observation_id, detector_selection=None, sotodlib_config=None) classmethod

Loads and preprocesses an observation.

Parameters:

  • preprocess_config (str | Path | dict[str, Any]) –

    Preprocessing configuration as a path to a yaml file or dictionary.

  • observation_id (str) –

    Observation id. (e.g. 'obs_1714550584_satp3_1111111').

  • detector_selection (dict[str, str] | None, default: None ) –

    Optional dictionary to select a subset of detectors (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).

  • sotodlib_config (SotodlibConfig | None, default: None ) –

    Optional sotodlib-specific configuration.

Returns:

Source code in src/furax/interfaces/sotodlib/observation.py
@classmethod
def from_preprocess(
    cls,
    preprocess_config: str | Path | dict[str, Any],
    observation_id: str,
    detector_selection: dict[str, str] | None = None,
    sotodlib_config: SotodlibConfig | None = None,
) -> SOTODLibObservation:
    """Loads and preprocesses an observation.

    Args:
        preprocess_config: Preprocessing configuration as a path to a yaml file or dictionary.
        observation_id: Observation id.
            (e.g. 'obs_1714550584_satp3_1111111').
        detector_selection: Optional dictionary to select a subset of detectors
            (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
        sotodlib_config: Optional sotodlib-specific configuration.

    Returns:
        An instance of SOTODLibObservation.
    """
    if isinstance(preprocess_config, dict):
        config = preprocess_config
    else:
        # load the preprocessing config from a yaml file
        with open(preprocess_config) as file:
            config = yaml.safe_load(file)

    data = pu.load_and_preprocess(observation_id, config, dets=detector_selection)
    return cls(data, sotodlib_config)

from_preproc_group(observation_id, init_config, proc_config=None, detector_selection=None, downsample=1, sotodlib_config=None) classmethod

Loads a (already preprocessed) observation directly from the preprocessing db.

This reads the saved preprocessing products from the archive and re-applies the process pipeline, without writing any intermediate file. It mirrors the load path of preproc_or_load_group (as used by the furax-so-prepare tool) but skips the archive/proc-aman saving, so the result is identical to mapping a binary file produced by that tool.

Parameters:

  • observation_id (str) –

    Observation id (e.g. 'obs_1714550584_satp3_1111111').

  • init_config (str | Path) –

    Base-layer preprocessing config (path or posix string).

  • proc_config (str | Path | None, default: None ) –

    Optional second-layer preprocessing config. If given, the two-layer (init+proc) load path is used.

  • detector_selection (dict[str, str] | None, default: None ) –

    Optional detector restriction (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).

  • downsample (int, default: 1 ) –

    Integer downsampling factor applied after preprocessing.

  • sotodlib_config (SotodlibConfig | None, default: None ) –

    Optional sotodlib-specific configuration.

Returns:

Raises:

  • RuntimeError

    If no detectors remain after cuts (nothing to load).

Source code in src/furax/interfaces/sotodlib/observation.py
@classmethod
def from_preproc_group(
    cls,
    observation_id: str,
    init_config: str | Path,
    proc_config: str | Path | None = None,
    detector_selection: dict[str, str] | None = None,
    downsample: int = 1,
    sotodlib_config: SotodlibConfig | None = None,
) -> SOTODLibObservation:
    """Loads a (already preprocessed) observation directly from the preprocessing db.

    This reads the saved preprocessing products from the archive and re-applies the
    process pipeline, without writing any intermediate file. It mirrors the load path
    of ``preproc_or_load_group`` (as used by the ``furax-so-prepare`` tool) but skips
    the archive/proc-aman saving, so the result is identical to mapping a binary file
    produced by that tool.

    Args:
        observation_id: Observation id (e.g. 'obs_1714550584_satp3_1111111').
        init_config: Base-layer preprocessing config (path or posix string).
        proc_config: Optional second-layer preprocessing config. If given, the
            two-layer (init+proc) load path is used.
        detector_selection: Optional detector restriction
            (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
        downsample: Integer downsampling factor applied after preprocessing.
        sotodlib_config: Optional sotodlib-specific configuration.

    Returns:
        An instance of SOTODLibObservation.

    Raises:
        RuntimeError: If no detectors remain after cuts (nothing to load).
    """
    _enable_preproc_context_cache()
    init_config = Path(init_config).as_posix()
    if proc_config is not None:
        aman = pu.multilayer_load_and_preprocess(
            observation_id,
            init_config,
            Path(proc_config).as_posix(),
            dets=detector_selection,
        )
    else:
        # load_and_preprocess returns (aman, full_aman); we only need the restricted one
        result = pu.load_and_preprocess(observation_id, init_config, dets=detector_selection)
        aman = result[0] if isinstance(result, tuple) else result
    if aman is None:
        raise RuntimeError(f'no detectors left after cuts for {observation_id}')
    if downsample > 1:
        aman = downsample_obs(aman, downsample)
    return cls(aman, sotodlib_config)

get_tods()

Returns the timestream data.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_tods(self) -> Float[np.ndarray, 'dets samps']:
    """Returns the timestream data."""
    # furax's LinearPolarizerOperator assumes power, sotodlib assumes temperature
    tods = np.asarray(self.data.signal, dtype=np.float64)
    return 0.5 * np.atleast_2d(tods)

get_demodulated_tods(stokes='IQU')

get_demodulated_tods(stokes: Literal['I']) -> StokesI
get_demodulated_tods(stokes: Literal['QU']) -> StokesQU
get_demodulated_tods(stokes: Literal['IQU']) -> StokesIQU
get_demodulated_tods(stokes: Literal['IQUV']) -> StokesIQUV

Returns the demodulated timestream data as a Stokes pytree.

'IQUV' is not supported.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_demodulated_tods(self, stokes: ValidStokesType = 'IQU') -> StokesType:
    """Returns the demodulated timestream data as a Stokes pytree.

    'IQUV' is not supported.
    """
    if stokes == 'IQUV':
        raise NotImplementedError
    kls = Stokes.class_for(stokes)
    tods = [self._get_demodulated_tod(s) for s in stokes]  # type: ignore[arg-type]
    return kls.from_array(np.stack(tods, axis=0))

get_detector_offset_angles()

Returns the detector offset angles.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
    """Returns the detector offset angles."""
    return np.asarray(self.data.focal_plane['gamma'])

get_hwp_angles()

Returns the HWP angles.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
    """Returns the HWP angles."""
    return np.asarray(self.data.hwp_angle)

get_scanning_intervals(det_ind=0)

Returns scanning intervals of the chosen detector.

The output is a list of the starting and ending sample indices

Source code in src/furax/interfaces/sotodlib/observation.py
def get_scanning_intervals(self, det_ind: int = 0) -> NDArray[Any]:
    """Returns scanning intervals of the chosen detector.

    The output is a list of the starting and ending sample indices
    """
    return np.array(
        self.data.preprocess.turnaround_flags.turnarounds.ranges[det_ind].complement().ranges()
    )

get_sample_mask()

Source code in src/furax/interfaces/sotodlib/observation.py
def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
    try:
        return np.asarray((~self.data.flags.glitch_flags).mask(), dtype=bool)
    except KeyError as e:
        raise RuntimeError('Glitch flags unavailable in the observation') from e

get_left_scan_mask()

Source code in src/furax/interfaces/sotodlib/observation.py
def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    try:
        # Stored per-detector (RangesMatrix), but scan direction is shared across the
        # focal plane; collapse to the boresight 1D mask (cf. get_scanning_intervals).
        return np.asarray(self.data.flags.left_scan.mask()[0], dtype=bool)
    except KeyError as e:
        raise RuntimeError('Scan mask unavailable in the observation') from e

get_right_scan_mask()

Source code in src/furax/interfaces/sotodlib/observation.py
def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    try:
        return np.asarray(self.data.flags.right_scan.mask()[0], dtype=bool)
    except KeyError as e:
        raise RuntimeError('Scan mask unavailable in the observation') from e

get_azimuth()

Returns the azimuth of the boresight for each sample.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_azimuth(self) -> Float[np.ndarray, ' a']:
    """Returns the azimuth of the boresight for each sample."""
    return np.asarray(self.data.boresight.az)

get_elevation()

Returns the elevation of the boresight for each sample.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_elevation(self) -> Float[np.ndarray, ' a']:
    """Returns the elevation of the boresight for each sample."""
    return np.asarray(self.data.boresight.el)

get_wcs_shape_and_kernel(resolution_arcmin, projection=ProjectionType.CAR)

Returns astropy WCS kernel object corresponding to the observed sky.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_wcs_shape_and_kernel(
    self,
    resolution_arcmin: float,
    projection: ProjectionType = ProjectionType.CAR,
) -> tuple[tuple[int, int], WCS]:
    """Returns astropy WCS kernel object corresponding to the observed sky."""
    res = resolution_arcmin * pixell.utils.arcmin
    wcs_kernel_init = coords.get_wcs_kernel(projection.name.lower(), 0, 0, res=res)
    wcs_shape, wcs_kernel = coords.get_footprint(self.data, wcs_kernel_init)

    return wcs_shape, wcs_kernel

get_pointing_and_spin_angles(landscape)

Obtain pointing information and spin angles from the observation.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_pointing_and_spin_angles(
    self, landscape: StokesLandscape
) -> tuple[Integer[Array, 'dets samps 2'], Float[Array, 'dets samps']]:
    """Obtain pointing information and spin angles from the observation."""
    # Projection Matrix class instance for the observation
    if isinstance(landscape, AstropyWCSLandscape):
        # TODO: pass 'cuts' keyword here for time slices (glitches etc)?
        P = coords.P.for_tod(self.data, wcs_kernel=landscape.wcs, comps='TQU', hwp=True)
    elif isinstance(landscape, HealpixLandscape):
        hp_geom = coords.healpix_utils.get_geometry(nside=landscape.nside, ordering='RING')
        P = coords.P.for_tod(self.data, geom=hp_geom)
    else:
        raise NotImplementedError(f'Landscape {landscape} not supported')

    # Projectionist object from P
    proj = P._get_proj()

    # Assembly containing the focal plane and boresight information
    assembly = P._get_asm()

    # Get the pixel indices as before, but also obtain
    # the spin projection factors of size (n_samps,n_comps) for each detector,
    # which have 1, cos(2*p), sin(2*p) where p is the parallactic angle
    pixel_inds, spin_proj = proj.get_pointing_matrix(assembly)

    # TODO: check if this could be jnp array directly
    pixel_inds = np.array(pixel_inds)

    spin_proj = jnp.array(spin_proj, dtype=landscape.dtype)
    spin_ang = jnp.arctan2(spin_proj[..., 2], spin_proj[..., 1]) / 2.0

    return pixel_inds, spin_ang  # type: ignore[return-value]

get_timestamps()

Returns timestamps (sec) of the samples.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_timestamps(self) -> Float[np.ndarray, ' a']:
    """Returns timestamps (sec) of the samples."""
    return np.asarray(self.data.timestamps)

get_scanning_mask()

Source code in src/furax/interfaces/sotodlib/observation.py
def get_scanning_mask(self) -> Bool[np.ndarray, ' samp']:
    # Assume that all detectors have the same scanning intervals
    return np.asarray(
        self.data.preprocess.turnaround_flags.turnarounds.ranges[0].complement().mask(),
        dtype=bool,
    )

get_noise_model()

Load precomputed noise model from the data, if present. Otherwise, return None.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_noise_model(self) -> None | NoiseModel:
    """Load precomputed noise model from the data, if present. Otherwise, return None."""
    try:
        fit = self._get_noise_fit_for_stoke('I')
    except ValueError:
        return None
    return AtmosphericNoiseModel(*fit)

get_demodulated_noise_model(stokes='IQU')

Returns a single noise model covering every requested Stokes leg.

Each Stokes leg is fit independently (I/Q/U noise properties genuinely differ), so the per-detector parameters carry a leading Stokes axis rather than being separate models.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_demodulated_noise_model(self, stokes: ValidStokesType = 'IQU') -> NoiseModel:
    """Returns a single noise model covering every requested Stokes leg.

    Each Stokes leg is fit independently (I/Q/U noise properties genuinely differ), so the
    per-detector parameters carry a leading Stokes axis rather than being separate models.
    """
    if stokes == 'IQUV':
        raise NotImplementedError
    fits = np.stack([self._get_noise_fit_for_stoke(s) for s in stokes], axis=1)  # type: ignore[arg-type]
    return AtmosphericNoiseModel(*fits)

get_boresight_quaternions()

Returns the boresight quaternions at each time sample.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
    """Returns the boresight quaternions at each time sample."""
    site = self._sotodlib_config.site
    weather = self._sotodlib_config.weather
    if self._sotodlib_config.wobble_correction:
        wobble = self.data.get('wobble_params')
        if wobble is None:
            raise ValueError('wobble_params not found in observation data')
        csl = get_deflected_sightline(self.data, wobble, site=site, weather=weather)
    else:
        csl = so3g.proj.CelestialSightLine.az_el(
            self.data.timestamps,
            self.data.boresight.az,
            self.data.boresight.el,
            roll=self.data.boresight.roll,
            site=site,
            weather=weather,
        )
    return np.asarray(csl.Q, dtype=np.float64)

get_detector_quaternions()

Returns the quaternion offsets of the detectors.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
    """Returns the quaternion offsets of the detectors."""
    # Use so3g's CPU implementation so the result stays on host
    # quaternion convention is the same ordering (1,i,j,k)
    fp = self.data.focal_plane
    quats = so3g.proj.quat.rotation_xieta(fp.xi, fp.eta, fp.gamma)
    return np.atleast_2d(np.asarray(quats, dtype=np.float64))

mapmaker

Functions:

Attributes:

logger = init_logger('preprocess') module-attribute

main(preprocess_config, mapmaking_config, obs_id=None, wafer=None, band=None, verbosity=3, binary_filepath=None, obs=None, output_path=None)

Mapmaking script for SO observation data.

Parameters:

  • preprocess_config (path or dict, or None) –

    Preprocessing configuration dictionary or path to a yaml file

  • mapmaking_config (path or dict) –

    Mapmaking configuration dictionary or path to a yaml file

  • obs_id (str, default: None ) –

    Observation id. e.g., obs_1714550584_satp3_1111111.

  • wafer (str, default: None ) –

    If specified, select a subset of detectors by wafer slot, e.g. 'ws0'.

  • band (str, default: None ) –

    If specified, select a subset of detectors by bandpass, e.g. 'f150'.

  • verbosity (int, default: 3 ) –

    verbosity level of logging.Logger

  • binary_filepath (path, default: None ) –

    If specified, load the observation from a binary file instead. Overrides preprocess_config, obs_id, wafer, band.

  • obs (AxisManager, default: None ) –

    If specified, use the observation object passed. Overrides preprocess_config, obs_id, wafer, band, binary_filepath.

  • output_path (path, default: None ) –

    If specified, create directories leading to the path and save the output map and configuration files.

Returns:

  • map_results ( dict ) –

    Product of the mapmaking pipeline. The contents vary depending on the mapmaker used. See individual mapmakers for details on the outputs.

Source code in src/furax/interfaces/sotodlib/mapmaker.py
def main(
    preprocess_config: str | Path | dict[str, Any] | None,
    mapmaking_config: str | Path | MapMakingConfig,
    obs_id: str | None = None,
    wafer: str | None = None,
    band: str | None = None,
    verbosity: int = 3,
    binary_filepath: str | Path | None = None,
    obs: AxisManager | None = None,
    output_path: str | Path | None = None,
) -> dict[str, Any]:
    """Mapmaking script for SO observation data.

    Args:
        preprocess_config (path or dict, or None):
            Preprocessing configuration dictionary or path to a yaml file
        mapmaking_config (path or dict):
            Mapmaking configuration dictionary or path to a yaml file
        obs_id (str):
            Observation id.
            e.g., obs_1714550584_satp3_1111111.
        wafer (str, optional):
            If specified, select a subset of detectors by wafer slot, e.g. 'ws0'.
        band (str, optional):
            If specified, select a subset of detectors by bandpass, e.g. 'f150'.
        verbosity (int):
            verbosity level of logging.Logger
        binary_filepath (path, optional):
            If specified, load the observation from a binary file instead.
            Overrides preprocess_config, obs_id, wafer, band.
        obs (AxisManager, optional):
            If specified, use the observation object passed.
            Overrides preprocess_config, obs_id, wafer, band, binary_filepath.
        output_path (path, optional):
            If specified, create directories leading to the path and
            save the output map and configuration files.

    Returns:
        map_results (dict):
            Product of the mapmaking pipeline.
            The contents vary depending on the mapmaker used.
            See individual mapmakers for details on the outputs.
    """
    logger = init_logger('preprocess', verbosity=verbosity)
    logger.info('Initialised logger')

    # Load map-making config
    if not isinstance(mapmaking_config, MapMakingConfig):
        mapmaking_config = MapMakingConfig.load_yaml(path=mapmaking_config)
        assert isinstance(mapmaking_config, MapMakingConfig), 'Bad mapmaking config file'
        logger.info('Mapmaking config loaded from file')

    # Create map-maker
    maker = MapMaker.from_config(mapmaking_config, logger=logger)

    if wafer is None and band is None:
        det_select = None
    else:
        det_select = {}
        if wafer:
            det_select['wafer_slot'] = wafer
        if band:
            det_select['wafer.bandpass'] = band

    # Load observation
    sotodlib_config = mapmaking_config.sotodlib
    if obs is None:
        if binary_filepath is not None:
            observation = SOTODLibObservation.from_file(
                binary_filepath, sotodlib_config=sotodlib_config
            )
            logger.info(f'Observation data loaded from {binary_filepath}')
        else:
            if obs_id is None or preprocess_config is None:
                msg = 'obs_id and preprocess_config must be specified if obs is not given'
                raise ValueError(msg)
            observation = SOTODLibObservation.from_preprocess(
                preprocess_config, obs_id, det_select, sotodlib_config=sotodlib_config
            )
            logger.info('Observation data loaded from preprocessing config')
    else:
        observation = SOTODLibObservation(obs, sotodlib_config)

    # Make maps
    results = maker.run(observation=observation, out_dir=output_path)
    logger.info('Mapmaking finished')

    # Save preprocessing config
    if output_path is not None and preprocess_config is not None:
        dest = Path(output_path) / 'preprocess_config.yaml'
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_text(yaml.dump(preprocess_config, default_flow_style=False))
        logger.info('Preprocess config saved to file')

    return results

load_result(result_path)

Load results from a directory into a dictionary.

Source code in src/furax/interfaces/sotodlib/mapmaker.py
def load_result(result_path: str | Path) -> dict[str, Any]:
    """Load results from a directory into a dictionary."""
    if not isinstance(result_path, Path):
        result_path = Path(result_path)

    if not result_path.is_dir():
        raise ValueError(f"Provided path '{result_path.as_posix()}' is not a directory.")

    results = {}
    for path in result_path.iterdir():
        if path.is_file():
            filename, extension = path.stem, path.suffix
            if extension == '.yaml':
                results[filename] = yaml.safe_load(path.read_text())
            elif extension == '.npy':
                results[filename] = np.load(path)
            elif extension == '.pkl':
                with path.open('rb') as f:
                    results[filename] = pickle.load(f)
            elif path.name == 'wcs.fits':
                with fits.open(path) as hdul:
                    results[filename] = WCS(hdul[0].header)
    return results

get_parser(parser=None)

Source code in src/furax/interfaces/sotodlib/mapmaker.py
def get_parser(parser: ArgumentParser | None = None) -> ArgumentParser:
    if parser is None:
        parser = ArgumentParser()

    parser.add_argument('preprocess_config', help='Preprocessing configuration file')
    parser.add_argument('mapmaking_config', help='Mapmaking configuration file')
    parser.add_argument('--obs-id', help='obs-id of the observation')
    parser.add_argument('--wafer', help='wafer slot selection')
    parser.add_argument('--band', help='wafer bandpass selection')

    parser.add_argument(
        '--verbosity',
        help='Output verbosity level. 0:Error, 1:Warning, 2:Info(default), 3:Debug',
        default=3,
        type=int,
    )
    parser.add_argument(
        '--binary-filepath', help='Optional path to binary file containing observation'
    )
    parser.add_argument('--output-path', help='Path where to save results')
    return parser

main_cli()

Source code in src/furax/interfaces/sotodlib/mapmaker.py
def main_cli() -> None:
    main_launcher(main, get_parser)

mapmaker_multi

Functions:

Attributes:

logger = init_logger('preprocess') module-attribute

main(preprocess_config, mapmaking_configs, obs_ids, wafer_slot=None, wafer_bandpass=None, verbosity=3, output_path=None, log_path=None)

Mapmaking script for SO observation data.

Parameters:

  • preprocess_config (str or dict) –

    Preprocessing configuration dictionary or path to a yaml file.

  • mapmaking_configs (list) –

    Paths to mapmaking configuration yaml files.

  • obs_ids (list) –

    Observation ids. e.g., ['obs_1714550584_satp3_1111111']

  • wafer_slot (str or None, default: None ) –

    If specified, select a subset of wafers e.g. 'ws0'

  • wafer_bandpass (str or None, default: None ) –

    If specified, select a subset of bandpasses e.g. 'f150'

  • verbosity (int, default: 3 ) –

    verboisity level of logging.Logger.

  • output_path (str or None, default: None ) –

    If specified, create directories leading to the path and save the output map and configuration files.

  • log_path (str or None, default: None ) –

    If specified, save output logs in a file of the given path.

Returns:

  • map_results ( dict ) –

    Product of the mapmaking pipeline. The contents vary depending on the mapmaker used. See individual mapmakers for details on the outputs.

Source code in src/furax/interfaces/sotodlib/mapmaker_multi.py
def main(
    preprocess_config: str | dict[str, Any] | None,
    mapmaking_configs: list[str],
    obs_ids: list[str],
    wafer_slot: str | None = None,
    wafer_bandpass: str | None = None,
    verbosity: int = 3,
    output_path: str | None = None,
    log_path: str | None = None,
) -> None:
    """Mapmaking script for SO observation data.

    Args:
        preprocess_config (str or dict):
            Preprocessing configuration dictionary or path to a yaml file.
        mapmaking_configs (list):
            Paths to mapmaking configuration yaml files.
        obs_ids (list):
            Observation ids.
            e.g., ['obs_1714550584_satp3_1111111']
        wafer_slot (str or None):
            If specified, select a subset of wafers
            e.g. 'ws0'
        wafer_bandpass (str or None):
            If specified, select a subset of bandpasses
            e.g. 'f150'
        verbosity (int):
            verboisity level of logging.Logger.
        output_path (str or None):
            If specified, create directories leading to the path and
            save the output map and configuration files.
        log_path (str or None):
            If specified, save output logs in a file of the given path.

    Returns:
        map_results (dict):
            Product of the mapmaking pipeline.
            The contents vary depending on the mapmaker used.
            See individual mapmakers for details on the outputs.
    """
    logger = init_logger('preprocess', verbosity=verbosity)
    logger.info('Initialised logger')

    logger.info(f'{len(obs_ids)} observations in the list')
    logger.info(f'{len(mapmaking_configs)} mapmakers')
    logger.info(mapmaking_configs)

    if wafer_slot is None and wafer_bandpass is None:
        det_select = None
    else:
        det_select = {}
        if wafer_slot:
            det_select['wafer_slot'] = wafer_slot
        if wafer_bandpass:
            det_select['wafer.bandpass'] = wafer_bandpass

    if log_path is not None:
        fh = logging.FileHandler(log_path)
        formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
        fh.setLevel(logging.DEBUG)
        fh.setFormatter(formatter)
        logger.addHandler(fh)

    preprocess_config = yaml.safe_load(open(preprocess_config))  # type: ignore[arg-type]
    logger.info('Preprocessing config loaded from file')

    for obs_id in obs_ids:
        # First check if we need to load this obs or not
        completed = True
        for mapmaking_config in mapmaking_configs:
            output_dir = get_output_dir(
                root_dir=output_path,  # type: ignore[arg-type]
                mapmaking_config=mapmaking_config,
                obs_id=obs_id,
                det_select=det_select,
            )
            if not os.path.exists(output_dir):
                completed = False
                break
        if completed:
            logger.info(f'Skipping {obs_id}...')
            continue

        # Load obs
        try:
            obs = load_and_preprocess(obs_id, preprocess_config, dets=det_select)
            logger.info('Observationa data loaded')
        except Exception as exception:
            logger.info(f'Loading failed for {obs_id}')
            logger.info(exception)
            continue

        for mapmaking_config in mapmaking_configs:
            # First check if we need to do mapmaking or not
            mm_name = os.path.basename(mapmaking_config)
            output_dir = get_output_dir(
                root_dir=output_path,  # type: ignore[arg-type]
                mapmaking_config=mapmaking_config,
                obs_id=obs_id,
                det_select=det_select,
            )
            if os.path.exists(output_dir):
                # Skip if the data exist already
                logger.info(f'Skipping [{mm_name}] on {obs_id}...')
                continue

            # Load mapmaking config
            config = MapMakingConfig.load_yaml(path=mapmaking_config)
            logger.info(f'Mapmaking config [{mm_name}] loaded from file')

            # Set up mapmaker and output path
            maker = MapMaker.from_config(config=config, logger=logger)
            observation = SOTODLibObservation(obs, config.sotodlib)

            # Make maps
            try:
                maker.run(observation=observation, out_dir=output_dir)
                logger.info('Mapmaking finished')
                logger.info(f'Output directory: {output_dir}')
            except Exception as exception:
                logger.info(f'Mapmaking failed for [{mm_name}] on {obs_id}')
                logger.info(exception)
                continue

        logger.info(f'Finished mapmaking for {obs_id}')
    logger.info('Finished mapmaking for all observations provided.')

get_output_dir(root_dir, mapmaking_config, obs_id, det_select=None)

Source code in src/furax/interfaces/sotodlib/mapmaker_multi.py
def get_output_dir(
    root_dir: str, mapmaking_config: str, obs_id: str, det_select: dict[str, Any] | None = None
) -> str:
    name = Path(mapmaking_config).stem
    output_dir = f'{root_dir}/{name}/{obs_id}'
    if det_select is not None:
        output_dir = f'{output_dir}/{det_select["wafer.bandpass"]}/{det_select["wafer_slot"]}'
    return output_dir

get_parser(parser=None)

Source code in src/furax/interfaces/sotodlib/mapmaker_multi.py
def get_parser(parser: argparse.ArgumentParser | None = None) -> argparse.ArgumentParser:
    if parser is None:
        parser = argparse.ArgumentParser()

    parser.add_argument('--preprocess-config', help='Preprocessing configuration file')
    parser.add_argument('--mapmaking-configs', nargs='+', help='Mapmaking configuration file(s)')
    parser.add_argument('--obs-ids', nargs='+', help='Observation id(s)')
    parser.add_argument('--wafer-slot', help='wafer slot selection', default=None)
    parser.add_argument('--wafer-bandpass', help='wafer bandpass selection', default=None)

    parser.add_argument(
        '--verbosity',
        help='Output verbosity level. 0:Error, 1:Warning, 2:Info(default), 3:Debug',
        default=3,
        type=int,
    )
    parser.add_argument('--output-path', help='File output path', default=None)
    parser.add_argument('--log-path', help='Log output path', default=None)

    return parser

main_cli()

Source code in src/furax/interfaces/sotodlib/mapmaker_multi.py
def main_cli() -> None:
    main_launcher(main, get_parser)

observation

Classes:

SOTODLibObservation

Bases: AbstractGroundObservation[AxisManager]

Class for interfacing with sotodlib's AxisManager.

Methods:

Attributes:

Source code in src/furax/interfaces/sotodlib/observation.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
class SOTODLibObservation(AbstractGroundObservation[AxisManager]):
    """Class for interfacing with sotodlib's AxisManager."""

    def __init__(self, data: AxisManager, sotodlib_config: SotodlibConfig | None = None) -> None:
        super().__init__(data)
        self._sotodlib_config = sotodlib_config or SotodlibConfig()

    @classmethod
    def from_file(
        cls,
        filename: str | Path,
        requested_fields: Collection[str] | None = None,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> SOTODLibObservation:
        # check that file exists
        if not Path(filename).exists():
            raise FileNotFoundError(f'File {filename} does not exist')
        if isinstance(filename, Path):
            filename = filename.as_posix()

        config = sotodlib_config or SotodlibConfig()
        if requested_fields is None:
            # default is to load everything
            data = AxisManager.load(filename, fields=None)
            return cls(data, config)

        requested = set(requested_fields)
        # minimum information needed to determine buffer shapes
        fields = {'dets', 'samp'}
        # translate request to sotodlib subfield names (sets dedup overlapping requests)
        if ReaderField.METADATA in requested:
            fields.add('obs_info')
        if ReaderField.SAMPLE_DATA in requested:
            if config.demodulated:
                fields |= {'dsT', 'demodQ', 'demodU'}
            else:
                fields.add('signal')
        if ReaderField.VALID_SAMPLE_MASKS in requested:
            fields.add('flags.glitch_flags')
        if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
            fields.add('preprocess.turnaround_flags')
        if ReaderField.LEFT_SCAN_MASK in requested:
            fields.add('flags.left_scan')
        if ReaderField.RIGHT_SCAN_MASK in requested:
            fields.add('flags.right_scan')
        if {ReaderField.AZIMUTH, ReaderField.ELEVATION} & requested:
            fields.add('boresight')
        if ReaderField.TIMESTAMPS in requested:
            fields.add('timestamps')
        if ReaderField.HWP_ANGLES in requested:
            fields.add('hwp_angle')
        if ReaderField.BORESIGHT_QUATERNIONS in requested:
            fields |= {'boresight', 'timestamps'}
            if config.wobble_correction:
                fields |= {'wobble_params', 'det_info', 'hwp_angle'}
        if ReaderField.DETECTOR_QUATERNIONS in requested:
            fields.add('focal_plane')
        if ReaderField.NOISE_MODEL_FITS in requested:
            if config.noise_source == 'mapmaking':
                fields.add('preprocess.noiseQ_mapmaking')
            else:
                fields |= {'preprocess.noiseT', 'preprocess.noiseQ', 'preprocess.noiseU'}

        data = AxisManager.load(filename, fields=list(fields))
        return cls(data, config)

    @classmethod
    def from_preprocess(
        cls,
        preprocess_config: str | Path | dict[str, Any],
        observation_id: str,
        detector_selection: dict[str, str] | None = None,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> SOTODLibObservation:
        """Loads and preprocesses an observation.

        Args:
            preprocess_config: Preprocessing configuration as a path to a yaml file or dictionary.
            observation_id: Observation id.
                (e.g. 'obs_1714550584_satp3_1111111').
            detector_selection: Optional dictionary to select a subset of detectors
                (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
            sotodlib_config: Optional sotodlib-specific configuration.

        Returns:
            An instance of SOTODLibObservation.
        """
        if isinstance(preprocess_config, dict):
            config = preprocess_config
        else:
            # load the preprocessing config from a yaml file
            with open(preprocess_config) as file:
                config = yaml.safe_load(file)

        data = pu.load_and_preprocess(observation_id, config, dets=detector_selection)
        return cls(data, sotodlib_config)

    @classmethod
    def from_preproc_group(
        cls,
        observation_id: str,
        init_config: str | Path,
        proc_config: str | Path | None = None,
        detector_selection: dict[str, str] | None = None,
        downsample: int = 1,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> SOTODLibObservation:
        """Loads a (already preprocessed) observation directly from the preprocessing db.

        This reads the saved preprocessing products from the archive and re-applies the
        process pipeline, without writing any intermediate file. It mirrors the load path
        of ``preproc_or_load_group`` (as used by the ``furax-so-prepare`` tool) but skips
        the archive/proc-aman saving, so the result is identical to mapping a binary file
        produced by that tool.

        Args:
            observation_id: Observation id (e.g. 'obs_1714550584_satp3_1111111').
            init_config: Base-layer preprocessing config (path or posix string).
            proc_config: Optional second-layer preprocessing config. If given, the
                two-layer (init+proc) load path is used.
            detector_selection: Optional detector restriction
                (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
            downsample: Integer downsampling factor applied after preprocessing.
            sotodlib_config: Optional sotodlib-specific configuration.

        Returns:
            An instance of SOTODLibObservation.

        Raises:
            RuntimeError: If no detectors remain after cuts (nothing to load).
        """
        _enable_preproc_context_cache()
        init_config = Path(init_config).as_posix()
        if proc_config is not None:
            aman = pu.multilayer_load_and_preprocess(
                observation_id,
                init_config,
                Path(proc_config).as_posix(),
                dets=detector_selection,
            )
        else:
            # load_and_preprocess returns (aman, full_aman); we only need the restricted one
            result = pu.load_and_preprocess(observation_id, init_config, dets=detector_selection)
            aman = result[0] if isinstance(result, tuple) else result
        if aman is None:
            raise RuntimeError(f'no detectors left after cuts for {observation_id}')
        if downsample > 1:
            aman = downsample_obs(aman, downsample)
        return cls(aman, sotodlib_config)

    @property
    def name(self) -> str:
        return self.data.obs_info.obs_id  # type: ignore[no-any-return]

    @property
    def telescope(self) -> str:
        return self.data.obs_info.get('telescope')  # type: ignore[no-any-return]

    @property
    def n_samples(self) -> int:
        return self.data.samps.count  # type: ignore[no-any-return]

    @property
    def detectors(self) -> list[str]:
        return self.data.dets.vals  # type: ignore[no-any-return]

    @property
    def n_detectors(self) -> int:
        return self.data.dets.count  # type: ignore[no-any-return]

    @property
    def sample_rate(self) -> float:
        """Returns the sampling rate (in Hz) of the data."""
        duration: float = self.data.timestamps[-1] - self.data.timestamps[0]
        return (self.n_samples - 1) / duration

    def get_tods(self) -> Float[np.ndarray, 'dets samps']:
        """Returns the timestream data."""
        # furax's LinearPolarizerOperator assumes power, sotodlib assumes temperature
        tods = np.asarray(self.data.signal, dtype=np.float64)
        return 0.5 * np.atleast_2d(tods)

    @overload
    def get_demodulated_tods(self, stokes: Literal['I']) -> StokesI: ...
    @overload
    def get_demodulated_tods(self, stokes: Literal['QU']) -> StokesQU: ...
    @overload
    def get_demodulated_tods(self, stokes: Literal['IQU']) -> StokesIQU: ...
    @overload
    def get_demodulated_tods(self, stokes: Literal['IQUV']) -> StokesIQUV: ...
    def get_demodulated_tods(self, stokes: ValidStokesType = 'IQU') -> StokesType:
        """Returns the demodulated timestream data as a Stokes pytree.

        'IQUV' is not supported.
        """
        if stokes == 'IQUV':
            raise NotImplementedError
        kls = Stokes.class_for(stokes)
        tods = [self._get_demodulated_tod(s) for s in stokes]  # type: ignore[arg-type]
        return kls.from_array(np.stack(tods, axis=0))

    def _get_demodulated_tod(self, stoke: Literal['I', 'Q', 'U']) -> NDArray[np.float64]:
        attr = {'I': 'dsT', 'Q': 'demodQ', 'U': 'demodU'}[stoke]
        tod = np.asarray(getattr(self.data, attr), dtype=np.float64)
        return 0.5 * np.atleast_2d(tod)

    def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
        """Returns the detector offset angles."""
        return np.asarray(self.data.focal_plane['gamma'])

    def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
        """Returns the HWP angles."""
        return np.asarray(self.data.hwp_angle)

    def get_scanning_intervals(self, det_ind: int = 0) -> NDArray[Any]:
        """Returns scanning intervals of the chosen detector.

        The output is a list of the starting and ending sample indices
        """
        return np.array(
            self.data.preprocess.turnaround_flags.turnarounds.ranges[det_ind].complement().ranges()
        )

    def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
        try:
            return np.asarray((~self.data.flags.glitch_flags).mask(), dtype=bool)
        except KeyError as e:
            raise RuntimeError('Glitch flags unavailable in the observation') from e

    def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        try:
            # Stored per-detector (RangesMatrix), but scan direction is shared across the
            # focal plane; collapse to the boresight 1D mask (cf. get_scanning_intervals).
            return np.asarray(self.data.flags.left_scan.mask()[0], dtype=bool)
        except KeyError as e:
            raise RuntimeError('Scan mask unavailable in the observation') from e

    def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        try:
            return np.asarray(self.data.flags.right_scan.mask()[0], dtype=bool)
        except KeyError as e:
            raise RuntimeError('Scan mask unavailable in the observation') from e

    def get_azimuth(self) -> Float[np.ndarray, ' a']:
        """Returns the azimuth of the boresight for each sample."""
        return np.asarray(self.data.boresight.az)

    def get_elevation(self) -> Float[np.ndarray, ' a']:
        """Returns the elevation of the boresight for each sample."""
        return np.asarray(self.data.boresight.el)

    def get_wcs_shape_and_kernel(
        self,
        resolution_arcmin: float,
        projection: ProjectionType = ProjectionType.CAR,
    ) -> tuple[tuple[int, int], WCS]:
        """Returns astropy WCS kernel object corresponding to the observed sky."""
        res = resolution_arcmin * pixell.utils.arcmin
        wcs_kernel_init = coords.get_wcs_kernel(projection.name.lower(), 0, 0, res=res)
        wcs_shape, wcs_kernel = coords.get_footprint(self.data, wcs_kernel_init)

        return wcs_shape, wcs_kernel

    def get_pointing_and_spin_angles(
        self, landscape: StokesLandscape
    ) -> tuple[Integer[Array, 'dets samps 2'], Float[Array, 'dets samps']]:
        """Obtain pointing information and spin angles from the observation."""
        # Projection Matrix class instance for the observation
        if isinstance(landscape, AstropyWCSLandscape):
            # TODO: pass 'cuts' keyword here for time slices (glitches etc)?
            P = coords.P.for_tod(self.data, wcs_kernel=landscape.wcs, comps='TQU', hwp=True)
        elif isinstance(landscape, HealpixLandscape):
            hp_geom = coords.healpix_utils.get_geometry(nside=landscape.nside, ordering='RING')
            P = coords.P.for_tod(self.data, geom=hp_geom)
        else:
            raise NotImplementedError(f'Landscape {landscape} not supported')

        # Projectionist object from P
        proj = P._get_proj()

        # Assembly containing the focal plane and boresight information
        assembly = P._get_asm()

        # Get the pixel indices as before, but also obtain
        # the spin projection factors of size (n_samps,n_comps) for each detector,
        # which have 1, cos(2*p), sin(2*p) where p is the parallactic angle
        pixel_inds, spin_proj = proj.get_pointing_matrix(assembly)

        # TODO: check if this could be jnp array directly
        pixel_inds = np.array(pixel_inds)

        spin_proj = jnp.array(spin_proj, dtype=landscape.dtype)
        spin_ang = jnp.arctan2(spin_proj[..., 2], spin_proj[..., 1]) / 2.0

        return pixel_inds, spin_ang  # type: ignore[return-value]

    def get_timestamps(self) -> Float[np.ndarray, ' a']:
        """Returns timestamps (sec) of the samples."""
        return np.asarray(self.data.timestamps)

    def get_scanning_mask(self) -> Bool[np.ndarray, ' samp']:
        # Assume that all detectors have the same scanning intervals
        return np.asarray(
            self.data.preprocess.turnaround_flags.turnarounds.ranges[0].complement().mask(),
            dtype=bool,
        )

    def get_noise_model(self) -> None | NoiseModel:
        """Load precomputed noise model from the data, if present. Otherwise, return None."""
        try:
            fit = self._get_noise_fit_for_stoke('I')
        except ValueError:
            return None
        return AtmosphericNoiseModel(*fit)

    def get_demodulated_noise_model(self, stokes: ValidStokesType = 'IQU') -> NoiseModel:
        """Returns a single noise model covering every requested Stokes leg.

        Each Stokes leg is fit independently (I/Q/U noise properties genuinely differ), so the
        per-detector parameters carry a leading Stokes axis rather than being separate models.
        """
        if stokes == 'IQUV':
            raise NotImplementedError
        fits = np.stack([self._get_noise_fit_for_stoke(s) for s in stokes], axis=1)  # type: ignore[arg-type]
        return AtmosphericNoiseModel(*fits)

    def _get_noise_fit_for_stoke(self, stoke: Literal['I', 'Q', 'U']) -> NDArray[np.floating]:
        """Returns the (sigma, alpha, fk, f0) fit rows for one Stokes leg, shape ``(4, dets)``."""
        preproc = self.data.get('preprocess')
        if preproc is None:
            raise ValueError('No preprocess data available')
        if self._sotodlib_config.noise_source == 'mapmaking':
            sigma = np.asarray(preproc['noiseQ_mapmaking.white_noise'])
            ones = np.ones_like(sigma)  # fake alpha, fk, f0
            return np.stack([sigma, ones, ones, ones], axis=0)
        attr = {'I': 'noiseT', 'Q': 'noiseQ', 'U': 'noiseU'}[stoke]
        if attr not in preproc:
            raise ValueError(f'No {attr} noise model available')
        fit = getattr(preproc, attr)
        # sotodlib fit's columns: (w, fknee, alpha), with
        # psd = wn**2 * (1 + (fknee / f) ** alpha)
        # Note the difference in the sign of alpha
        expected = ['white_noise', 'fknee', 'alpha']
        actual = list(fit.noise_model_coeffs.vals)
        if actual != expected:
            raise ValueError(f'Unexpected noise model coefficients: {actual}, expected {expected}')
        sigma = np.asarray(fit.fit[:, 0])
        alpha = np.asarray(-fit.fit[:, 2])
        fk = np.asarray(fit.fit[:, 1])
        f0 = 1e-5 * np.ones_like(fk)
        return np.stack([sigma, alpha, fk, f0], axis=0)

    '''
    def get_noise_fits(self, fmin: float) -> NDArray[np.float64]:
        """Returns fitted values of the noise psd with 1/f and white noise,
        either using the fitted parameters from the preprocessing,
        or fitting the model directly.
        #TODO: reimplement below by calling get_noise_model() instead
        """
        preproc = self.observation.preprocess

        if 'psdT' in preproc:
            f = preproc.psdT.freqs
            fit = preproc.noiseT_fit.fit  # columns: (fknee, w, alpha)
        elif 'Pxx_raw' in preproc:
            f = preproc.Pxx_raw.freqs
            fit = preproc.noise_signal_fit.fit  # columns: (fknee, w, alpha)
        else:
            # Estimate psd
            raise NotImplementedError('Self-psd evaluation not implemented')
        imin = np.argmin(np.abs(f - fmin))
        noiseT_fit_eval = np.zeros((fit.shape[0], f.size), dtype=float)  # (dets, freqs)
        noiseT_fit_eval[:, imin:] = fit[:, [1]] * (
            1 + (fit[:, [0]] / f[None, imin:]) ** fit[:, [2]]
        )
        noiseT_fit_eval[:, :imin] = noiseT_fit_eval[:, [imin]]

        return np.array(noiseT_fit_eval)

    def get_white_noise_fit(self) -> NDArray[np.float64]:
        """Returns fitted values of the white noise,
        obtained as a result of a 1/f + white noise model fitting.
        Uses either the fitted parameters from the preprocessing,
        or fitting the model directly.
        """

        preproc = self.observation.preprocess
        if 'psdT' in preproc:
            fit = preproc.noiseT_fit.fit  # columns: (fknee, w, alpha)
        elif 'Pxx_raw' in preproc:
            fit = preproc.noise_signal_fit.fit  # columns: (fknee, w, alpha)
        else:
            # Estimate psd
            raise NotImplementedError('Self-psd evaluation not implemented')
        return fit[:, 1]  # type: ignore[no-any-return]
    '''

    def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
        """Returns the boresight quaternions at each time sample."""
        site = self._sotodlib_config.site
        weather = self._sotodlib_config.weather
        if self._sotodlib_config.wobble_correction:
            wobble = self.data.get('wobble_params')
            if wobble is None:
                raise ValueError('wobble_params not found in observation data')
            csl = get_deflected_sightline(self.data, wobble, site=site, weather=weather)
        else:
            csl = so3g.proj.CelestialSightLine.az_el(
                self.data.timestamps,
                self.data.boresight.az,
                self.data.boresight.el,
                roll=self.data.boresight.roll,
                site=site,
                weather=weather,
            )
        return np.asarray(csl.Q, dtype=np.float64)

    def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
        """Returns the quaternion offsets of the detectors."""
        # Use so3g's CPU implementation so the result stays on host
        # quaternion convention is the same ordering (1,i,j,k)
        fp = self.data.focal_plane
        quats = so3g.proj.quat.rotation_xieta(fp.xi, fp.eta, fp.gamma)
        return np.atleast_2d(np.asarray(quats, dtype=np.float64))
name property
telescope property
n_samples property
detectors property
n_detectors property
sample_rate property

Returns the sampling rate (in Hz) of the data.

__init__(data, sotodlib_config=None)
Source code in src/furax/interfaces/sotodlib/observation.py
def __init__(self, data: AxisManager, sotodlib_config: SotodlibConfig | None = None) -> None:
    super().__init__(data)
    self._sotodlib_config = sotodlib_config or SotodlibConfig()
from_file(filename, requested_fields=None, sotodlib_config=None) classmethod
Source code in src/furax/interfaces/sotodlib/observation.py
@classmethod
def from_file(
    cls,
    filename: str | Path,
    requested_fields: Collection[str] | None = None,
    sotodlib_config: SotodlibConfig | None = None,
) -> SOTODLibObservation:
    # check that file exists
    if not Path(filename).exists():
        raise FileNotFoundError(f'File {filename} does not exist')
    if isinstance(filename, Path):
        filename = filename.as_posix()

    config = sotodlib_config or SotodlibConfig()
    if requested_fields is None:
        # default is to load everything
        data = AxisManager.load(filename, fields=None)
        return cls(data, config)

    requested = set(requested_fields)
    # minimum information needed to determine buffer shapes
    fields = {'dets', 'samp'}
    # translate request to sotodlib subfield names (sets dedup overlapping requests)
    if ReaderField.METADATA in requested:
        fields.add('obs_info')
    if ReaderField.SAMPLE_DATA in requested:
        if config.demodulated:
            fields |= {'dsT', 'demodQ', 'demodU'}
        else:
            fields.add('signal')
    if ReaderField.VALID_SAMPLE_MASKS in requested:
        fields.add('flags.glitch_flags')
    if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
        fields.add('preprocess.turnaround_flags')
    if ReaderField.LEFT_SCAN_MASK in requested:
        fields.add('flags.left_scan')
    if ReaderField.RIGHT_SCAN_MASK in requested:
        fields.add('flags.right_scan')
    if {ReaderField.AZIMUTH, ReaderField.ELEVATION} & requested:
        fields.add('boresight')
    if ReaderField.TIMESTAMPS in requested:
        fields.add('timestamps')
    if ReaderField.HWP_ANGLES in requested:
        fields.add('hwp_angle')
    if ReaderField.BORESIGHT_QUATERNIONS in requested:
        fields |= {'boresight', 'timestamps'}
        if config.wobble_correction:
            fields |= {'wobble_params', 'det_info', 'hwp_angle'}
    if ReaderField.DETECTOR_QUATERNIONS in requested:
        fields.add('focal_plane')
    if ReaderField.NOISE_MODEL_FITS in requested:
        if config.noise_source == 'mapmaking':
            fields.add('preprocess.noiseQ_mapmaking')
        else:
            fields |= {'preprocess.noiseT', 'preprocess.noiseQ', 'preprocess.noiseU'}

    data = AxisManager.load(filename, fields=list(fields))
    return cls(data, config)
from_preprocess(preprocess_config, observation_id, detector_selection=None, sotodlib_config=None) classmethod

Loads and preprocesses an observation.

Parameters:

  • preprocess_config (str | Path | dict[str, Any]) –

    Preprocessing configuration as a path to a yaml file or dictionary.

  • observation_id (str) –

    Observation id. (e.g. 'obs_1714550584_satp3_1111111').

  • detector_selection (dict[str, str] | None, default: None ) –

    Optional dictionary to select a subset of detectors (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).

  • sotodlib_config (SotodlibConfig | None, default: None ) –

    Optional sotodlib-specific configuration.

Returns:

Source code in src/furax/interfaces/sotodlib/observation.py
@classmethod
def from_preprocess(
    cls,
    preprocess_config: str | Path | dict[str, Any],
    observation_id: str,
    detector_selection: dict[str, str] | None = None,
    sotodlib_config: SotodlibConfig | None = None,
) -> SOTODLibObservation:
    """Loads and preprocesses an observation.

    Args:
        preprocess_config: Preprocessing configuration as a path to a yaml file or dictionary.
        observation_id: Observation id.
            (e.g. 'obs_1714550584_satp3_1111111').
        detector_selection: Optional dictionary to select a subset of detectors
            (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
        sotodlib_config: Optional sotodlib-specific configuration.

    Returns:
        An instance of SOTODLibObservation.
    """
    if isinstance(preprocess_config, dict):
        config = preprocess_config
    else:
        # load the preprocessing config from a yaml file
        with open(preprocess_config) as file:
            config = yaml.safe_load(file)

    data = pu.load_and_preprocess(observation_id, config, dets=detector_selection)
    return cls(data, sotodlib_config)
from_preproc_group(observation_id, init_config, proc_config=None, detector_selection=None, downsample=1, sotodlib_config=None) classmethod

Loads a (already preprocessed) observation directly from the preprocessing db.

This reads the saved preprocessing products from the archive and re-applies the process pipeline, without writing any intermediate file. It mirrors the load path of preproc_or_load_group (as used by the furax-so-prepare tool) but skips the archive/proc-aman saving, so the result is identical to mapping a binary file produced by that tool.

Parameters:

  • observation_id (str) –

    Observation id (e.g. 'obs_1714550584_satp3_1111111').

  • init_config (str | Path) –

    Base-layer preprocessing config (path or posix string).

  • proc_config (str | Path | None, default: None ) –

    Optional second-layer preprocessing config. If given, the two-layer (init+proc) load path is used.

  • detector_selection (dict[str, str] | None, default: None ) –

    Optional detector restriction (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).

  • downsample (int, default: 1 ) –

    Integer downsampling factor applied after preprocessing.

  • sotodlib_config (SotodlibConfig | None, default: None ) –

    Optional sotodlib-specific configuration.

Returns:

Raises:

  • RuntimeError

    If no detectors remain after cuts (nothing to load).

Source code in src/furax/interfaces/sotodlib/observation.py
@classmethod
def from_preproc_group(
    cls,
    observation_id: str,
    init_config: str | Path,
    proc_config: str | Path | None = None,
    detector_selection: dict[str, str] | None = None,
    downsample: int = 1,
    sotodlib_config: SotodlibConfig | None = None,
) -> SOTODLibObservation:
    """Loads a (already preprocessed) observation directly from the preprocessing db.

    This reads the saved preprocessing products from the archive and re-applies the
    process pipeline, without writing any intermediate file. It mirrors the load path
    of ``preproc_or_load_group`` (as used by the ``furax-so-prepare`` tool) but skips
    the archive/proc-aman saving, so the result is identical to mapping a binary file
    produced by that tool.

    Args:
        observation_id: Observation id (e.g. 'obs_1714550584_satp3_1111111').
        init_config: Base-layer preprocessing config (path or posix string).
        proc_config: Optional second-layer preprocessing config. If given, the
            two-layer (init+proc) load path is used.
        detector_selection: Optional detector restriction
            (e.g. {'wafer_slot': 'ws0', 'wafer.bandpass': 'f150'}).
        downsample: Integer downsampling factor applied after preprocessing.
        sotodlib_config: Optional sotodlib-specific configuration.

    Returns:
        An instance of SOTODLibObservation.

    Raises:
        RuntimeError: If no detectors remain after cuts (nothing to load).
    """
    _enable_preproc_context_cache()
    init_config = Path(init_config).as_posix()
    if proc_config is not None:
        aman = pu.multilayer_load_and_preprocess(
            observation_id,
            init_config,
            Path(proc_config).as_posix(),
            dets=detector_selection,
        )
    else:
        # load_and_preprocess returns (aman, full_aman); we only need the restricted one
        result = pu.load_and_preprocess(observation_id, init_config, dets=detector_selection)
        aman = result[0] if isinstance(result, tuple) else result
    if aman is None:
        raise RuntimeError(f'no detectors left after cuts for {observation_id}')
    if downsample > 1:
        aman = downsample_obs(aman, downsample)
    return cls(aman, sotodlib_config)
get_tods()

Returns the timestream data.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_tods(self) -> Float[np.ndarray, 'dets samps']:
    """Returns the timestream data."""
    # furax's LinearPolarizerOperator assumes power, sotodlib assumes temperature
    tods = np.asarray(self.data.signal, dtype=np.float64)
    return 0.5 * np.atleast_2d(tods)
get_demodulated_tods(stokes='IQU')
get_demodulated_tods(stokes: Literal['I']) -> StokesI
get_demodulated_tods(stokes: Literal['QU']) -> StokesQU
get_demodulated_tods(stokes: Literal['IQU']) -> StokesIQU
get_demodulated_tods(stokes: Literal['IQUV']) -> StokesIQUV

Returns the demodulated timestream data as a Stokes pytree.

'IQUV' is not supported.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_demodulated_tods(self, stokes: ValidStokesType = 'IQU') -> StokesType:
    """Returns the demodulated timestream data as a Stokes pytree.

    'IQUV' is not supported.
    """
    if stokes == 'IQUV':
        raise NotImplementedError
    kls = Stokes.class_for(stokes)
    tods = [self._get_demodulated_tod(s) for s in stokes]  # type: ignore[arg-type]
    return kls.from_array(np.stack(tods, axis=0))
get_detector_offset_angles()

Returns the detector offset angles.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
    """Returns the detector offset angles."""
    return np.asarray(self.data.focal_plane['gamma'])
get_hwp_angles()

Returns the HWP angles.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
    """Returns the HWP angles."""
    return np.asarray(self.data.hwp_angle)
get_scanning_intervals(det_ind=0)

Returns scanning intervals of the chosen detector.

The output is a list of the starting and ending sample indices

Source code in src/furax/interfaces/sotodlib/observation.py
def get_scanning_intervals(self, det_ind: int = 0) -> NDArray[Any]:
    """Returns scanning intervals of the chosen detector.

    The output is a list of the starting and ending sample indices
    """
    return np.array(
        self.data.preprocess.turnaround_flags.turnarounds.ranges[det_ind].complement().ranges()
    )
get_sample_mask()
Source code in src/furax/interfaces/sotodlib/observation.py
def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
    try:
        return np.asarray((~self.data.flags.glitch_flags).mask(), dtype=bool)
    except KeyError as e:
        raise RuntimeError('Glitch flags unavailable in the observation') from e
get_left_scan_mask()
Source code in src/furax/interfaces/sotodlib/observation.py
def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    try:
        # Stored per-detector (RangesMatrix), but scan direction is shared across the
        # focal plane; collapse to the boresight 1D mask (cf. get_scanning_intervals).
        return np.asarray(self.data.flags.left_scan.mask()[0], dtype=bool)
    except KeyError as e:
        raise RuntimeError('Scan mask unavailable in the observation') from e
get_right_scan_mask()
Source code in src/furax/interfaces/sotodlib/observation.py
def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    try:
        return np.asarray(self.data.flags.right_scan.mask()[0], dtype=bool)
    except KeyError as e:
        raise RuntimeError('Scan mask unavailable in the observation') from e
get_azimuth()

Returns the azimuth of the boresight for each sample.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_azimuth(self) -> Float[np.ndarray, ' a']:
    """Returns the azimuth of the boresight for each sample."""
    return np.asarray(self.data.boresight.az)
get_elevation()

Returns the elevation of the boresight for each sample.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_elevation(self) -> Float[np.ndarray, ' a']:
    """Returns the elevation of the boresight for each sample."""
    return np.asarray(self.data.boresight.el)
get_wcs_shape_and_kernel(resolution_arcmin, projection=ProjectionType.CAR)

Returns astropy WCS kernel object corresponding to the observed sky.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_wcs_shape_and_kernel(
    self,
    resolution_arcmin: float,
    projection: ProjectionType = ProjectionType.CAR,
) -> tuple[tuple[int, int], WCS]:
    """Returns astropy WCS kernel object corresponding to the observed sky."""
    res = resolution_arcmin * pixell.utils.arcmin
    wcs_kernel_init = coords.get_wcs_kernel(projection.name.lower(), 0, 0, res=res)
    wcs_shape, wcs_kernel = coords.get_footprint(self.data, wcs_kernel_init)

    return wcs_shape, wcs_kernel
get_pointing_and_spin_angles(landscape)

Obtain pointing information and spin angles from the observation.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_pointing_and_spin_angles(
    self, landscape: StokesLandscape
) -> tuple[Integer[Array, 'dets samps 2'], Float[Array, 'dets samps']]:
    """Obtain pointing information and spin angles from the observation."""
    # Projection Matrix class instance for the observation
    if isinstance(landscape, AstropyWCSLandscape):
        # TODO: pass 'cuts' keyword here for time slices (glitches etc)?
        P = coords.P.for_tod(self.data, wcs_kernel=landscape.wcs, comps='TQU', hwp=True)
    elif isinstance(landscape, HealpixLandscape):
        hp_geom = coords.healpix_utils.get_geometry(nside=landscape.nside, ordering='RING')
        P = coords.P.for_tod(self.data, geom=hp_geom)
    else:
        raise NotImplementedError(f'Landscape {landscape} not supported')

    # Projectionist object from P
    proj = P._get_proj()

    # Assembly containing the focal plane and boresight information
    assembly = P._get_asm()

    # Get the pixel indices as before, but also obtain
    # the spin projection factors of size (n_samps,n_comps) for each detector,
    # which have 1, cos(2*p), sin(2*p) where p is the parallactic angle
    pixel_inds, spin_proj = proj.get_pointing_matrix(assembly)

    # TODO: check if this could be jnp array directly
    pixel_inds = np.array(pixel_inds)

    spin_proj = jnp.array(spin_proj, dtype=landscape.dtype)
    spin_ang = jnp.arctan2(spin_proj[..., 2], spin_proj[..., 1]) / 2.0

    return pixel_inds, spin_ang  # type: ignore[return-value]
get_timestamps()

Returns timestamps (sec) of the samples.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_timestamps(self) -> Float[np.ndarray, ' a']:
    """Returns timestamps (sec) of the samples."""
    return np.asarray(self.data.timestamps)
get_scanning_mask()
Source code in src/furax/interfaces/sotodlib/observation.py
def get_scanning_mask(self) -> Bool[np.ndarray, ' samp']:
    # Assume that all detectors have the same scanning intervals
    return np.asarray(
        self.data.preprocess.turnaround_flags.turnarounds.ranges[0].complement().mask(),
        dtype=bool,
    )
get_noise_model()

Load precomputed noise model from the data, if present. Otherwise, return None.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_noise_model(self) -> None | NoiseModel:
    """Load precomputed noise model from the data, if present. Otherwise, return None."""
    try:
        fit = self._get_noise_fit_for_stoke('I')
    except ValueError:
        return None
    return AtmosphericNoiseModel(*fit)
get_demodulated_noise_model(stokes='IQU')

Returns a single noise model covering every requested Stokes leg.

Each Stokes leg is fit independently (I/Q/U noise properties genuinely differ), so the per-detector parameters carry a leading Stokes axis rather than being separate models.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_demodulated_noise_model(self, stokes: ValidStokesType = 'IQU') -> NoiseModel:
    """Returns a single noise model covering every requested Stokes leg.

    Each Stokes leg is fit independently (I/Q/U noise properties genuinely differ), so the
    per-detector parameters carry a leading Stokes axis rather than being separate models.
    """
    if stokes == 'IQUV':
        raise NotImplementedError
    fits = np.stack([self._get_noise_fit_for_stoke(s) for s in stokes], axis=1)  # type: ignore[arg-type]
    return AtmosphericNoiseModel(*fits)
get_boresight_quaternions()

Returns the boresight quaternions at each time sample.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
    """Returns the boresight quaternions at each time sample."""
    site = self._sotodlib_config.site
    weather = self._sotodlib_config.weather
    if self._sotodlib_config.wobble_correction:
        wobble = self.data.get('wobble_params')
        if wobble is None:
            raise ValueError('wobble_params not found in observation data')
        csl = get_deflected_sightline(self.data, wobble, site=site, weather=weather)
    else:
        csl = so3g.proj.CelestialSightLine.az_el(
            self.data.timestamps,
            self.data.boresight.az,
            self.data.boresight.el,
            roll=self.data.boresight.roll,
            site=site,
            weather=weather,
        )
    return np.asarray(csl.Q, dtype=np.float64)
get_detector_quaternions()

Returns the quaternion offsets of the detectors.

Source code in src/furax/interfaces/sotodlib/observation.py
def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
    """Returns the quaternion offsets of the detectors."""
    # Use so3g's CPU implementation so the result stays on host
    # quaternion convention is the same ordering (1,i,j,k)
    fp = self.data.focal_plane
    quats = so3g.proj.quat.rotation_xieta(fp.xi, fp.eta, fp.gamma)
    return np.atleast_2d(np.asarray(quats, dtype=np.float64))

LazySOTODLibObservation

Bases: FileBackedLazyObservation[AxisManager]

Methods:

Attributes:

Source code in src/furax/interfaces/sotodlib/observation.py
class LazySOTODLibObservation(FileBackedLazyObservation[AxisManager]):
    interface_class = SOTODLibObservation

    def __init__(self, filename: str | Path, sotodlib_config: SotodlibConfig | None = None) -> None:
        super().__init__(filename)
        self._sotodlib_config = sotodlib_config

    def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
        return SOTODLibObservation.from_file(
            self.file, requested_fields, sotodlib_config=self._sotodlib_config
        )
interface_class = SOTODLibObservation class-attribute instance-attribute
__init__(filename, sotodlib_config=None)
Source code in src/furax/interfaces/sotodlib/observation.py
def __init__(self, filename: str | Path, sotodlib_config: SotodlibConfig | None = None) -> None:
    super().__init__(filename)
    self._sotodlib_config = sotodlib_config
get_data(requested_fields=None)
Source code in src/furax/interfaces/sotodlib/observation.py
def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
    return SOTODLibObservation.from_file(
        self.file, requested_fields, sotodlib_config=self._sotodlib_config
    )

LazyPreprocSOTODLibObservation

Bases: AbstractLazyObservation[AxisManager]

Lazy observation backed by the preprocessing database (no intermediate file).

Loads on demand via SOTODLibObservation.from_preproc_group, so observations are streamed straight from the preproc archive at mapmaking time and never copied to disk.

Methods:

Attributes:

Source code in src/furax/interfaces/sotodlib/observation.py
class LazyPreprocSOTODLibObservation(AbstractLazyObservation[AxisManager]):
    """Lazy observation backed by the preprocessing database (no intermediate file).

    Loads on demand via ``SOTODLibObservation.from_preproc_group``, so observations are
    streamed straight from the preproc archive at mapmaking time and never copied to disk.
    """

    interface_class = SOTODLibObservation

    def __init__(
        self,
        observation_id: str,
        init_config: str | Path,
        proc_config: str | Path | None = None,
        detector_selection: dict[str, str] | None = None,
        downsample: int = 1,
        sotodlib_config: SotodlibConfig | None = None,
    ) -> None:
        self.observation_id = observation_id
        self.init_config = Path(init_config).resolve()
        self.proc_config = Path(proc_config).resolve() if proc_config else None
        self.detector_selection = detector_selection
        self.downsample = downsample
        self._sotodlib_config = sotodlib_config

    @property
    def name(self) -> str:
        return self.observation_id

    def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
        # A preproc load cannot be field-subset: the full observation is always returned,
        # which satisfies any requested fields. The cheap shape path lives in probe_shape.
        return SOTODLibObservation.from_preproc_group(
            self.observation_id,
            self.init_config,
            self.proc_config,
            self.detector_selection,
            downsample=self.downsample,
            sotodlib_config=self._sotodlib_config,
        )

    def probe_shape(self, intervals: bool = False) -> ObservationBufferShapes:
        """Returns an upper bound on the variable padded-buffer dimensions for ``fields``.

        All values are read from the init preprocess metadata: no heavy I/O work is done.

        The value is only an *upper bound*, not the exact post-pipeline shape:

        - ``n_detectors`` is the count after applying ``detector_selection`` (wafer slot / bandpass)
          but before the process pipeline, which only ever restricts detectors further.
        - ``n_samples`` is ``ceil(meta.samps.count / downsample)``. The process pipeline only trims
          samples (e.g. edge cuts) before downsampling, so this bounds the actual samps count.
        """
        _enable_preproc_context_cache()
        # call get_preprocess_context through the module so the cached get_preprocess_context is used
        # (str, not Path: sotodlib and the cache key expect a posix string)
        _, context = pu.get_preprocess_context(self.init_config.as_posix())
        meta = context.get_meta(self.observation_id, dets=self.detector_selection)
        n_samps_ub = -(-meta.samps.count // self.downsample)  # ceil, matches downsample_obs
        return ObservationBufferShapes(
            meta.dets.count,
            n_samps_ub,
            meta.subscans.count if intervals else 0,
        )
interface_class = SOTODLibObservation class-attribute instance-attribute
observation_id = observation_id instance-attribute
init_config = Path(init_config).resolve() instance-attribute
proc_config = Path(proc_config).resolve() if proc_config else None instance-attribute
detector_selection = detector_selection instance-attribute
downsample = downsample instance-attribute
name property
__init__(observation_id, init_config, proc_config=None, detector_selection=None, downsample=1, sotodlib_config=None)
Source code in src/furax/interfaces/sotodlib/observation.py
def __init__(
    self,
    observation_id: str,
    init_config: str | Path,
    proc_config: str | Path | None = None,
    detector_selection: dict[str, str] | None = None,
    downsample: int = 1,
    sotodlib_config: SotodlibConfig | None = None,
) -> None:
    self.observation_id = observation_id
    self.init_config = Path(init_config).resolve()
    self.proc_config = Path(proc_config).resolve() if proc_config else None
    self.detector_selection = detector_selection
    self.downsample = downsample
    self._sotodlib_config = sotodlib_config
get_data(requested_fields=None)
Source code in src/furax/interfaces/sotodlib/observation.py
def get_data(self, requested_fields: Collection[str] | None = None) -> SOTODLibObservation:
    # A preproc load cannot be field-subset: the full observation is always returned,
    # which satisfies any requested fields. The cheap shape path lives in probe_shape.
    return SOTODLibObservation.from_preproc_group(
        self.observation_id,
        self.init_config,
        self.proc_config,
        self.detector_selection,
        downsample=self.downsample,
        sotodlib_config=self._sotodlib_config,
    )
probe_shape(intervals=False)

Returns an upper bound on the variable padded-buffer dimensions for fields.

All values are read from the init preprocess metadata: no heavy I/O work is done.

The value is only an upper bound, not the exact post-pipeline shape:

  • n_detectors is the count after applying detector_selection (wafer slot / bandpass) but before the process pipeline, which only ever restricts detectors further.
  • n_samples is ceil(meta.samps.count / downsample). The process pipeline only trims samples (e.g. edge cuts) before downsampling, so this bounds the actual samps count.
Source code in src/furax/interfaces/sotodlib/observation.py
def probe_shape(self, intervals: bool = False) -> ObservationBufferShapes:
    """Returns an upper bound on the variable padded-buffer dimensions for ``fields``.

    All values are read from the init preprocess metadata: no heavy I/O work is done.

    The value is only an *upper bound*, not the exact post-pipeline shape:

    - ``n_detectors`` is the count after applying ``detector_selection`` (wafer slot / bandpass)
      but before the process pipeline, which only ever restricts detectors further.
    - ``n_samples`` is ``ceil(meta.samps.count / downsample)``. The process pipeline only trims
      samples (e.g. edge cuts) before downsampling, so this bounds the actual samps count.
    """
    _enable_preproc_context_cache()
    # call get_preprocess_context through the module so the cached get_preprocess_context is used
    # (str, not Path: sotodlib and the cache key expect a posix string)
    _, context = pu.get_preprocess_context(self.init_config.as_posix())
    meta = context.get_meta(self.observation_id, dets=self.detector_selection)
    n_samps_ub = -(-meta.samps.count // self.downsample)  # ceil, matches downsample_obs
    return ObservationBufferShapes(
        meta.dets.count,
        n_samps_ub,
        meta.subscans.count if intervals else 0,
    )

furax.interfaces.toast

Modules:

Classes:

LazyToastObservation

Bases: FileBackedLazyObservation[Data]

Attributes:

Source code in src/furax/interfaces/toast/observation.py
class LazyToastObservation(FileBackedLazyObservation[toast.Data]):
    interface_class = ToastObservation

interface_class = ToastObservation class-attribute instance-attribute

ToastObservation

Bases: AbstractGroundObservation[Data]

Methods:

Attributes:

Source code in src/furax/interfaces/toast/observation.py
class ToastObservation(AbstractGroundObservation[toast.Data]):
    def __init__(
        self,
        data: toast.Data,
        observation_index: int = 0,
        *,
        det_selection: list[str] | None = None,
        det_mask: int = defaults.det_mask_nonscience,
        det_data: str = defaults.det_data,
        pixels: str = defaults.pixels,
        quats: str = defaults.quats,
        hwp_angle: str | None = defaults.hwp_angle,
        noise_model: str | None = defaults.noise_model,
        azimuth: str = defaults.azimuth,
        elevation: str = defaults.elevation,
        boresight: str = defaults.boresight_radec,
        cross_psd: tuple[Float[Array, ' freq'], Float[Array, 'det det freq']] | None = None,
    ) -> None:
        """Create a `ToastObservation` from a `toast.Data` object.

        Args:
            data: The toast data container.
            observation_index: The index of the observation of interest in the list.
                Defaults to zero (the first one).
            det_selection: A list of detector names to select.
            det_mask: A bitmask to exclude detectors.
            det_data: The key for the detector sample data buffer.
            pixels: The key for the pixel indices buffer.
            quats: The key for the (RA/dec) expanded quaternions buffer.
            hwp_angle: The key for the HWP angle buffer.
            noise_model: The key for the noise model.
            azimuth: The key for the azimuth angle buffer.
            elevation: The key for the elevation angle buffer.
            boresight: The key for the (RA/dec) boresight quaternions buffer.
            cross_psd: Optional ``(frequencies, cross-PSD matrix)`` tuple defining a
                correlated noise model across detectors.
        """
        # only keep the observation at the given index
        self.data: toast.Observation = data.obs[observation_index]

        # also keep a reference to the full data container in order to apply toast operators later
        self._toast_data = data

        # toast names
        self._det_selection = det_selection
        self._det_mask = det_mask
        self._det_data = det_data
        self._pixels = pixels
        self._quats = quats
        self._hwp_angle = hwp_angle
        self._noise_model = noise_model
        self._azimuth = azimuth
        self._elevation = elevation
        self._boresight = boresight
        self._cross_psd = cross_psd  # FIXME: unused

    @classmethod
    def from_file(
        cls, filename: str | Path, requested_fields: Collection[str] | None = None
    ) -> ToastObservation:
        # check that file exists
        if not Path(filename).exists():
            raise FileNotFoundError(f'File {filename} does not exist')
        if isinstance(filename, Path):
            filename = filename.as_posix()

        # Prepare TOAST loading operator
        loader = LoadHDF5(files=filename)
        data = toast.Data()
        if requested_fields is None:
            loader.apply(data)
            return ToastObservation(data)

        requested = set(requested_fields)
        # translate request to toast subfield names (sets dedup overlapping requests)
        detdata: set[str] = set()
        if ReaderField.SAMPLE_DATA in requested:
            detdata.add(defaults.det_data)
        if ReaderField.VALID_SAMPLE_MASKS in requested:
            detdata.add(defaults.det_flags)

        shared = {defaults.times}  # Always need to load timestamps
        if ReaderField.HWP_ANGLES in requested:
            shared.add(defaults.hwp_angle)
        if ReaderField.BORESIGHT_QUATERNIONS in requested:
            shared.add(defaults.boresight_radec)
        if ReaderField.AZIMUTH in requested:
            shared.add(defaults.azimuth)
        if ReaderField.ELEVATION in requested:
            shared.add(defaults.elevation)

        intervals = {''}  # Toast loads all intervals if the list is empty
        # Load the precomputed scan intervals the getters read, so they need not recompute
        # them via AzimuthIntervals (which would require the azimuth shared field).
        if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
            intervals.add(defaults.scanning_interval)
        if ReaderField.LEFT_SCAN_MASK in requested:
            intervals.add(defaults.scan_rightleft_interval)
        if ReaderField.RIGHT_SCAN_MASK in requested:
            intervals.add(defaults.scan_leftright_interval)

        loader.detdata = list(detdata)
        loader.shared = list(shared)
        loader.intervals = list(intervals)

        loader.apply(data)
        return ToastObservation(data)

    @property
    def name(self) -> str:
        return self.data.name  # type: ignore[no-any-return]

    @property
    def telescope(self) -> str:
        return self.data.telescope.name  # type: ignore[no-any-return]

    @property
    def n_samples(self) -> int:
        return self.data.n_local_samples  # type: ignore[no-any-return]

    @property
    def detectors(self) -> list[str]:
        local_selection: list[str] = self.data.select_local_detectors(
            selection=self._det_selection, flagmask=self._det_mask
        )
        return local_selection

    @property
    def _focal_plane(self) -> toast.Focalplane:
        return self.data.telescope.focalplane

    @property
    def sample_rate(self) -> float:
        """Returns the sampling rate (in Hz) of the data."""
        return self._focal_plane.sample_rate.to_value(u.Hz)  # type: ignore[no-any-return]

    def get_tods(self) -> Float[np.ndarray, 'dets samps']:
        """Returns the timestream data."""
        # furax's LinearPolarizerOperator assumes power, TOAST assumes temperature
        tods = 0.5 * np.asarray(self.data.detdata[self._det_data][self.detectors, :])
        return np.atleast_2d(tods)

    def _get_detector_angles(self) -> Array:
        """Returns the detector angles on the sky."""
        # stick to the TOAST storage convention because get_local_meridian_angle expects it
        quats = self._get_expanded_quats()
        angles = get_local_meridian_angle(quats)
        return jnp.atleast_2d(angles)

    def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
        """Returns the detector offset angles."""
        fp = self._focal_plane
        return np.array([fp[det]['gamma'].to_value(u.rad) for det in self.detectors])

    def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
        """Returns the HWP angles."""
        if self._hwp_angle is None or self._hwp_angle not in self.data.shared:
            raise ValueError('HWP angle field not provided.')
        return np.asarray(self.data.shared[self._hwp_angle].data)

    def _get_psd_model(self) -> tuple[Array, Array]:
        """Returns frequencies and PSD values of the noise model."""
        if self._noise_model is None or self._noise_model not in self.data:
            raise ValueError('Noise model not provided.')
        model = self.data[self._noise_model]
        freq = jnp.array([model.freq(det) for det in self.detectors])
        psd = jnp.array([model.psd(det) for det in self.detectors])
        return freq, psd

    def get_scanning_intervals(self) -> NDArray[Any]:
        """Returns scanning intervals.

        The output is a list of the starting and ending sample indices
        """
        if not hasattr(self.data, 'intervals') or 'scanning' not in self.data.intervals:
            # Scanning information missing, first compute the intervals
            toast.ops.AzimuthIntervals().apply(self._toast_data)
        intervals = self.data.intervals['scanning']
        return np.array(intervals[['first', 'last']].tolist())

    def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
        return np.asarray(self.data.detdata['flags'].data == 0, dtype=bool)

    def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        if not hasattr(self.data, 'intervals'):
            # Scanning information missing, first compute the intervals
            toast.ops.AzimuthIntervals().apply(self._toast_data)

        # Left scan means scanning FROM right TO left
        intervals_list = self.data.intervals['scan_rightleft'][['first', 'last']].tolist()
        mask = np.zeros(self.n_samples, dtype=bool)
        for start, stop in intervals_list:
            mask[start:stop] = True
        return mask

    def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        if not hasattr(self.data, 'intervals'):
            # Scanning information missing, first compute the intervals
            toast.ops.AzimuthIntervals().apply(self._toast_data)

        # Right scan means scanning FROM left TO right
        intervals_list = self.data.intervals['scan_leftright'][['first', 'last']].tolist()
        mask = np.zeros(self.n_samples, dtype=bool)
        for start, stop in intervals_list:
            mask[start:stop] = True
        return mask

    def get_azimuth(self) -> Float[np.ndarray, ' a']:
        """Returns the azimuth of the boresight for each sample."""
        if self._azimuth not in self.data.shared:
            raise ValueError('Azimuth field not provided.')
        return np.asarray(self.data.shared[self._azimuth].data)

    def get_elevation(self) -> Float[np.ndarray, ' a']:
        """Returns the elevation of the boresight for each sample."""
        if self._elevation not in self.data.shared:
            raise ValueError('Elevation field not provided.')
        return np.asarray(self.data.shared[self._elevation].data)

    def get_timestamps(self) -> Float[np.ndarray, ' a']:
        """Returns timestamps (sec) of the samples."""
        return np.asarray(self.data.shared['times'].data)

    def get_wcs_shape_and_kernel(
        self,
        resolution_arcmin: float,
        projection: ProjectionType = ProjectionType.CAR,
    ) -> tuple[tuple[int, int], WCS]:
        """Returns the shape and object corresponding to a WCS projection.

        Here, this is obtained while we compute the pointing and pixelisation.
        """
        det_pointing = toast.ops.PointingDetectorSimple(
            boresight=defaults.boresight_radec, quats=self._quats
        )
        det_pixels = toast.ops.PixelsWCS(
            detector_pointing=det_pointing,
            pixels=self._pixels,
            resolution=[res := (resolution_arcmin * u.arcmin), res],
            projection=projection.name,
            dimensions=tuple(),
        )
        det_pixels.apply(self._toast_data)

        # Un-flatten the pixel indices
        pix = self._get_pixel_indices() % det_pixels._n_pix
        pix_dec = pix // det_pixels.pix_lat
        pix_ra = pix % det_pixels.pix_lat
        tot_pix = np.stack([pix_dec, pix_ra], axis=-1)
        del self.data.detdata[self._pixels]
        self.data.detdata[self._pixels] = tot_pix

        return det_pixels.wcs_shape, det_pixels.wcs

    def get_pointing_and_spin_angles(
        self, landscape: StokesLandscape
    ) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
        """Obtain pointing information and spin angles from the observation."""
        det_keys = self.data.detdata.keys()
        if self._quats in det_keys and self._pixels in det_keys:
            indices = self._get_pixel_indices()
            spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
            return indices, spin_ang

        elif isinstance(landscape, AstropyWCSLandscape):
            raise ValueError(
                'Pointing information is missing from the data. \
                        This is supposed to be obtained when computing \
                        the WCS kernel.'
            )

        det_pointing = toast.ops.PointingDetectorSimple(
            boresight=defaults.boresight_radec, quats=self._quats
        )

        if isinstance(landscape, HealpixLandscape):
            det_pixels = toast.ops.PixelsHealpix(
                detector_pointing=det_pointing,
                pixels=self._pixels,
                nside=landscape.nside,
                nest=False,
            )
            det_pixels.apply(self._toast_data)
            indices = self._get_pixel_indices()
            spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
            return indices, spin_ang
        else:
            raise ValueError('Invalid landscape type')

    def _get_pixel_indices(self) -> Array:
        """Returns the pixel indices."""
        pixels = jnp.array(self.data.detdata[self._pixels][self.detectors, :])
        return jnp.atleast_2d(pixels)

    @typing.no_type_check
    def get_noise_model(self) -> None | NoiseModel:
        """Load noise model from the focalplane data, if present. Otherwise, return None."""
        noise_keys = ['psd_fmin', 'psd_fknee', 'psd_alpha', 'psd_net']
        fp_data = self.data.telescope.focalplane.detector_data
        for key in noise_keys:
            if key not in fp_data.colnames:
                # Noise model cannot be loaded from the observation
                return None

        idets = np.argwhere(np.array(self.detectors)[:, None] == fp_data['name'][None, :])[:, 1]
        noise_model = AtmosphericNoiseModel(
            sigma=jnp.array(fp_data['psd_net'][idets].to(u.K * u.s**0.5).value),
            alpha=jnp.array(-fp_data['psd_alpha'][idets].value),  # Note toast's sign convention
            fk=jnp.array(fp_data['psd_fknee'][idets].to(u.Hz).value),
            f0=jnp.array(fp_data['psd_fmin'][idets].to(u.Hz).value),
        )
        return noise_model

    def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
        if self._boresight not in self.data.shared:
            raise ValueError('Boresight field not provided.')
        quats = np.asarray(self.data.shared[self._boresight].data)
        return np.roll(quats, 1, axis=-1)

    def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
        quats = np.array([self._focal_plane[d]['quat'] for d in self.detectors])
        quats = np.roll(quats, 1, axis=-1)
        return np.atleast_2d(quats)

    def _get_expanded_quats(self) -> Array:
        """Returns expanded pointing quaternions.

        These will be in the TOAST storage convention, i.e. vector-scalar!
        """
        quats = jnp.array(self.data.detdata[self._quats][self.detectors, :])
        if quats.ndim >= 3:
            return quats
        # np.atleast_3d appends one new axis for 1d/2d inputs, we want to prepend it instead
        return jnp.moveaxis(jnp.atleast_3d(quats), -1, 0)

data = data.obs[observation_index] instance-attribute

name property

telescope property

n_samples property

detectors property

sample_rate property

Returns the sampling rate (in Hz) of the data.

__init__(data, observation_index=0, *, det_selection=None, det_mask=defaults.det_mask_nonscience, det_data=defaults.det_data, pixels=defaults.pixels, quats=defaults.quats, hwp_angle=defaults.hwp_angle, noise_model=defaults.noise_model, azimuth=defaults.azimuth, elevation=defaults.elevation, boresight=defaults.boresight_radec, cross_psd=None)

Parameters:

  • data (Data) –

    The toast data container.

  • observation_index (int, default: 0 ) –

    The index of the observation of interest in the list. Defaults to zero (the first one).

  • det_selection (list[str] | None, default: None ) –

    A list of detector names to select.

  • det_mask (int, default: det_mask_nonscience ) –

    A bitmask to exclude detectors.

  • det_data (str, default: det_data ) –

    The key for the detector sample data buffer.

  • pixels (str, default: pixels ) –

    The key for the pixel indices buffer.

  • quats (str, default: quats ) –

    The key for the (RA/dec) expanded quaternions buffer.

  • hwp_angle (str | None, default: hwp_angle ) –

    The key for the HWP angle buffer.

  • noise_model (str | None, default: noise_model ) –

    The key for the noise model.

  • azimuth (str, default: azimuth ) –

    The key for the azimuth angle buffer.

  • elevation (str, default: elevation ) –

    The key for the elevation angle buffer.

  • boresight (str, default: boresight_radec ) –

    The key for the (RA/dec) boresight quaternions buffer.

  • cross_psd (tuple[Float[Array, ' freq'], Float[Array, 'det det freq']] | None, default: None ) –

    Optional (frequencies, cross-PSD matrix) tuple defining a correlated noise model across detectors.

Source code in src/furax/interfaces/toast/observation.py
def __init__(
    self,
    data: toast.Data,
    observation_index: int = 0,
    *,
    det_selection: list[str] | None = None,
    det_mask: int = defaults.det_mask_nonscience,
    det_data: str = defaults.det_data,
    pixels: str = defaults.pixels,
    quats: str = defaults.quats,
    hwp_angle: str | None = defaults.hwp_angle,
    noise_model: str | None = defaults.noise_model,
    azimuth: str = defaults.azimuth,
    elevation: str = defaults.elevation,
    boresight: str = defaults.boresight_radec,
    cross_psd: tuple[Float[Array, ' freq'], Float[Array, 'det det freq']] | None = None,
) -> None:
    """Create a `ToastObservation` from a `toast.Data` object.

    Args:
        data: The toast data container.
        observation_index: The index of the observation of interest in the list.
            Defaults to zero (the first one).
        det_selection: A list of detector names to select.
        det_mask: A bitmask to exclude detectors.
        det_data: The key for the detector sample data buffer.
        pixels: The key for the pixel indices buffer.
        quats: The key for the (RA/dec) expanded quaternions buffer.
        hwp_angle: The key for the HWP angle buffer.
        noise_model: The key for the noise model.
        azimuth: The key for the azimuth angle buffer.
        elevation: The key for the elevation angle buffer.
        boresight: The key for the (RA/dec) boresight quaternions buffer.
        cross_psd: Optional ``(frequencies, cross-PSD matrix)`` tuple defining a
            correlated noise model across detectors.
    """
    # only keep the observation at the given index
    self.data: toast.Observation = data.obs[observation_index]

    # also keep a reference to the full data container in order to apply toast operators later
    self._toast_data = data

    # toast names
    self._det_selection = det_selection
    self._det_mask = det_mask
    self._det_data = det_data
    self._pixels = pixels
    self._quats = quats
    self._hwp_angle = hwp_angle
    self._noise_model = noise_model
    self._azimuth = azimuth
    self._elevation = elevation
    self._boresight = boresight
    self._cross_psd = cross_psd  # FIXME: unused

from_file(filename, requested_fields=None) classmethod

Source code in src/furax/interfaces/toast/observation.py
@classmethod
def from_file(
    cls, filename: str | Path, requested_fields: Collection[str] | None = None
) -> ToastObservation:
    # check that file exists
    if not Path(filename).exists():
        raise FileNotFoundError(f'File {filename} does not exist')
    if isinstance(filename, Path):
        filename = filename.as_posix()

    # Prepare TOAST loading operator
    loader = LoadHDF5(files=filename)
    data = toast.Data()
    if requested_fields is None:
        loader.apply(data)
        return ToastObservation(data)

    requested = set(requested_fields)
    # translate request to toast subfield names (sets dedup overlapping requests)
    detdata: set[str] = set()
    if ReaderField.SAMPLE_DATA in requested:
        detdata.add(defaults.det_data)
    if ReaderField.VALID_SAMPLE_MASKS in requested:
        detdata.add(defaults.det_flags)

    shared = {defaults.times}  # Always need to load timestamps
    if ReaderField.HWP_ANGLES in requested:
        shared.add(defaults.hwp_angle)
    if ReaderField.BORESIGHT_QUATERNIONS in requested:
        shared.add(defaults.boresight_radec)
    if ReaderField.AZIMUTH in requested:
        shared.add(defaults.azimuth)
    if ReaderField.ELEVATION in requested:
        shared.add(defaults.elevation)

    intervals = {''}  # Toast loads all intervals if the list is empty
    # Load the precomputed scan intervals the getters read, so they need not recompute
    # them via AzimuthIntervals (which would require the azimuth shared field).
    if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
        intervals.add(defaults.scanning_interval)
    if ReaderField.LEFT_SCAN_MASK in requested:
        intervals.add(defaults.scan_rightleft_interval)
    if ReaderField.RIGHT_SCAN_MASK in requested:
        intervals.add(defaults.scan_leftright_interval)

    loader.detdata = list(detdata)
    loader.shared = list(shared)
    loader.intervals = list(intervals)

    loader.apply(data)
    return ToastObservation(data)

get_tods()

Returns the timestream data.

Source code in src/furax/interfaces/toast/observation.py
def get_tods(self) -> Float[np.ndarray, 'dets samps']:
    """Returns the timestream data."""
    # furax's LinearPolarizerOperator assumes power, TOAST assumes temperature
    tods = 0.5 * np.asarray(self.data.detdata[self._det_data][self.detectors, :])
    return np.atleast_2d(tods)

get_detector_offset_angles()

Returns the detector offset angles.

Source code in src/furax/interfaces/toast/observation.py
def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
    """Returns the detector offset angles."""
    fp = self._focal_plane
    return np.array([fp[det]['gamma'].to_value(u.rad) for det in self.detectors])

get_hwp_angles()

Returns the HWP angles.

Source code in src/furax/interfaces/toast/observation.py
def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
    """Returns the HWP angles."""
    if self._hwp_angle is None or self._hwp_angle not in self.data.shared:
        raise ValueError('HWP angle field not provided.')
    return np.asarray(self.data.shared[self._hwp_angle].data)

get_scanning_intervals()

Returns scanning intervals.

The output is a list of the starting and ending sample indices

Source code in src/furax/interfaces/toast/observation.py
def get_scanning_intervals(self) -> NDArray[Any]:
    """Returns scanning intervals.

    The output is a list of the starting and ending sample indices
    """
    if not hasattr(self.data, 'intervals') or 'scanning' not in self.data.intervals:
        # Scanning information missing, first compute the intervals
        toast.ops.AzimuthIntervals().apply(self._toast_data)
    intervals = self.data.intervals['scanning']
    return np.array(intervals[['first', 'last']].tolist())

get_sample_mask()

Source code in src/furax/interfaces/toast/observation.py
def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
    return np.asarray(self.data.detdata['flags'].data == 0, dtype=bool)

get_left_scan_mask()

Source code in src/furax/interfaces/toast/observation.py
def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    if not hasattr(self.data, 'intervals'):
        # Scanning information missing, first compute the intervals
        toast.ops.AzimuthIntervals().apply(self._toast_data)

    # Left scan means scanning FROM right TO left
    intervals_list = self.data.intervals['scan_rightleft'][['first', 'last']].tolist()
    mask = np.zeros(self.n_samples, dtype=bool)
    for start, stop in intervals_list:
        mask[start:stop] = True
    return mask

get_right_scan_mask()

Source code in src/furax/interfaces/toast/observation.py
def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    if not hasattr(self.data, 'intervals'):
        # Scanning information missing, first compute the intervals
        toast.ops.AzimuthIntervals().apply(self._toast_data)

    # Right scan means scanning FROM left TO right
    intervals_list = self.data.intervals['scan_leftright'][['first', 'last']].tolist()
    mask = np.zeros(self.n_samples, dtype=bool)
    for start, stop in intervals_list:
        mask[start:stop] = True
    return mask

get_azimuth()

Returns the azimuth of the boresight for each sample.

Source code in src/furax/interfaces/toast/observation.py
def get_azimuth(self) -> Float[np.ndarray, ' a']:
    """Returns the azimuth of the boresight for each sample."""
    if self._azimuth not in self.data.shared:
        raise ValueError('Azimuth field not provided.')
    return np.asarray(self.data.shared[self._azimuth].data)

get_elevation()

Returns the elevation of the boresight for each sample.

Source code in src/furax/interfaces/toast/observation.py
def get_elevation(self) -> Float[np.ndarray, ' a']:
    """Returns the elevation of the boresight for each sample."""
    if self._elevation not in self.data.shared:
        raise ValueError('Elevation field not provided.')
    return np.asarray(self.data.shared[self._elevation].data)

get_timestamps()

Returns timestamps (sec) of the samples.

Source code in src/furax/interfaces/toast/observation.py
def get_timestamps(self) -> Float[np.ndarray, ' a']:
    """Returns timestamps (sec) of the samples."""
    return np.asarray(self.data.shared['times'].data)

get_wcs_shape_and_kernel(resolution_arcmin, projection=ProjectionType.CAR)

Returns the shape and object corresponding to a WCS projection.

Here, this is obtained while we compute the pointing and pixelisation.

Source code in src/furax/interfaces/toast/observation.py
def get_wcs_shape_and_kernel(
    self,
    resolution_arcmin: float,
    projection: ProjectionType = ProjectionType.CAR,
) -> tuple[tuple[int, int], WCS]:
    """Returns the shape and object corresponding to a WCS projection.

    Here, this is obtained while we compute the pointing and pixelisation.
    """
    det_pointing = toast.ops.PointingDetectorSimple(
        boresight=defaults.boresight_radec, quats=self._quats
    )
    det_pixels = toast.ops.PixelsWCS(
        detector_pointing=det_pointing,
        pixels=self._pixels,
        resolution=[res := (resolution_arcmin * u.arcmin), res],
        projection=projection.name,
        dimensions=tuple(),
    )
    det_pixels.apply(self._toast_data)

    # Un-flatten the pixel indices
    pix = self._get_pixel_indices() % det_pixels._n_pix
    pix_dec = pix // det_pixels.pix_lat
    pix_ra = pix % det_pixels.pix_lat
    tot_pix = np.stack([pix_dec, pix_ra], axis=-1)
    del self.data.detdata[self._pixels]
    self.data.detdata[self._pixels] = tot_pix

    return det_pixels.wcs_shape, det_pixels.wcs

get_pointing_and_spin_angles(landscape)

Obtain pointing information and spin angles from the observation.

Source code in src/furax/interfaces/toast/observation.py
def get_pointing_and_spin_angles(
    self, landscape: StokesLandscape
) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
    """Obtain pointing information and spin angles from the observation."""
    det_keys = self.data.detdata.keys()
    if self._quats in det_keys and self._pixels in det_keys:
        indices = self._get_pixel_indices()
        spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
        return indices, spin_ang

    elif isinstance(landscape, AstropyWCSLandscape):
        raise ValueError(
            'Pointing information is missing from the data. \
                    This is supposed to be obtained when computing \
                    the WCS kernel.'
        )

    det_pointing = toast.ops.PointingDetectorSimple(
        boresight=defaults.boresight_radec, quats=self._quats
    )

    if isinstance(landscape, HealpixLandscape):
        det_pixels = toast.ops.PixelsHealpix(
            detector_pointing=det_pointing,
            pixels=self._pixels,
            nside=landscape.nside,
            nest=False,
        )
        det_pixels.apply(self._toast_data)
        indices = self._get_pixel_indices()
        spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
        return indices, spin_ang
    else:
        raise ValueError('Invalid landscape type')

get_noise_model()

Load noise model from the focalplane data, if present. Otherwise, return None.

Source code in src/furax/interfaces/toast/observation.py
@typing.no_type_check
def get_noise_model(self) -> None | NoiseModel:
    """Load noise model from the focalplane data, if present. Otherwise, return None."""
    noise_keys = ['psd_fmin', 'psd_fknee', 'psd_alpha', 'psd_net']
    fp_data = self.data.telescope.focalplane.detector_data
    for key in noise_keys:
        if key not in fp_data.colnames:
            # Noise model cannot be loaded from the observation
            return None

    idets = np.argwhere(np.array(self.detectors)[:, None] == fp_data['name'][None, :])[:, 1]
    noise_model = AtmosphericNoiseModel(
        sigma=jnp.array(fp_data['psd_net'][idets].to(u.K * u.s**0.5).value),
        alpha=jnp.array(-fp_data['psd_alpha'][idets].value),  # Note toast's sign convention
        fk=jnp.array(fp_data['psd_fknee'][idets].to(u.Hz).value),
        f0=jnp.array(fp_data['psd_fmin'][idets].to(u.Hz).value),
    )
    return noise_model

get_boresight_quaternions()

Source code in src/furax/interfaces/toast/observation.py
def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
    if self._boresight not in self.data.shared:
        raise ValueError('Boresight field not provided.')
    quats = np.asarray(self.data.shared[self._boresight].data)
    return np.roll(quats, 1, axis=-1)

get_detector_quaternions()

Source code in src/furax/interfaces/toast/observation.py
def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
    quats = np.array([self._focal_plane[d]['quat'] for d in self.detectors])
    quats = np.roll(quats, 1, axis=-1)
    return np.atleast_2d(quats)

obs_matrix

Classes:

ToastObservationMatrixOperator

Bases: AbstractLinearOperator

Class for applying a Toast observation matrix.

Usage

path = '/path/to/nside064/toast_telescope_all_time_all_obs_matrix.npz' O = ToastObservationMatrixOperator.from_file(path) x = jnp.ones(O.in_structure.shape) y = O(x) y = O.T(x)

Methods:

Attributes:

Source code in src/furax/interfaces/toast/obs_matrix.py
@square
class ToastObservationMatrixOperator(AbstractLinearOperator):
    """Class for applying a Toast observation matrix.

    Usage:
        >>> path = '/path/to/nside064/toast_telescope_all_time_all_obs_matrix.npz'
        >>> O = ToastObservationMatrixOperator.from_file(path)
        >>> x = jnp.ones(O.in_structure.shape)
        >>> y = O(x)
        >>> y = O.T(x)
    """

    matrix: CSR

    def __init__(self, matrix: CSR) -> None:
        object.__setattr__(self, 'matrix', matrix)
        super().__init__(
            in_structure=jax.ShapeDtypeStruct((self.matrix.shape[0],), self.matrix.dtype)
        )

    @classmethod
    def from_file(cls, path: Path | str) -> 'ToastObservationMatrixOperator':
        with np.load(path) as data:
            fmt = data['format']
            if isinstance(fmt, np.ndarray):
                fmt = fmt[()]
            if isinstance(fmt, bytes):
                fmt = fmt.decode('utf-8')
            if fmt != 'csr':
                raise NotImplementedError
            matrix = CSR((data['data'], data['indices'], data['indptr']), shape=data['shape'])
        if matrix.shape[0] != matrix.shape[1]:
            raise ValueError('The observation matrix is not square.')
        return cls(matrix=matrix)

    def mv(self, x: PyTree[Inexact[Array, ' _a']]) -> PyTree[Inexact[Array, ' _a']]:
        return self.matrix @ x

    def transpose(self) -> AbstractLinearOperator:
        return ToastObservationMatrixTransposeOperator(self)
matrix instance-attribute
__init__(matrix)
Source code in src/furax/interfaces/toast/obs_matrix.py
def __init__(self, matrix: CSR) -> None:
    object.__setattr__(self, 'matrix', matrix)
    super().__init__(
        in_structure=jax.ShapeDtypeStruct((self.matrix.shape[0],), self.matrix.dtype)
    )
from_file(path) classmethod
Source code in src/furax/interfaces/toast/obs_matrix.py
@classmethod
def from_file(cls, path: Path | str) -> 'ToastObservationMatrixOperator':
    with np.load(path) as data:
        fmt = data['format']
        if isinstance(fmt, np.ndarray):
            fmt = fmt[()]
        if isinstance(fmt, bytes):
            fmt = fmt.decode('utf-8')
        if fmt != 'csr':
            raise NotImplementedError
        matrix = CSR((data['data'], data['indices'], data['indptr']), shape=data['shape'])
    if matrix.shape[0] != matrix.shape[1]:
        raise ValueError('The observation matrix is not square.')
    return cls(matrix=matrix)
mv(x)
Source code in src/furax/interfaces/toast/obs_matrix.py
def mv(self, x: PyTree[Inexact[Array, ' _a']]) -> PyTree[Inexact[Array, ' _a']]:
    return self.matrix @ x
transpose()
Source code in src/furax/interfaces/toast/obs_matrix.py
def transpose(self) -> AbstractLinearOperator:
    return ToastObservationMatrixTransposeOperator(self)

ToastObservationMatrixTransposeOperator dataclass

Bases: TransposeOperator

Class for applying the transpose of a Toast observation matrix.

Methods:

Attributes:

Source code in src/furax/interfaces/toast/obs_matrix.py
class ToastObservationMatrixTransposeOperator(TransposeOperator):
    """Class for applying the transpose of a Toast observation matrix."""

    operator: ToastObservationMatrixOperator

    def mv(self, x: PyTree[Inexact[Array, ' _a']]) -> PyTree[Inexact[Array, ' _a']]:
        return self.operator.matrix.T @ x
operator instance-attribute
mv(x)
Source code in src/furax/interfaces/toast/obs_matrix.py
def mv(self, x: PyTree[Inexact[Array, ' _a']]) -> PyTree[Inexact[Array, ' _a']]:
    return self.operator.matrix.T @ x

observation

Classes:

Functions:

ToastObservation

Bases: AbstractGroundObservation[Data]

Methods:

Attributes:

Source code in src/furax/interfaces/toast/observation.py
class ToastObservation(AbstractGroundObservation[toast.Data]):
    def __init__(
        self,
        data: toast.Data,
        observation_index: int = 0,
        *,
        det_selection: list[str] | None = None,
        det_mask: int = defaults.det_mask_nonscience,
        det_data: str = defaults.det_data,
        pixels: str = defaults.pixels,
        quats: str = defaults.quats,
        hwp_angle: str | None = defaults.hwp_angle,
        noise_model: str | None = defaults.noise_model,
        azimuth: str = defaults.azimuth,
        elevation: str = defaults.elevation,
        boresight: str = defaults.boresight_radec,
        cross_psd: tuple[Float[Array, ' freq'], Float[Array, 'det det freq']] | None = None,
    ) -> None:
        """Create a `ToastObservation` from a `toast.Data` object.

        Args:
            data: The toast data container.
            observation_index: The index of the observation of interest in the list.
                Defaults to zero (the first one).
            det_selection: A list of detector names to select.
            det_mask: A bitmask to exclude detectors.
            det_data: The key for the detector sample data buffer.
            pixels: The key for the pixel indices buffer.
            quats: The key for the (RA/dec) expanded quaternions buffer.
            hwp_angle: The key for the HWP angle buffer.
            noise_model: The key for the noise model.
            azimuth: The key for the azimuth angle buffer.
            elevation: The key for the elevation angle buffer.
            boresight: The key for the (RA/dec) boresight quaternions buffer.
            cross_psd: Optional ``(frequencies, cross-PSD matrix)`` tuple defining a
                correlated noise model across detectors.
        """
        # only keep the observation at the given index
        self.data: toast.Observation = data.obs[observation_index]

        # also keep a reference to the full data container in order to apply toast operators later
        self._toast_data = data

        # toast names
        self._det_selection = det_selection
        self._det_mask = det_mask
        self._det_data = det_data
        self._pixels = pixels
        self._quats = quats
        self._hwp_angle = hwp_angle
        self._noise_model = noise_model
        self._azimuth = azimuth
        self._elevation = elevation
        self._boresight = boresight
        self._cross_psd = cross_psd  # FIXME: unused

    @classmethod
    def from_file(
        cls, filename: str | Path, requested_fields: Collection[str] | None = None
    ) -> ToastObservation:
        # check that file exists
        if not Path(filename).exists():
            raise FileNotFoundError(f'File {filename} does not exist')
        if isinstance(filename, Path):
            filename = filename.as_posix()

        # Prepare TOAST loading operator
        loader = LoadHDF5(files=filename)
        data = toast.Data()
        if requested_fields is None:
            loader.apply(data)
            return ToastObservation(data)

        requested = set(requested_fields)
        # translate request to toast subfield names (sets dedup overlapping requests)
        detdata: set[str] = set()
        if ReaderField.SAMPLE_DATA in requested:
            detdata.add(defaults.det_data)
        if ReaderField.VALID_SAMPLE_MASKS in requested:
            detdata.add(defaults.det_flags)

        shared = {defaults.times}  # Always need to load timestamps
        if ReaderField.HWP_ANGLES in requested:
            shared.add(defaults.hwp_angle)
        if ReaderField.BORESIGHT_QUATERNIONS in requested:
            shared.add(defaults.boresight_radec)
        if ReaderField.AZIMUTH in requested:
            shared.add(defaults.azimuth)
        if ReaderField.ELEVATION in requested:
            shared.add(defaults.elevation)

        intervals = {''}  # Toast loads all intervals if the list is empty
        # Load the precomputed scan intervals the getters read, so they need not recompute
        # them via AzimuthIntervals (which would require the azimuth shared field).
        if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
            intervals.add(defaults.scanning_interval)
        if ReaderField.LEFT_SCAN_MASK in requested:
            intervals.add(defaults.scan_rightleft_interval)
        if ReaderField.RIGHT_SCAN_MASK in requested:
            intervals.add(defaults.scan_leftright_interval)

        loader.detdata = list(detdata)
        loader.shared = list(shared)
        loader.intervals = list(intervals)

        loader.apply(data)
        return ToastObservation(data)

    @property
    def name(self) -> str:
        return self.data.name  # type: ignore[no-any-return]

    @property
    def telescope(self) -> str:
        return self.data.telescope.name  # type: ignore[no-any-return]

    @property
    def n_samples(self) -> int:
        return self.data.n_local_samples  # type: ignore[no-any-return]

    @property
    def detectors(self) -> list[str]:
        local_selection: list[str] = self.data.select_local_detectors(
            selection=self._det_selection, flagmask=self._det_mask
        )
        return local_selection

    @property
    def _focal_plane(self) -> toast.Focalplane:
        return self.data.telescope.focalplane

    @property
    def sample_rate(self) -> float:
        """Returns the sampling rate (in Hz) of the data."""
        return self._focal_plane.sample_rate.to_value(u.Hz)  # type: ignore[no-any-return]

    def get_tods(self) -> Float[np.ndarray, 'dets samps']:
        """Returns the timestream data."""
        # furax's LinearPolarizerOperator assumes power, TOAST assumes temperature
        tods = 0.5 * np.asarray(self.data.detdata[self._det_data][self.detectors, :])
        return np.atleast_2d(tods)

    def _get_detector_angles(self) -> Array:
        """Returns the detector angles on the sky."""
        # stick to the TOAST storage convention because get_local_meridian_angle expects it
        quats = self._get_expanded_quats()
        angles = get_local_meridian_angle(quats)
        return jnp.atleast_2d(angles)

    def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
        """Returns the detector offset angles."""
        fp = self._focal_plane
        return np.array([fp[det]['gamma'].to_value(u.rad) for det in self.detectors])

    def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
        """Returns the HWP angles."""
        if self._hwp_angle is None or self._hwp_angle not in self.data.shared:
            raise ValueError('HWP angle field not provided.')
        return np.asarray(self.data.shared[self._hwp_angle].data)

    def _get_psd_model(self) -> tuple[Array, Array]:
        """Returns frequencies and PSD values of the noise model."""
        if self._noise_model is None or self._noise_model not in self.data:
            raise ValueError('Noise model not provided.')
        model = self.data[self._noise_model]
        freq = jnp.array([model.freq(det) for det in self.detectors])
        psd = jnp.array([model.psd(det) for det in self.detectors])
        return freq, psd

    def get_scanning_intervals(self) -> NDArray[Any]:
        """Returns scanning intervals.

        The output is a list of the starting and ending sample indices
        """
        if not hasattr(self.data, 'intervals') or 'scanning' not in self.data.intervals:
            # Scanning information missing, first compute the intervals
            toast.ops.AzimuthIntervals().apply(self._toast_data)
        intervals = self.data.intervals['scanning']
        return np.array(intervals[['first', 'last']].tolist())

    def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
        return np.asarray(self.data.detdata['flags'].data == 0, dtype=bool)

    def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        if not hasattr(self.data, 'intervals'):
            # Scanning information missing, first compute the intervals
            toast.ops.AzimuthIntervals().apply(self._toast_data)

        # Left scan means scanning FROM right TO left
        intervals_list = self.data.intervals['scan_rightleft'][['first', 'last']].tolist()
        mask = np.zeros(self.n_samples, dtype=bool)
        for start, stop in intervals_list:
            mask[start:stop] = True
        return mask

    def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        if not hasattr(self.data, 'intervals'):
            # Scanning information missing, first compute the intervals
            toast.ops.AzimuthIntervals().apply(self._toast_data)

        # Right scan means scanning FROM left TO right
        intervals_list = self.data.intervals['scan_leftright'][['first', 'last']].tolist()
        mask = np.zeros(self.n_samples, dtype=bool)
        for start, stop in intervals_list:
            mask[start:stop] = True
        return mask

    def get_azimuth(self) -> Float[np.ndarray, ' a']:
        """Returns the azimuth of the boresight for each sample."""
        if self._azimuth not in self.data.shared:
            raise ValueError('Azimuth field not provided.')
        return np.asarray(self.data.shared[self._azimuth].data)

    def get_elevation(self) -> Float[np.ndarray, ' a']:
        """Returns the elevation of the boresight for each sample."""
        if self._elevation not in self.data.shared:
            raise ValueError('Elevation field not provided.')
        return np.asarray(self.data.shared[self._elevation].data)

    def get_timestamps(self) -> Float[np.ndarray, ' a']:
        """Returns timestamps (sec) of the samples."""
        return np.asarray(self.data.shared['times'].data)

    def get_wcs_shape_and_kernel(
        self,
        resolution_arcmin: float,
        projection: ProjectionType = ProjectionType.CAR,
    ) -> tuple[tuple[int, int], WCS]:
        """Returns the shape and object corresponding to a WCS projection.

        Here, this is obtained while we compute the pointing and pixelisation.
        """
        det_pointing = toast.ops.PointingDetectorSimple(
            boresight=defaults.boresight_radec, quats=self._quats
        )
        det_pixels = toast.ops.PixelsWCS(
            detector_pointing=det_pointing,
            pixels=self._pixels,
            resolution=[res := (resolution_arcmin * u.arcmin), res],
            projection=projection.name,
            dimensions=tuple(),
        )
        det_pixels.apply(self._toast_data)

        # Un-flatten the pixel indices
        pix = self._get_pixel_indices() % det_pixels._n_pix
        pix_dec = pix // det_pixels.pix_lat
        pix_ra = pix % det_pixels.pix_lat
        tot_pix = np.stack([pix_dec, pix_ra], axis=-1)
        del self.data.detdata[self._pixels]
        self.data.detdata[self._pixels] = tot_pix

        return det_pixels.wcs_shape, det_pixels.wcs

    def get_pointing_and_spin_angles(
        self, landscape: StokesLandscape
    ) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
        """Obtain pointing information and spin angles from the observation."""
        det_keys = self.data.detdata.keys()
        if self._quats in det_keys and self._pixels in det_keys:
            indices = self._get_pixel_indices()
            spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
            return indices, spin_ang

        elif isinstance(landscape, AstropyWCSLandscape):
            raise ValueError(
                'Pointing information is missing from the data. \
                        This is supposed to be obtained when computing \
                        the WCS kernel.'
            )

        det_pointing = toast.ops.PointingDetectorSimple(
            boresight=defaults.boresight_radec, quats=self._quats
        )

        if isinstance(landscape, HealpixLandscape):
            det_pixels = toast.ops.PixelsHealpix(
                detector_pointing=det_pointing,
                pixels=self._pixels,
                nside=landscape.nside,
                nest=False,
            )
            det_pixels.apply(self._toast_data)
            indices = self._get_pixel_indices()
            spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
            return indices, spin_ang
        else:
            raise ValueError('Invalid landscape type')

    def _get_pixel_indices(self) -> Array:
        """Returns the pixel indices."""
        pixels = jnp.array(self.data.detdata[self._pixels][self.detectors, :])
        return jnp.atleast_2d(pixels)

    @typing.no_type_check
    def get_noise_model(self) -> None | NoiseModel:
        """Load noise model from the focalplane data, if present. Otherwise, return None."""
        noise_keys = ['psd_fmin', 'psd_fknee', 'psd_alpha', 'psd_net']
        fp_data = self.data.telescope.focalplane.detector_data
        for key in noise_keys:
            if key not in fp_data.colnames:
                # Noise model cannot be loaded from the observation
                return None

        idets = np.argwhere(np.array(self.detectors)[:, None] == fp_data['name'][None, :])[:, 1]
        noise_model = AtmosphericNoiseModel(
            sigma=jnp.array(fp_data['psd_net'][idets].to(u.K * u.s**0.5).value),
            alpha=jnp.array(-fp_data['psd_alpha'][idets].value),  # Note toast's sign convention
            fk=jnp.array(fp_data['psd_fknee'][idets].to(u.Hz).value),
            f0=jnp.array(fp_data['psd_fmin'][idets].to(u.Hz).value),
        )
        return noise_model

    def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
        if self._boresight not in self.data.shared:
            raise ValueError('Boresight field not provided.')
        quats = np.asarray(self.data.shared[self._boresight].data)
        return np.roll(quats, 1, axis=-1)

    def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
        quats = np.array([self._focal_plane[d]['quat'] for d in self.detectors])
        quats = np.roll(quats, 1, axis=-1)
        return np.atleast_2d(quats)

    def _get_expanded_quats(self) -> Array:
        """Returns expanded pointing quaternions.

        These will be in the TOAST storage convention, i.e. vector-scalar!
        """
        quats = jnp.array(self.data.detdata[self._quats][self.detectors, :])
        if quats.ndim >= 3:
            return quats
        # np.atleast_3d appends one new axis for 1d/2d inputs, we want to prepend it instead
        return jnp.moveaxis(jnp.atleast_3d(quats), -1, 0)
data = data.obs[observation_index] instance-attribute
name property
telescope property
n_samples property
detectors property
sample_rate property

Returns the sampling rate (in Hz) of the data.

__init__(data, observation_index=0, *, det_selection=None, det_mask=defaults.det_mask_nonscience, det_data=defaults.det_data, pixels=defaults.pixels, quats=defaults.quats, hwp_angle=defaults.hwp_angle, noise_model=defaults.noise_model, azimuth=defaults.azimuth, elevation=defaults.elevation, boresight=defaults.boresight_radec, cross_psd=None)

Parameters:

  • data (Data) –

    The toast data container.

  • observation_index (int, default: 0 ) –

    The index of the observation of interest in the list. Defaults to zero (the first one).

  • det_selection (list[str] | None, default: None ) –

    A list of detector names to select.

  • det_mask (int, default: det_mask_nonscience ) –

    A bitmask to exclude detectors.

  • det_data (str, default: det_data ) –

    The key for the detector sample data buffer.

  • pixels (str, default: pixels ) –

    The key for the pixel indices buffer.

  • quats (str, default: quats ) –

    The key for the (RA/dec) expanded quaternions buffer.

  • hwp_angle (str | None, default: hwp_angle ) –

    The key for the HWP angle buffer.

  • noise_model (str | None, default: noise_model ) –

    The key for the noise model.

  • azimuth (str, default: azimuth ) –

    The key for the azimuth angle buffer.

  • elevation (str, default: elevation ) –

    The key for the elevation angle buffer.

  • boresight (str, default: boresight_radec ) –

    The key for the (RA/dec) boresight quaternions buffer.

  • cross_psd (tuple[Float[Array, ' freq'], Float[Array, 'det det freq']] | None, default: None ) –

    Optional (frequencies, cross-PSD matrix) tuple defining a correlated noise model across detectors.

Source code in src/furax/interfaces/toast/observation.py
def __init__(
    self,
    data: toast.Data,
    observation_index: int = 0,
    *,
    det_selection: list[str] | None = None,
    det_mask: int = defaults.det_mask_nonscience,
    det_data: str = defaults.det_data,
    pixels: str = defaults.pixels,
    quats: str = defaults.quats,
    hwp_angle: str | None = defaults.hwp_angle,
    noise_model: str | None = defaults.noise_model,
    azimuth: str = defaults.azimuth,
    elevation: str = defaults.elevation,
    boresight: str = defaults.boresight_radec,
    cross_psd: tuple[Float[Array, ' freq'], Float[Array, 'det det freq']] | None = None,
) -> None:
    """Create a `ToastObservation` from a `toast.Data` object.

    Args:
        data: The toast data container.
        observation_index: The index of the observation of interest in the list.
            Defaults to zero (the first one).
        det_selection: A list of detector names to select.
        det_mask: A bitmask to exclude detectors.
        det_data: The key for the detector sample data buffer.
        pixels: The key for the pixel indices buffer.
        quats: The key for the (RA/dec) expanded quaternions buffer.
        hwp_angle: The key for the HWP angle buffer.
        noise_model: The key for the noise model.
        azimuth: The key for the azimuth angle buffer.
        elevation: The key for the elevation angle buffer.
        boresight: The key for the (RA/dec) boresight quaternions buffer.
        cross_psd: Optional ``(frequencies, cross-PSD matrix)`` tuple defining a
            correlated noise model across detectors.
    """
    # only keep the observation at the given index
    self.data: toast.Observation = data.obs[observation_index]

    # also keep a reference to the full data container in order to apply toast operators later
    self._toast_data = data

    # toast names
    self._det_selection = det_selection
    self._det_mask = det_mask
    self._det_data = det_data
    self._pixels = pixels
    self._quats = quats
    self._hwp_angle = hwp_angle
    self._noise_model = noise_model
    self._azimuth = azimuth
    self._elevation = elevation
    self._boresight = boresight
    self._cross_psd = cross_psd  # FIXME: unused
from_file(filename, requested_fields=None) classmethod
Source code in src/furax/interfaces/toast/observation.py
@classmethod
def from_file(
    cls, filename: str | Path, requested_fields: Collection[str] | None = None
) -> ToastObservation:
    # check that file exists
    if not Path(filename).exists():
        raise FileNotFoundError(f'File {filename} does not exist')
    if isinstance(filename, Path):
        filename = filename.as_posix()

    # Prepare TOAST loading operator
    loader = LoadHDF5(files=filename)
    data = toast.Data()
    if requested_fields is None:
        loader.apply(data)
        return ToastObservation(data)

    requested = set(requested_fields)
    # translate request to toast subfield names (sets dedup overlapping requests)
    detdata: set[str] = set()
    if ReaderField.SAMPLE_DATA in requested:
        detdata.add(defaults.det_data)
    if ReaderField.VALID_SAMPLE_MASKS in requested:
        detdata.add(defaults.det_flags)

    shared = {defaults.times}  # Always need to load timestamps
    if ReaderField.HWP_ANGLES in requested:
        shared.add(defaults.hwp_angle)
    if ReaderField.BORESIGHT_QUATERNIONS in requested:
        shared.add(defaults.boresight_radec)
    if ReaderField.AZIMUTH in requested:
        shared.add(defaults.azimuth)
    if ReaderField.ELEVATION in requested:
        shared.add(defaults.elevation)

    intervals = {''}  # Toast loads all intervals if the list is empty
    # Load the precomputed scan intervals the getters read, so they need not recompute
    # them via AzimuthIntervals (which would require the azimuth shared field).
    if {ReaderField.VALID_SCANNING_MASKS, ReaderField.SCANNING_INTERVALS} & requested:
        intervals.add(defaults.scanning_interval)
    if ReaderField.LEFT_SCAN_MASK in requested:
        intervals.add(defaults.scan_rightleft_interval)
    if ReaderField.RIGHT_SCAN_MASK in requested:
        intervals.add(defaults.scan_leftright_interval)

    loader.detdata = list(detdata)
    loader.shared = list(shared)
    loader.intervals = list(intervals)

    loader.apply(data)
    return ToastObservation(data)
get_tods()

Returns the timestream data.

Source code in src/furax/interfaces/toast/observation.py
def get_tods(self) -> Float[np.ndarray, 'dets samps']:
    """Returns the timestream data."""
    # furax's LinearPolarizerOperator assumes power, TOAST assumes temperature
    tods = 0.5 * np.asarray(self.data.detdata[self._det_data][self.detectors, :])
    return np.atleast_2d(tods)
get_detector_offset_angles()

Returns the detector offset angles.

Source code in src/furax/interfaces/toast/observation.py
def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
    """Returns the detector offset angles."""
    fp = self._focal_plane
    return np.array([fp[det]['gamma'].to_value(u.rad) for det in self.detectors])
get_hwp_angles()

Returns the HWP angles.

Source code in src/furax/interfaces/toast/observation.py
def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
    """Returns the HWP angles."""
    if self._hwp_angle is None or self._hwp_angle not in self.data.shared:
        raise ValueError('HWP angle field not provided.')
    return np.asarray(self.data.shared[self._hwp_angle].data)
get_scanning_intervals()

Returns scanning intervals.

The output is a list of the starting and ending sample indices

Source code in src/furax/interfaces/toast/observation.py
def get_scanning_intervals(self) -> NDArray[Any]:
    """Returns scanning intervals.

    The output is a list of the starting and ending sample indices
    """
    if not hasattr(self.data, 'intervals') or 'scanning' not in self.data.intervals:
        # Scanning information missing, first compute the intervals
        toast.ops.AzimuthIntervals().apply(self._toast_data)
    intervals = self.data.intervals['scanning']
    return np.array(intervals[['first', 'last']].tolist())
get_sample_mask()
Source code in src/furax/interfaces/toast/observation.py
def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
    return np.asarray(self.data.detdata['flags'].data == 0, dtype=bool)
get_left_scan_mask()
Source code in src/furax/interfaces/toast/observation.py
def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    if not hasattr(self.data, 'intervals'):
        # Scanning information missing, first compute the intervals
        toast.ops.AzimuthIntervals().apply(self._toast_data)

    # Left scan means scanning FROM right TO left
    intervals_list = self.data.intervals['scan_rightleft'][['first', 'last']].tolist()
    mask = np.zeros(self.n_samples, dtype=bool)
    for start, stop in intervals_list:
        mask[start:stop] = True
    return mask
get_right_scan_mask()
Source code in src/furax/interfaces/toast/observation.py
def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    if not hasattr(self.data, 'intervals'):
        # Scanning information missing, first compute the intervals
        toast.ops.AzimuthIntervals().apply(self._toast_data)

    # Right scan means scanning FROM left TO right
    intervals_list = self.data.intervals['scan_leftright'][['first', 'last']].tolist()
    mask = np.zeros(self.n_samples, dtype=bool)
    for start, stop in intervals_list:
        mask[start:stop] = True
    return mask
get_azimuth()

Returns the azimuth of the boresight for each sample.

Source code in src/furax/interfaces/toast/observation.py
def get_azimuth(self) -> Float[np.ndarray, ' a']:
    """Returns the azimuth of the boresight for each sample."""
    if self._azimuth not in self.data.shared:
        raise ValueError('Azimuth field not provided.')
    return np.asarray(self.data.shared[self._azimuth].data)
get_elevation()

Returns the elevation of the boresight for each sample.

Source code in src/furax/interfaces/toast/observation.py
def get_elevation(self) -> Float[np.ndarray, ' a']:
    """Returns the elevation of the boresight for each sample."""
    if self._elevation not in self.data.shared:
        raise ValueError('Elevation field not provided.')
    return np.asarray(self.data.shared[self._elevation].data)
get_timestamps()

Returns timestamps (sec) of the samples.

Source code in src/furax/interfaces/toast/observation.py
def get_timestamps(self) -> Float[np.ndarray, ' a']:
    """Returns timestamps (sec) of the samples."""
    return np.asarray(self.data.shared['times'].data)
get_wcs_shape_and_kernel(resolution_arcmin, projection=ProjectionType.CAR)

Returns the shape and object corresponding to a WCS projection.

Here, this is obtained while we compute the pointing and pixelisation.

Source code in src/furax/interfaces/toast/observation.py
def get_wcs_shape_and_kernel(
    self,
    resolution_arcmin: float,
    projection: ProjectionType = ProjectionType.CAR,
) -> tuple[tuple[int, int], WCS]:
    """Returns the shape and object corresponding to a WCS projection.

    Here, this is obtained while we compute the pointing and pixelisation.
    """
    det_pointing = toast.ops.PointingDetectorSimple(
        boresight=defaults.boresight_radec, quats=self._quats
    )
    det_pixels = toast.ops.PixelsWCS(
        detector_pointing=det_pointing,
        pixels=self._pixels,
        resolution=[res := (resolution_arcmin * u.arcmin), res],
        projection=projection.name,
        dimensions=tuple(),
    )
    det_pixels.apply(self._toast_data)

    # Un-flatten the pixel indices
    pix = self._get_pixel_indices() % det_pixels._n_pix
    pix_dec = pix // det_pixels.pix_lat
    pix_ra = pix % det_pixels.pix_lat
    tot_pix = np.stack([pix_dec, pix_ra], axis=-1)
    del self.data.detdata[self._pixels]
    self.data.detdata[self._pixels] = tot_pix

    return det_pixels.wcs_shape, det_pixels.wcs
get_pointing_and_spin_angles(landscape)

Obtain pointing information and spin angles from the observation.

Source code in src/furax/interfaces/toast/observation.py
def get_pointing_and_spin_angles(
    self, landscape: StokesLandscape
) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
    """Obtain pointing information and spin angles from the observation."""
    det_keys = self.data.detdata.keys()
    if self._quats in det_keys and self._pixels in det_keys:
        indices = self._get_pixel_indices()
        spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
        return indices, spin_ang

    elif isinstance(landscape, AstropyWCSLandscape):
        raise ValueError(
            'Pointing information is missing from the data. \
                    This is supposed to be obtained when computing \
                    the WCS kernel.'
        )

    det_pointing = toast.ops.PointingDetectorSimple(
        boresight=defaults.boresight_radec, quats=self._quats
    )

    if isinstance(landscape, HealpixLandscape):
        det_pixels = toast.ops.PixelsHealpix(
            detector_pointing=det_pointing,
            pixels=self._pixels,
            nside=landscape.nside,
            nest=False,
        )
        det_pixels.apply(self._toast_data)
        indices = self._get_pixel_indices()
        spin_ang = self._get_detector_angles() - 2 * self.get_detector_offset_angles()[:, None]
        return indices, spin_ang
    else:
        raise ValueError('Invalid landscape type')
get_noise_model()

Load noise model from the focalplane data, if present. Otherwise, return None.

Source code in src/furax/interfaces/toast/observation.py
@typing.no_type_check
def get_noise_model(self) -> None | NoiseModel:
    """Load noise model from the focalplane data, if present. Otherwise, return None."""
    noise_keys = ['psd_fmin', 'psd_fknee', 'psd_alpha', 'psd_net']
    fp_data = self.data.telescope.focalplane.detector_data
    for key in noise_keys:
        if key not in fp_data.colnames:
            # Noise model cannot be loaded from the observation
            return None

    idets = np.argwhere(np.array(self.detectors)[:, None] == fp_data['name'][None, :])[:, 1]
    noise_model = AtmosphericNoiseModel(
        sigma=jnp.array(fp_data['psd_net'][idets].to(u.K * u.s**0.5).value),
        alpha=jnp.array(-fp_data['psd_alpha'][idets].value),  # Note toast's sign convention
        fk=jnp.array(fp_data['psd_fknee'][idets].to(u.Hz).value),
        f0=jnp.array(fp_data['psd_fmin'][idets].to(u.Hz).value),
    )
    return noise_model
get_boresight_quaternions()
Source code in src/furax/interfaces/toast/observation.py
def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
    if self._boresight not in self.data.shared:
        raise ValueError('Boresight field not provided.')
    quats = np.asarray(self.data.shared[self._boresight].data)
    return np.roll(quats, 1, axis=-1)
get_detector_quaternions()
Source code in src/furax/interfaces/toast/observation.py
def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
    quats = np.array([self._focal_plane[d]['quat'] for d in self.detectors])
    quats = np.roll(quats, 1, axis=-1)
    return np.atleast_2d(quats)

LazyToastObservation

Bases: FileBackedLazyObservation[Data]

Attributes:

Source code in src/furax/interfaces/toast/observation.py
class LazyToastObservation(FileBackedLazyObservation[toast.Data]):
    interface_class = ToastObservation
interface_class = ToastObservation class-attribute instance-attribute

get_local_meridian_angle(quat)

Compute angle between local meridian and orientation vector from quaternions.

Assumes that the quaternions encode the rotation between the celestial frame and some other frame (e.g. detector or boresight frame). The "orientation vector" is the unit vector of the latter frame obtained by rotating the X axis of the celestial frame. For a detector this will be the polarization sensitive direction. The local meridian vector is obtained by projecting the -Z axis of the celestial frame onto the plane orthogonal to the pointing direction.

taken from https://github.com/hpc4cmb/toast/blob/toast3/src/toast/ops/stokes_weights/kernels_numpy.py#L12

Source code in src/furax/interfaces/toast/observation.py
@partial(np.vectorize, signature='(4)->()')
def get_local_meridian_angle(quat):  # type: ignore[no-untyped-def]
    """Compute angle between local meridian and orientation vector from quaternions.

    Assumes that the quaternions encode the rotation between the celestial frame
    and some other frame (e.g. detector or boresight frame). The "orientation vector"
    is the unit vector of the latter frame obtained by rotating the X axis of the
    celestial frame. For a detector this will be the polarization sensitive direction.
    The local meridian vector is obtained by projecting the -Z axis of the celestial
    frame onto the plane orthogonal to the pointing direction.

    taken from
    https://github.com/hpc4cmb/toast/blob/toast3/src/toast/ops/stokes_weights/kernels_numpy.py#L12
    """
    zaxis = np.array([0, 0, 1], dtype=np.float64)
    xaxis = np.array([1, 0, 0], dtype=np.float64)

    vd = qa.rotate(quat, zaxis)
    vo = qa.rotate(quat, xaxis)

    # The vector orthogonal to the line of sight that is parallel
    # to the local meridian.
    dir_ang = np.arctan2(vd[1], vd[0])
    dir_r = np.sqrt(1.0 - vd[2] * vd[2])
    vm_z = -dir_r
    vm_x = vd[2] * np.cos(dir_ang)
    vm_y = vd[2] * np.sin(dir_ang)

    # Compute the rotation angle from the meridian vector to the
    # orientation vector.  The direction vector is normal to the plane
    # containing these two vectors, so the rotation angle is:
    #
    # angle = atan2((v_m x v_o) . v_d, v_m . v_o)
    #
    alpha_y = (
        vd[0] * (vm_y * vo[2] - vm_z * vo[1])
        - vd[1] * (vm_x * vo[2] - vm_z * vo[0])
        + vd[2] * (vm_x * vo[1] - vm_y * vo[0])
    )
    alpha_x = vm_x * vo[0] + vm_y * vo[1] + vm_z * vo[2]

    alpha = np.arctan2(alpha_y, alpha_x)
    return alpha