Skip to content

furax.mapmaking

Modules:

Classes:

Functions:

  • gap_fill

    Fill flagged time samples with a constrained noise realization, leaving good samples unchanged.

Attributes:

furax.mapmaking.logger = logging.getLogger('furax-mapmaking') module-attribute

furax.mapmaking.AbstractGroundObservation

Bases: AbstractObservation[T]

Class for interfacing with ground-based observation data.

Methods:

Attributes:

Source code in src/furax/mapmaking/_observation.py
class AbstractGroundObservation(AbstractObservation[T]):
    """Class for interfacing with ground-based observation data."""

    AVAILABLE_READER_FIELDS: ClassVar[frozenset[str]] = (
        AbstractObservation.AVAILABLE_READER_FIELDS
        | {
            ReaderField.VALID_SCANNING_MASKS,
            ReaderField.AZIMUTH,
            ReaderField.ELEVATION,
            ReaderField.LEFT_SCAN_MASK,
            ReaderField.RIGHT_SCAN_MASK,
            ReaderField.SCANNING_INTERVALS,
        }
    )

    OPTIONAL_READER_FIELDS: ClassVar[frozenset[str]] = (
        AbstractObservation.OPTIONAL_READER_FIELDS
        | {
            ReaderField.VALID_SCANNING_MASKS,
            ReaderField.AZIMUTH,
            ReaderField.ELEVATION,
            ReaderField.LEFT_SCAN_MASK,
            ReaderField.RIGHT_SCAN_MASK,
            ReaderField.SCANNING_INTERVALS,
        }
    )

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

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

    @abstractmethod
    def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        """Returns boolean mask (True=valid) for selection of left-going scans."""

    @abstractmethod
    def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
        """Returns boolean mask (True=valid) for selection of right-going scans."""

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

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

    def get_scanning_mask(self) -> Bool[np.ndarray, ' samp']:
        """Returns a boolean sample mask from scanning intervals (True=scanning)."""
        intervals = self.get_scanning_intervals()
        mask = np.zeros(self.n_samples, dtype=bool)
        for l, u in intervals:
            mask[l:u] = True

        return mask

    def get_detector_pointing_lonlat(
        self,
        thin_samples: int = 1,
        use_scanning_mask: bool = True,
        use_degrees: bool = True,
    ) -> tuple[Float[Array, 'det samp'], Float[Array, 'det samp']]:
        """Compute the pointing trajectory in longitude/latitude coordinates for all detectors.

        This method calculates the sky coordinates (longitude, latitude) for each detector
        at each time sample by combining boresight quaternions with detector offset quaternions.

        Args:
            thin_samples (int, default=1):
                Factor by which to subsample the time axis. If > 1, only every nth sample
                is included in the output. Applied after scanning mask if both are used.
            use_scanning_mask (bool, default=True):
                Whether to apply the scanning mask to exclude non-scanning periods.
                When True, only samples during active scanning are included.
            use_degrees (bool, default=True):
                If True, return angles in degrees. If False, return in radians.

        Returns:
            alpha (Float[Array, 'det samp']):
                Longitude coordinates (RA or azimuth) for each detector and sample.
                Shape is (n_detectors, n_samples_final) where n_samples_final depends
                on scanning mask and subsampling.
            delta (Float[Array, 'det samp']):
                Latitude coordinates (Dec or elevation) for each detector and sample.
                Shape is (n_detectors, n_samples_final).

        Notes:
            - The coordinate system depends on the boresight quaternion convention:
              typically equatorial (RA/Dec) or horizontal (Az/El)
            - Processing order: scanning mask is applied first, then subsampling
            - The quaternion multiplication combines the boresight pointing with
              detector offsets to get the absolute pointing of each detector

        Examples:
            >>> # Get pointing in degrees for all detectors
            >>> lon, lat = obs.get_detector_pointing_lonlat()
            >>> print(f"Shape: {lon.shape}")  # (n_dets, n_samples_scanning)

            >>> # Get pointing in radians without scanning mask, subsampled by 10
            >>> lon_rad, lat_rad = obs.get_detector_pointing_lonlat(
            ...     thin_samples=10, use_scanning_mask=False, use_degrees=False
            ... )

        """
        # Get quaternions for boresight and detector offsets
        boresight_quaternions = self.get_boresight_quaternions()  # (n_samples, 4)
        detector_quaternions = self.get_detector_quaternions()  # (n_dets, 4)

        # Apply scanning mask first if requested
        if use_scanning_mask:
            mask = self.get_scanning_mask()  # (n_samples,) boolean array
            boresight_quaternions = boresight_quaternions[mask, :]

        # Apply subsampling after masking if requested
        if thin_samples > 1:
            boresight_quaternions = boresight_quaternions[::thin_samples, :]

        # Combine boresight pointing with detector offsets via quaternion multiplication
        # Broadcasting: (1, n_final_samples, 4) * (n_dets, 1, 4) -> (n_dets, n_final_samples, 4)
        qdet_full = qmul(
            boresight_quaternions[None, :, :],  # (1, n_final_samples, 4)
            detector_quaternions[:, None, :],  # (n_dets, 1, 4)
        )  # Result: (n_dets, n_final_samples, 4)

        # Convert quaternions to longitude/latitude angles in radians
        alpha, delta, _ = to_lonlat_angles(qdet_full)

        # Convert to degrees if requested
        if use_degrees:
            alpha = jnp.degrees(alpha)
            delta = jnp.degrees(delta)

        return alpha, delta

AVAILABLE_READER_FIELDS = AbstractObservation.AVAILABLE_READER_FIELDS | {ReaderField.VALID_SCANNING_MASKS, ReaderField.AZIMUTH, ReaderField.ELEVATION, ReaderField.LEFT_SCAN_MASK, ReaderField.RIGHT_SCAN_MASK, ReaderField.SCANNING_INTERVALS} class-attribute

OPTIONAL_READER_FIELDS = AbstractObservation.OPTIONAL_READER_FIELDS | {ReaderField.VALID_SCANNING_MASKS, ReaderField.AZIMUTH, ReaderField.ELEVATION, ReaderField.LEFT_SCAN_MASK, ReaderField.RIGHT_SCAN_MASK, ReaderField.SCANNING_INTERVALS} class-attribute

get_scanning_intervals() abstractmethod

Returns scanning intervals.

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

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_scanning_intervals(self) -> NDArray[Any]:
    """Returns scanning intervals.

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

get_left_scan_mask() abstractmethod

Returns boolean mask (True=valid) for selection of left-going scans.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_left_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    """Returns boolean mask (True=valid) for selection of left-going scans."""

get_right_scan_mask() abstractmethod

Returns boolean mask (True=valid) for selection of right-going scans.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_right_scan_mask(self) -> Bool[np.ndarray, ' samps']:
    """Returns boolean mask (True=valid) for selection of right-going scans."""

get_azimuth() abstractmethod

Returns the azimuth of the boresight for each sample.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_azimuth(self) -> Float[np.ndarray, ' a']:
    """Returns the azimuth of the boresight for each sample."""

get_elevation() abstractmethod

Returns the elevation of the boresight for each sample.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_elevation(self) -> Float[np.ndarray, ' a']:
    """Returns the elevation of the boresight for each sample."""

get_scanning_mask()

Returns a boolean sample mask from scanning intervals (True=scanning).

Source code in src/furax/mapmaking/_observation.py
def get_scanning_mask(self) -> Bool[np.ndarray, ' samp']:
    """Returns a boolean sample mask from scanning intervals (True=scanning)."""
    intervals = self.get_scanning_intervals()
    mask = np.zeros(self.n_samples, dtype=bool)
    for l, u in intervals:
        mask[l:u] = True

    return mask

get_detector_pointing_lonlat(thin_samples=1, use_scanning_mask=True, use_degrees=True)

Compute the pointing trajectory in longitude/latitude coordinates for all detectors.

This method calculates the sky coordinates (longitude, latitude) for each detector at each time sample by combining boresight quaternions with detector offset quaternions.

Parameters:

  • thin_samples (int, default=1, default: 1 ) –

    Factor by which to subsample the time axis. If > 1, only every nth sample is included in the output. Applied after scanning mask if both are used.

  • use_scanning_mask (bool, default=True, default: True ) –

    Whether to apply the scanning mask to exclude non-scanning periods. When True, only samples during active scanning are included.

  • use_degrees (bool, default=True, default: True ) –

    If True, return angles in degrees. If False, return in radians.

Returns:

  • alpha ( Float[Array, 'det samp'] ) –

    Longitude coordinates (RA or azimuth) for each detector and sample. Shape is (n_detectors, n_samples_final) where n_samples_final depends on scanning mask and subsampling.

  • delta ( Float[Array, 'det samp'] ) –

    Latitude coordinates (Dec or elevation) for each detector and sample. Shape is (n_detectors, n_samples_final).

Notes
  • The coordinate system depends on the boresight quaternion convention: typically equatorial (RA/Dec) or horizontal (Az/El)
  • Processing order: scanning mask is applied first, then subsampling
  • The quaternion multiplication combines the boresight pointing with detector offsets to get the absolute pointing of each detector

Examples:

>>> # Get pointing in degrees for all detectors
>>> lon, lat = obs.get_detector_pointing_lonlat()
>>> print(f"Shape: {lon.shape}")  # (n_dets, n_samples_scanning)
>>> # Get pointing in radians without scanning mask, subsampled by 10
>>> lon_rad, lat_rad = obs.get_detector_pointing_lonlat(
...     thin_samples=10, use_scanning_mask=False, use_degrees=False
... )
Source code in src/furax/mapmaking/_observation.py
def get_detector_pointing_lonlat(
    self,
    thin_samples: int = 1,
    use_scanning_mask: bool = True,
    use_degrees: bool = True,
) -> tuple[Float[Array, 'det samp'], Float[Array, 'det samp']]:
    """Compute the pointing trajectory in longitude/latitude coordinates for all detectors.

    This method calculates the sky coordinates (longitude, latitude) for each detector
    at each time sample by combining boresight quaternions with detector offset quaternions.

    Args:
        thin_samples (int, default=1):
            Factor by which to subsample the time axis. If > 1, only every nth sample
            is included in the output. Applied after scanning mask if both are used.
        use_scanning_mask (bool, default=True):
            Whether to apply the scanning mask to exclude non-scanning periods.
            When True, only samples during active scanning are included.
        use_degrees (bool, default=True):
            If True, return angles in degrees. If False, return in radians.

    Returns:
        alpha (Float[Array, 'det samp']):
            Longitude coordinates (RA or azimuth) for each detector and sample.
            Shape is (n_detectors, n_samples_final) where n_samples_final depends
            on scanning mask and subsampling.
        delta (Float[Array, 'det samp']):
            Latitude coordinates (Dec or elevation) for each detector and sample.
            Shape is (n_detectors, n_samples_final).

    Notes:
        - The coordinate system depends on the boresight quaternion convention:
          typically equatorial (RA/Dec) or horizontal (Az/El)
        - Processing order: scanning mask is applied first, then subsampling
        - The quaternion multiplication combines the boresight pointing with
          detector offsets to get the absolute pointing of each detector

    Examples:
        >>> # Get pointing in degrees for all detectors
        >>> lon, lat = obs.get_detector_pointing_lonlat()
        >>> print(f"Shape: {lon.shape}")  # (n_dets, n_samples_scanning)

        >>> # Get pointing in radians without scanning mask, subsampled by 10
        >>> lon_rad, lat_rad = obs.get_detector_pointing_lonlat(
        ...     thin_samples=10, use_scanning_mask=False, use_degrees=False
        ... )

    """
    # Get quaternions for boresight and detector offsets
    boresight_quaternions = self.get_boresight_quaternions()  # (n_samples, 4)
    detector_quaternions = self.get_detector_quaternions()  # (n_dets, 4)

    # Apply scanning mask first if requested
    if use_scanning_mask:
        mask = self.get_scanning_mask()  # (n_samples,) boolean array
        boresight_quaternions = boresight_quaternions[mask, :]

    # Apply subsampling after masking if requested
    if thin_samples > 1:
        boresight_quaternions = boresight_quaternions[::thin_samples, :]

    # Combine boresight pointing with detector offsets via quaternion multiplication
    # Broadcasting: (1, n_final_samples, 4) * (n_dets, 1, 4) -> (n_dets, n_final_samples, 4)
    qdet_full = qmul(
        boresight_quaternions[None, :, :],  # (1, n_final_samples, 4)
        detector_quaternions[:, None, :],  # (n_dets, 1, 4)
    )  # Result: (n_dets, n_final_samples, 4)

    # Convert quaternions to longitude/latitude angles in radians
    alpha, delta, _ = to_lonlat_angles(qdet_full)

    # Convert to degrees if requested
    if use_degrees:
        alpha = jnp.degrees(alpha)
        delta = jnp.degrees(delta)

    return alpha, delta

furax.mapmaking.AbstractLazyObservation

Bases: ABC, Generic[T]

Deferred handle to an observation: opens its backing store only when read.

The default implementation is file-backed, but subclasses are free to back the observation by any source (e.g. a preprocessing database) by overriding get_data.

Methods:

  • get_data

    Loads observation data from the underlying source.

  • probe_shape

    Returns the buffer dimensions for this observation.

Attributes:

Source code in src/furax/mapmaking/_observation.py
class AbstractLazyObservation(ABC, Generic[T]):
    """Deferred handle to an observation: opens its backing store only when read.

    The default implementation is file-backed, but subclasses are free to back the
    observation by any source (e.g. a preprocessing database) by overriding ``get_data``.
    """

    interface_class: type[AbstractObservation[T]]

    @abstractmethod
    def get_data(self, requested_fields: Collection[str] | None = None) -> AbstractObservation[T]:
        """Loads observation data from the underlying source.

        Args:
            requested_fields: List of data fields needed.
                If None, the entire observation is loaded into memory.
                If `[]` (empty list), loads only what's needed to determine buffer shapes.
                Otherwise, loads whatever is needed to satisfy the request.
        """

    @property
    def name(self) -> str:
        """Human-readable identifier, used e.g. to report observations that failed to load."""
        return type(self).__name__

    def probe_shape(self, intervals: bool = False) -> ObservationBufferShapes:
        """Returns the buffer dimensions for this observation.

        The default opens the observation with a minimal field set request; subclasses may
        override with a cheaper query (e.g., metadata only).
        """
        if intervals:
            if ReaderField.SCANNING_INTERVALS not in self.interface_class.AVAILABLE_READER_FIELDS:
                msg = 'observation does not support reading scanning intervals'
                raise RuntimeError(msg)
            data = self.get_data([ReaderField.SCANNING_INTERVALS])
            n_intervals = data.get_scanning_intervals().shape[0]  # type: ignore[attr-defined]
        else:
            data = self.get_data([])
            n_intervals = 0
        return ObservationBufferShapes(data.n_detectors, data.n_samples, n_intervals)

interface_class instance-attribute

name property

Human-readable identifier, used e.g. to report observations that failed to load.

get_data(requested_fields=None) abstractmethod

Loads observation data from the underlying source.

Parameters:

  • requested_fields (Collection[str] | None, default: None ) –

    List of data fields needed. If None, the entire observation is loaded into memory. If [] (empty list), loads only what's needed to determine buffer shapes. Otherwise, loads whatever is needed to satisfy the request.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_data(self, requested_fields: Collection[str] | None = None) -> AbstractObservation[T]:
    """Loads observation data from the underlying source.

    Args:
        requested_fields: List of data fields needed.
            If None, the entire observation is loaded into memory.
            If `[]` (empty list), loads only what's needed to determine buffer shapes.
            Otherwise, loads whatever is needed to satisfy the request.
    """

probe_shape(intervals=False)

Returns the buffer dimensions for this observation.

The default opens the observation with a minimal field set request; subclasses may override with a cheaper query (e.g., metadata only).

Source code in src/furax/mapmaking/_observation.py
def probe_shape(self, intervals: bool = False) -> ObservationBufferShapes:
    """Returns the buffer dimensions for this observation.

    The default opens the observation with a minimal field set request; subclasses may
    override with a cheaper query (e.g., metadata only).
    """
    if intervals:
        if ReaderField.SCANNING_INTERVALS not in self.interface_class.AVAILABLE_READER_FIELDS:
            msg = 'observation does not support reading scanning intervals'
            raise RuntimeError(msg)
        data = self.get_data([ReaderField.SCANNING_INTERVALS])
        n_intervals = data.get_scanning_intervals().shape[0]  # type: ignore[attr-defined]
    else:
        data = self.get_data([])
        n_intervals = 0
    return ObservationBufferShapes(data.n_detectors, data.n_samples, n_intervals)

furax.mapmaking.AbstractObservation

Bases: ABC, Generic[T]

Abstract class for interfacing with any observation data.

This class defines what data is needed for making maps. It is meant to be used as a base class for interfacing with different containers (e.g. toast's Observation, sotodlib's AxisManager, litebird_sim's Observation, etc.)

Methods:

Attributes:

Source code in src/furax/mapmaking/_observation.py
class AbstractObservation(ABC, Generic[T]):
    """Abstract class for interfacing with any observation data.

    This class defines what data is needed for making maps. It is meant to be
    used as a base class for interfacing with different containers (e.g. toast's
    ``Observation``, sotodlib's ``AxisManager``, litebird_sim's ``Observation``, etc.)
    """

    AVAILABLE_READER_FIELDS: ClassVar[frozenset[str]] = frozenset(
        {
            ReaderField.METADATA,
            ReaderField.SAMPLE_DATA,
            ReaderField.VALID_SAMPLE_MASKS,
            ReaderField.TIMESTAMPS,
            ReaderField.HWP_ANGLES,
            ReaderField.DETECTOR_QUATERNIONS,
            ReaderField.BORESIGHT_QUATERNIONS,
            ReaderField.NOISE_MODEL_FITS,
        }
    )
    """Supported data field names for all observations"""

    OPTIONAL_READER_FIELDS: ClassVar[frozenset[str]] = frozenset({ReaderField.NOISE_MODEL_FITS})
    """Optional data field names"""

    def __init__(self, data: T) -> None:
        self.data = data

    @classmethod
    @abstractmethod
    def from_file(
        cls, filename: str | Path, requested_fields: Collection[str] | None = None
    ) -> AbstractObservation[T]:
        """Loads the observation from a binary file.

        Args:
            filename: The binary file.
            requested_fields: List of data fields needed.
                If None, the entire file is loaded into memory.
                If `[]` (empty list), loads only what's needed to determine buffer shapes.
                Otherwise, loads whatever is needed to satisfy the request.
        """

    @property
    @abstractmethod
    def name(self) -> str:
        """Observation name."""

    @property
    @abstractmethod
    def telescope(self) -> str:
        """Telescope name."""

    @property
    @abstractmethod
    def n_samples(self) -> int:
        """Returns the number of samples in the observation."""

    @property
    @abstractmethod
    def detectors(self) -> list[str]:
        """Returns a list of the detector names."""

    @property
    def n_detectors(self) -> int:
        return len(self.detectors)

    @property
    @abstractmethod
    def sample_rate(self) -> float:
        """Returns the sampling rate (in Hz) of the data."""

    @abstractmethod
    def get_tods(self) -> Float[np.ndarray, 'dets samps']:
        """Returns the timestream data.

        Returns a host (numpy) array: getters feed the reader's io_callback, which
        performs a single host->device transfer. Returning a device (jax) array would
        force a wasteful device->host->device round trip at the callback boundary.
        """

    @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') -> Stokes:
        """Returns demodulated timestream data as a Stokes pytree.

        Subclasses that support demodulated data should override this method.
        """
        raise NotImplementedError(f'{type(self).__name__} does not support demodulated TODs')

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

        Its per-detector parameters carry a leading Stokes axis, so I/Q/U may have distinct
        fit values without being separate models.

        Subclasses that support demodulated data should override this method.
        """
        raise NotImplementedError(
            f'{type(self).__name__} does not support demodulated noise models'
        )

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

    @abstractmethod
    def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
        """Returns the HWP angles."""

    def get_hwp_frequency(self) -> Float[np.ndarray, '']:
        """Returns the average HWP rotation frequency in Hz."""
        hwp_angles = self.get_hwp_angles()
        timestamps = self.get_timestamps()
        return np.asarray(
            (np.unwrap(hwp_angles)[-1] - hwp_angles[0]) / np.ptp(timestamps) / (2 * np.pi)
        )

    @abstractmethod
    def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
        """Returns boolean sample mask (True=valid) of the TOD."""

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

    def get_elapsed_times(self) -> Float[np.ndarray, ' a']:
        """Returns time (sec) of the samples since the observation began."""
        timestamps = self.get_timestamps()
        return timestamps - timestamps[0]  # type: ignore[no-any-return]

    @abstractmethod
    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."""

    @abstractmethod
    def get_pointing_and_spin_angles(
        self, landscape: StokesLandscape
    ) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
        """Obtain pointing information and spin angles from the observation."""

    @abstractmethod
    def get_noise_model(self) -> None | NoiseModel:
        """Load a pre-computed noise model from the data, if present. Otherwise, return None."""

    @abstractmethod
    def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
        """Returns the boresight quaternions at each time sample."""

    @abstractmethod
    def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
        """Returns the quaternion offsets of the detectors."""

AVAILABLE_READER_FIELDS = frozenset({ReaderField.METADATA, ReaderField.SAMPLE_DATA, ReaderField.VALID_SAMPLE_MASKS, ReaderField.TIMESTAMPS, ReaderField.HWP_ANGLES, ReaderField.DETECTOR_QUATERNIONS, ReaderField.BORESIGHT_QUATERNIONS, ReaderField.NOISE_MODEL_FITS}) class-attribute

Supported data field names for all observations

OPTIONAL_READER_FIELDS = frozenset({ReaderField.NOISE_MODEL_FITS}) class-attribute

Optional data field names

data = data instance-attribute

name abstractmethod property

Observation name.

telescope abstractmethod property

Telescope name.

n_samples abstractmethod property

Returns the number of samples in the observation.

detectors abstractmethod property

Returns a list of the detector names.

n_detectors property

sample_rate abstractmethod property

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

__init__(data)

Source code in src/furax/mapmaking/_observation.py
def __init__(self, data: T) -> None:
    self.data = data

from_file(filename, requested_fields=None) abstractmethod classmethod

Loads the observation from a binary file.

Parameters:

  • filename (str | Path) –

    The binary file.

  • requested_fields (Collection[str] | None, default: None ) –

    List of data fields needed. If None, the entire file is loaded into memory. If [] (empty list), loads only what's needed to determine buffer shapes. Otherwise, loads whatever is needed to satisfy the request.

Source code in src/furax/mapmaking/_observation.py
@classmethod
@abstractmethod
def from_file(
    cls, filename: str | Path, requested_fields: Collection[str] | None = None
) -> AbstractObservation[T]:
    """Loads the observation from a binary file.

    Args:
        filename: The binary file.
        requested_fields: List of data fields needed.
            If None, the entire file is loaded into memory.
            If `[]` (empty list), loads only what's needed to determine buffer shapes.
            Otherwise, loads whatever is needed to satisfy the request.
    """

get_tods() abstractmethod

Returns the timestream data.

Returns a host (numpy) array: getters feed the reader's io_callback, which performs a single host->device transfer. Returning a device (jax) array would force a wasteful device->host->device round trip at the callback boundary.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_tods(self) -> Float[np.ndarray, 'dets samps']:
    """Returns the timestream data.

    Returns a host (numpy) array: getters feed the reader's io_callback, which
    performs a single host->device transfer. Returning a device (jax) array would
    force a wasteful device->host->device round trip at the callback boundary.
    """

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 demodulated timestream data as a Stokes pytree.

Subclasses that support demodulated data should override this method.

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

    Subclasses that support demodulated data should override this method.
    """
    raise NotImplementedError(f'{type(self).__name__} does not support demodulated TODs')

get_demodulated_noise_model(stokes='IQU')

Returns a single noise model covering every requested Stokes leg.

Its per-detector parameters carry a leading Stokes axis, so I/Q/U may have distinct fit values without being separate models.

Subclasses that support demodulated data should override this method.

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

    Its per-detector parameters carry a leading Stokes axis, so I/Q/U may have distinct
    fit values without being separate models.

    Subclasses that support demodulated data should override this method.
    """
    raise NotImplementedError(
        f'{type(self).__name__} does not support demodulated noise models'
    )

get_detector_offset_angles() abstractmethod

Returns the detector offset angles ('gamma').

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_detector_offset_angles(self) -> Float[np.ndarray, ' dets']:
    """Returns the detector offset angles ('gamma')."""

get_hwp_angles() abstractmethod

Returns the HWP angles.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_hwp_angles(self) -> Float[np.ndarray, ' a']:
    """Returns the HWP angles."""

get_hwp_frequency()

Returns the average HWP rotation frequency in Hz.

Source code in src/furax/mapmaking/_observation.py
def get_hwp_frequency(self) -> Float[np.ndarray, '']:
    """Returns the average HWP rotation frequency in Hz."""
    hwp_angles = self.get_hwp_angles()
    timestamps = self.get_timestamps()
    return np.asarray(
        (np.unwrap(hwp_angles)[-1] - hwp_angles[0]) / np.ptp(timestamps) / (2 * np.pi)
    )

get_sample_mask() abstractmethod

Returns boolean sample mask (True=valid) of the TOD.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_sample_mask(self) -> Bool[np.ndarray, 'dets samps']:
    """Returns boolean sample mask (True=valid) of the TOD."""

get_timestamps() abstractmethod

Returns timestamps (sec) of the samples.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_timestamps(self) -> Float[np.ndarray, ' a']:
    """Returns timestamps (sec) of the samples."""

get_elapsed_times()

Returns time (sec) of the samples since the observation began.

Source code in src/furax/mapmaking/_observation.py
def get_elapsed_times(self) -> Float[np.ndarray, ' a']:
    """Returns time (sec) of the samples since the observation began."""
    timestamps = self.get_timestamps()
    return timestamps - timestamps[0]  # type: ignore[no-any-return]

get_wcs_shape_and_kernel(resolution_arcmin, projection=ProjectionType.CAR) abstractmethod

Returns the shape and object corresponding to a WCS projection.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
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."""

get_pointing_and_spin_angles(landscape) abstractmethod

Obtain pointing information and spin angles from the observation.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_pointing_and_spin_angles(
    self, landscape: StokesLandscape
) -> tuple[Float[Array, ' ...'], Float[Array, ' ...']]:
    """Obtain pointing information and spin angles from the observation."""

get_noise_model() abstractmethod

Load a pre-computed noise model from the data, if present. Otherwise, return None.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_noise_model(self) -> None | NoiseModel:
    """Load a pre-computed noise model from the data, if present. Otherwise, return None."""

get_boresight_quaternions() abstractmethod

Returns the boresight quaternions at each time sample.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_boresight_quaternions(self) -> Float[np.ndarray, 'samp 4']:
    """Returns the boresight quaternions at each time sample."""

get_detector_quaternions() abstractmethod

Returns the quaternion offsets of the detectors.

Source code in src/furax/mapmaking/_observation.py
@abstractmethod
def get_detector_quaternions(self) -> Float[np.ndarray, 'det 4']:
    """Returns the quaternion offsets of the detectors."""

furax.mapmaking.AbstractSatelliteObservation

Bases: AbstractObservation[T]

Class for interfacing with satellite observation data.

Source code in src/furax/mapmaking/_observation.py
class AbstractSatelliteObservation(AbstractObservation[T]):
    """Class for interfacing with satellite observation data."""

    pass

furax.mapmaking.FileBackedLazyObservation

Bases: AbstractLazyObservation[T]

Lazy observation whose backing store is a single binary file.

Methods:

Attributes:

Source code in src/furax/mapmaking/_observation.py
class FileBackedLazyObservation(AbstractLazyObservation[T]):
    """Lazy observation whose backing store is a single binary file."""

    def __init__(self, filename: str | Path):
        self.file = Path(filename).resolve()
        if not self.file.exists():
            raise FileNotFoundError(f'Observation file {self.file} does not exist')

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

    def get_data(self, requested_fields: Collection[str] | None = None) -> AbstractObservation[T]:
        return self.interface_class.from_file(self.file, requested_fields)

file = Path(filename).resolve() instance-attribute

name property

__init__(filename)

Source code in src/furax/mapmaking/_observation.py
def __init__(self, filename: str | Path):
    self.file = Path(filename).resolve()
    if not self.file.exists():
        raise FileNotFoundError(f'Observation file {self.file} does not exist')

get_data(requested_fields=None)

Source code in src/furax/mapmaking/_observation.py
def get_data(self, requested_fields: Collection[str] | None = None) -> AbstractObservation[T]:
    return self.interface_class.from_file(self.file, requested_fields)

furax.mapmaking.HashedObservationMetadata dataclass

Hashed version of some metadata fields for JAX compatibility.

Methods:

Attributes:

Source code in src/furax/mapmaking/_observation.py
@register_dataclass
@dataclass
class HashedObservationMetadata:
    """Hashed version of some metadata fields for JAX compatibility."""

    uid: UInt32[np.ndarray | Array, '']
    telescope_uid: UInt32[np.ndarray | Array, '']
    detector_uids: UInt32[np.ndarray | Array, '*#dets']

    @classmethod
    def from_observation(cls, obs: AbstractObservation[T]) -> Self:
        return cls(
            uid=_names_to_uids(obs.name),
            telescope_uid=_names_to_uids(obs.telescope),
            detector_uids=_names_to_uids(obs.detectors),
        )

    @classmethod
    def structure_for(cls, n_dets: int) -> Self:
        return cls(
            uid=jax.ShapeDtypeStruct((), dtype=np.uint32),  # type: ignore[arg-type]
            telescope_uid=jax.ShapeDtypeStruct((), dtype=np.uint32),  # type: ignore[arg-type]
            detector_uids=jax.ShapeDtypeStruct((n_dets,), dtype=np.uint32),  # type: ignore[arg-type]
        )

    def split_key(self, key: Key[Array, '']) -> Key[Array, '*#dets']:
        fold = jnp.vectorize(jax.random.fold_in, signature='(),()->()')
        return fold(fold(fold(key, self.uid), self.telescope_uid), self.detector_uids)  # type: ignore[no-any-return]

uid instance-attribute

telescope_uid instance-attribute

detector_uids instance-attribute

from_observation(obs) classmethod

Source code in src/furax/mapmaking/_observation.py
@classmethod
def from_observation(cls, obs: AbstractObservation[T]) -> Self:
    return cls(
        uid=_names_to_uids(obs.name),
        telescope_uid=_names_to_uids(obs.telescope),
        detector_uids=_names_to_uids(obs.detectors),
    )

structure_for(n_dets) classmethod

Source code in src/furax/mapmaking/_observation.py
@classmethod
def structure_for(cls, n_dets: int) -> Self:
    return cls(
        uid=jax.ShapeDtypeStruct((), dtype=np.uint32),  # type: ignore[arg-type]
        telescope_uid=jax.ShapeDtypeStruct((), dtype=np.uint32),  # type: ignore[arg-type]
        detector_uids=jax.ShapeDtypeStruct((n_dets,), dtype=np.uint32),  # type: ignore[arg-type]
    )

split_key(key)

Source code in src/furax/mapmaking/_observation.py
def split_key(self, key: Key[Array, '']) -> Key[Array, '*#dets']:
    fold = jnp.vectorize(jax.random.fold_in, signature='(),()->()')
    return fold(fold(fold(key, self.uid), self.telescope_uid), self.detector_uids)  # type: ignore[no-any-return]

__init__(uid, telescope_uid, detector_uids)

furax.mapmaking.ObservationBufferShapes

Bases: NamedTuple

Attributes:

Source code in src/furax/mapmaking/_observation.py
class ObservationBufferShapes(NamedTuple):
    detector_count: int
    sample_count: int
    interval_count: int = 0

detector_count instance-attribute

sample_count instance-attribute

interval_count = 0 class-attribute instance-attribute

furax.mapmaking.ReaderField

Bases: StrEnum

Canonical names of the data fields an observation reader can load.

Members are plain strings (StrEnum), so they interoperate with string keys in field dictionaries and set membership tests. Using members instead of bare literals makes typos a name-resolution error caught by linting/type-checking.

Attributes:

Source code in src/furax/mapmaking/_observation.py
class ReaderField(StrEnum):
    """Canonical names of the data fields an observation reader can load.

    Members are plain strings (``StrEnum``), so they interoperate with string keys in
    field dictionaries and set membership tests. Using members instead of bare literals
    makes typos a name-resolution error caught by linting/type-checking.
    """

    METADATA = 'metadata'
    SAMPLE_DATA = 'sample_data'
    VALID_SAMPLE_MASKS = 'valid_sample_masks'
    VALID_SCANNING_MASKS = 'valid_scanning_masks'
    TIMESTAMPS = 'timestamps'
    HWP_ANGLES = 'hwp_angles'
    DETECTOR_QUATERNIONS = 'detector_quaternions'
    BORESIGHT_QUATERNIONS = 'boresight_quaternions'
    NOISE_MODEL_FITS = 'noise_model_fits'
    AZIMUTH = 'azimuth'
    ELEVATION = 'elevation'
    LEFT_SCAN_MASK = 'left_scan_mask'
    RIGHT_SCAN_MASK = 'right_scan_mask'
    SCANNING_INTERVALS = 'scanning_intervals'

METADATA = 'metadata' class-attribute instance-attribute

SAMPLE_DATA = 'sample_data' class-attribute instance-attribute

VALID_SAMPLE_MASKS = 'valid_sample_masks' class-attribute instance-attribute

VALID_SCANNING_MASKS = 'valid_scanning_masks' class-attribute instance-attribute

TIMESTAMPS = 'timestamps' class-attribute instance-attribute

HWP_ANGLES = 'hwp_angles' class-attribute instance-attribute

DETECTOR_QUATERNIONS = 'detector_quaternions' class-attribute instance-attribute

BORESIGHT_QUATERNIONS = 'boresight_quaternions' class-attribute instance-attribute

NOISE_MODEL_FITS = 'noise_model_fits' class-attribute instance-attribute

AZIMUTH = 'azimuth' class-attribute instance-attribute

ELEVATION = 'elevation' class-attribute instance-attribute

LEFT_SCAN_MASK = 'left_scan_mask' class-attribute instance-attribute

RIGHT_SCAN_MASK = 'right_scan_mask' class-attribute instance-attribute

SCANNING_INTERVALS = 'scanning_intervals' class-attribute instance-attribute

furax.mapmaking.ObservationReader

Bases: AbstractReader, Generic[T]

Jittable reader for ground observations.

The reader is set up with a list of filenames and data field names. Individual files can be loaded by passing an index in this list to the read method. The observation data is padded so that all observations have the same structure.

The available data fields for ground observations are
  • metadata: observation, telescope and detector uids.
  • sample_data: the detector read-outs.
  • valid_sample_masks: the (boolean) mask indicating which samples are valid (=True).
  • valid_scanning_masks: the (boolean) mask indicating which samples are taken during scans (and not turnarounds).
  • timestamps: the timestamps of the samples.
  • hwp_angles: the half-wave plate angle measured at each sample.
  • detector_quaternions: the detector quaternions.
  • boresight_quaternions: the boresight quaternions.
  • noise_model_fits: the fitted parameters for the noise model (1/f noise by default).
  • azimuth, elevation: the boresight scan angles (ground observations).
  • left_scan_mask, right_scan_mask: per-sample masks splitting left/right-going scans.
  • scanning_intervals: per-scan [start, end) sample-index pairs (ground observations).

Methods:

Attributes:

Source code in src/furax/mapmaking/_reader.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 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
@register_static
class ObservationReader(AbstractReader, Generic[T]):
    """Jittable reader for ground observations.

    The reader is set up with a list of filenames and data field names. Individual files can be
    loaded by passing an index in this list to the `read` method. The observation data is padded
    so that all observations have the same structure.

    The available data fields for ground observations are:
        - metadata: observation, telescope and detector uids.
        - sample_data: the detector read-outs.
        - valid_sample_masks: the (boolean) mask indicating which samples are valid (=True).
        - valid_scanning_masks: the (boolean) mask indicating which samples are taken
            during scans (and not turnarounds).
        - timestamps: the timestamps of the samples.
        - hwp_angles: the half-wave plate angle measured at each sample.
        - detector_quaternions: the detector quaternions.
        - boresight_quaternions: the boresight quaternions.
        - noise_model_fits: the fitted parameters for the noise model (1/f noise by default).
        - azimuth, elevation: the boresight scan angles (ground observations).
        - left_scan_mask, right_scan_mask: per-sample masks splitting left/right-going scans.
        - scanning_intervals: per-scan [start, end) sample-index pairs (ground observations).
    """

    def __init__(
        self,
        *args: Sequence[Any],
        demodulated: bool,
        stokes: ValidStokesType,
        dtype: DTypeLike = jnp.float64,
        common_keywords: dict[str, Any] | None = None,
        shapes: list[tuple[int, ...]] | None = None,
        known_failures: Sequence[int] | None = None,
        **keywords: Sequence[Any],
    ) -> None:
        # Set before super().__init__ so the structure/reader builders can use them
        self.demodulated = demodulated
        self.stokes = stokes
        self.dtype = dtype
        # Distributed mode passes pre-gathered (n_detectors, n_samples) shapes; turn them into
        # structures here, now that self carries demodulated/stokes/dtype. Otherwise leave them
        # unset and super() opens every observation via _read_structure_impure.
        structures = None
        if shapes is not None:
            fields = (common_keywords or {})['data_field_names']
            structures = [self._get_data_field_structures_for(shape, fields) for shape in shapes]
        super().__init__(
            *args,
            common_keywords=common_keywords,
            structures=structures,
            known_failures=known_failures,
            **keywords,
        )

    @classmethod
    def from_observations(
        cls,
        observations: Sequence[AbstractLazyObservation[T]],
        *,
        read_indices: Sequence[int] | None = None,
        requested_fields: Collection[str] | None = None,
        demodulated: bool = False,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = jnp.float64,
    ) -> Self:
        """Create a reader, performing I/O to infer data structures.

        Args:
            observations: Full list of lazy observations.
            read_indices: Optional indices into ``observations``; when set, only
                those are opened on this process to infer shapes, and shapes are
                synchronised across processes (distributed-mode shortcut).
            requested_fields: Optional list of fields to load. If None, read all non-optional fields.
            demodulated: Whether to read demodulated TODs.
            stokes: Stokes components to read when demodulated.
            dtype: Floating-point dtype applied to every floating-point field the reader
                returns: sample data, noise model fits and the geometry (timestamps, HWP
                angles, quaternions). Use jnp.float32 to run a float32 mapmaking pipeline
                (MapMakingConfig.double_precision=False). Casting the geometry is also
                required there: under jax_enable_x64=False a float64 array is illegal, so
                no field may stay float64. Timestamps are rebased to a per-observation
                zero origin (in float64, before the downcast) so the float32 cast does not
                collapse the absolute POSIX epoch onto a single value; see the timestamps
                reader in ``_get_data_field_readers``.
        """
        fields = cls._resolve_fields(observations, requested_fields)
        # In the default path, leave ``shapes`` unset so AbstractReader.__init__ opens every
        # observation on this process to infer its structure. In distributed mode, gather the
        # per-observation shapes from the local subset and all-gather them (see ``_gather_shapes``).
        shapes = None
        known_failures = None
        if read_indices is not None:
            shapes, known_failures = cls._gather_shapes(observations, read_indices, fields)
        return cls(
            observations,
            common_keywords={'data_field_names': fields},
            demodulated=demodulated,
            stokes=stokes,
            dtype=dtype,
            shapes=shapes,
            known_failures=known_failures,
        )

    @staticmethod
    def _resolve_fields(
        observations: Sequence[AbstractLazyObservation[T]],
        requested_fields: Collection[str] | None,
    ) -> set[str]:
        interface = observations[0].interface_class
        available = set(interface.AVAILABLE_READER_FIELDS)
        optional = set(interface.OPTIONAL_READER_FIELDS)
        if requested_fields is None:
            return available - optional
        fields = set(requested_fields)
        unsupported = fields - available
        if unsupported:
            raise ValueError(
                f'Requested data fields {unsupported} are not supported by the interface.'
            )
        return fields

    @staticmethod
    def _gather_shapes(
        observations: Sequence[AbstractLazyObservation[T]],
        read_indices: Sequence[int],
        fields: Collection[str],
    ) -> tuple[list[tuple[int, ...]], list[int]]:
        """Gather every observation's ``probe_shape()`` tuple in distributed mode.

        Each process probes only its ``read_indices`` subset; an all-gather then makes every rank
        agree on the full shape list, so padding / out_structure / etc. stay consistent.

        A probe that raises must not crash the rank (it would deadlock the others at the all-gather):
        the observation is given a dummy ``(1, 1)`` shape so it is excluded from the buffer-sizing
        max, and its (local) index is returned so the reader skips loading it and gates it out.

        Returns ``(shapes, failed_indices)`` where ``failed_indices`` are this process's
        probe-failed observation indices.
        """
        failed: list[int] = []
        need_intervals = ReaderField.SCANNING_INTERVALS in fields

        def probe(idx: int) -> tuple[int, tuple[int, ...]]:
            try:
                # retain observation index so we can dedup after gathering
                return idx, tuple(observations[idx].probe_shape(intervals=need_intervals))
            except Exception:
                logger.exception('probe of observation %d failed', idx)
                failed.append(idx)
                return idx, (1, 1, 0)

        local = [probe(idx) for idx in read_indices]
        width = 1 + len(local[0][1])  # each row is (idx, *shape)
        local_rows = np.array([(idx, *shape) for idx, shape in local], dtype=np.int32)

        # Drop potential duplicates (from padding) and sort by obs index
        all_rows = mhu.process_allgather(local_rows).reshape(-1, width)
        shapes = [tuple(row[1:]) for row in np.unique(all_rows, axis=0)]
        if not (ns := len(shapes)) == (no := len(observations)):
            msg = f'inconsistent observation shapes after allgather: expected {no}, got {ns}'
            raise RuntimeError(msg)
        return shapes, failed

    def _pad(
        self, data: PyTree[np.ndarray], padding: PyTree[tuple[int, ...]]
    ) -> PyTree[np.ndarray]:
        """Pads one ground observation to the common structure, on the host (numpy).

        The data is padded differently depending on the key:
            - sample_data: padded with 0.0 outside the valid samples
            - timestamps, hwp_angles: extrapolated in the padded region so that
                the sample rate and the hwp rotation frequency remain consistent
            - valid_sample_masks, valid_scanning_masks : padded with 0 (False) outside
                the valid samples
            - detector_quaternions: padded with (1, 0, 0, 0) for invalid detectors, as if they
                are located at the centre of the focal plane.
            - boresight_quaternions: padded with the last valid sample's quaternion, as if
                the telescoped stopped moving since then.
            - noise_model_fits: padded with (sigma, alpha, fknee, f0) = (0., 0., 1., 0.1)
        """
        # First, pad them with 0 by default
        data = super()._pad(data, padding)

        # Handle fields with non-zero padding
        data_field_names = self.common_keywords['data_field_names']
        if ReaderField.TIMESTAMPS in data_field_names:
            # Extrapolate in the padded region for constant sample rate
            pad_size = padding[ReaderField.TIMESTAMPS][0]
            valid = data[ReaderField.TIMESTAMPS][: data[ReaderField.TIMESTAMPS].size - pad_size]
            dt = (valid[-1] - valid[0]) / (valid.size - 1)  # Mean time spacing
            data[ReaderField.TIMESTAMPS] = np.pad(
                valid, (0, pad_size), mode='linear_ramp', end_values=valid[-1] + dt * pad_size
            )
        if ReaderField.HWP_ANGLES in data_field_names:
            # Extrapolate in the padded region for constant hwp rotation frequency
            pad_size = padding[ReaderField.HWP_ANGLES][0]
            valid = data[ReaderField.HWP_ANGLES][: data[ReaderField.HWP_ANGLES].size - pad_size]
            dphi = (np.unwrap(valid)[-1] - valid[0]) / (valid.size - 1)  # Mean angle spacing
            data[ReaderField.HWP_ANGLES] = np.pad(
                valid, (0, pad_size), mode='linear_ramp', end_values=valid[-1] + dphi * pad_size
            ) % (2 * np.pi)
        for angle_field in (ReaderField.AZIMUTH, ReaderField.ELEVATION):
            if angle_field in data_field_names:
                # Hold the last valid value (telescope stopped moving). Keeps min/ptp of the
                # scan unchanged, so Legendre/bin basis normalisation is unaffected.
                pad_size = padding[angle_field][0]
                valid = data[angle_field][: data[angle_field].size - pad_size]
                data[angle_field] = np.pad(valid, (0, pad_size), mode='edge')
        if ReaderField.SCANNING_INTERVALS in data_field_names:
            # Pad with zero-width [end, end) intervals anchored at the last real interval's
            # end, not [0, 0]: keeps `starts` non-decreasing, and a zero-width interval never
            # contains a real sample.
            pad_size = padding[ReaderField.SCANNING_INTERVALS][0]
            intervals = data[ReaderField.SCANNING_INTERVALS]
            valid = intervals[: intervals.shape[0] - pad_size]
            last_end = valid[-1, 1]
            data[ReaderField.SCANNING_INTERVALS] = np.concatenate(
                [valid, np.full((pad_size, 2), last_end, dtype=intervals.dtype)]
            )
        if ReaderField.DETECTOR_QUATERNIONS in data_field_names:
            # Pad with (1, 0, 0, 0), corresponding to xi=eta=gamma=0.
            quats = data[ReaderField.DETECTOR_QUATERNIONS]
            zero_padded = np.linalg.norm(quats, axis=-1) == 0.0
            data[ReaderField.DETECTOR_QUATERNIONS] = np.where(
                zero_padded[:, None],
                np.array([[1.0, 0.0, 0.0, 0.0]], dtype=quats.dtype),
                quats,
            )
        if ReaderField.BORESIGHT_QUATERNIONS in data_field_names:
            # Pad with the last non-zero quaternion provided.
            quats = data[ReaderField.BORESIGHT_QUATERNIONS]
            pad_size = padding[ReaderField.BORESIGHT_QUATERNIONS][0]  # samples axis
            last_quaternion = quats[-pad_size - 1, :]
            zero_padded = np.linalg.norm(quats, axis=-1) == 0.0
            data[ReaderField.BORESIGHT_QUATERNIONS] = np.where(
                zero_padded[:, None], last_quaternion[None, :], quats
            )
        if ReaderField.NOISE_MODEL_FITS in data_field_names:

            def _pad_noise_fits(arr: np.ndarray) -> np.ndarray:
                default = np.array([0.0, 0.0, 1.0, 0.1], dtype=arr.dtype)
                zero_padded = arr[..., 0] == 0.0
                return np.where(zero_padded[..., None], default, arr)

            data[ReaderField.NOISE_MODEL_FITS] = jax.tree.map(
                _pad_noise_fits, data[ReaderField.NOISE_MODEL_FITS]
            )

        return data

    def _get_data_field_structures_for(
        self,
        shape: tuple[int, ...],
        fields: Collection[str] | None = None,
    ) -> PyTree[jax.ShapeDtypeStruct]:
        """Build the padded-buffer structures for one observation from its ``probe_shape()``.

        Restricted to ``fields`` when given, else returns every supported field.
        """
        n_detectors, n_samples, n_intervals = shape
        demodulated = self.demodulated
        stokes = self.stokes
        dtype = self.dtype
        tod_shape = (n_detectors, n_samples)
        sample_data_structure = (
            Stokes.class_for(stokes).structure_for(tod_shape, dtype)
            if demodulated
            else jax.ShapeDtypeStruct(tod_shape, dtype)
        )

        structures: dict[str, PyTree[jax.ShapeDtypeStruct]] = {
            ReaderField.METADATA: HashedObservationMetadata.structure_for(n_detectors),
            ReaderField.SAMPLE_DATA: sample_data_structure,
            ReaderField.VALID_SAMPLE_MASKS: jax.ShapeDtypeStruct(
                (n_detectors, n_samples), jnp.bool
            ),
            ReaderField.VALID_SCANNING_MASKS: jax.ShapeDtypeStruct((n_samples,), jnp.bool),
            ReaderField.TIMESTAMPS: jax.ShapeDtypeStruct((n_samples,), dtype),
            ReaderField.HWP_ANGLES: jax.ShapeDtypeStruct((n_samples,), dtype),
            ReaderField.DETECTOR_QUATERNIONS: jax.ShapeDtypeStruct((n_detectors, 4), dtype),
            ReaderField.BORESIGHT_QUATERNIONS: jax.ShapeDtypeStruct((n_samples, 4), dtype),
            ReaderField.NOISE_MODEL_FITS: (
                jax.ShapeDtypeStruct((len(stokes), n_detectors, 4), dtype)
                if demodulated
                else jax.ShapeDtypeStruct((n_detectors, 4), dtype)
            ),
            ReaderField.AZIMUTH: jax.ShapeDtypeStruct((n_samples,), dtype),
            ReaderField.ELEVATION: jax.ShapeDtypeStruct((n_samples,), dtype),
            ReaderField.LEFT_SCAN_MASK: jax.ShapeDtypeStruct((n_samples,), jnp.bool),
            ReaderField.RIGHT_SCAN_MASK: jax.ShapeDtypeStruct((n_samples,), jnp.bool),
            ReaderField.SCANNING_INTERVALS: jax.ShapeDtypeStruct((n_intervals, 2), jnp.int32),
        }
        if fields is None:
            return structures
        return {field: structures[field] for field in fields}

    def _get_data_field_readers(self):  # type: ignore[no-untyped-def]
        def if_none_raise_error(x: Any) -> Any:
            if x is None:
                raise ValueError('Data field not available')
            return x

        demodulated = self.demodulated
        stokes = self.stokes
        target_dtype = self.dtype

        def get_sample_data(obs: AbstractObservation[T]) -> Any:
            tods: Stokes | np.ndarray
            if demodulated:
                tods = obs.get_demodulated_tods(stokes=stokes)
            else:
                tods = obs.get_tods()
            return jax.tree.map(lambda x: x.astype(target_dtype), tods)

        def get_noise_model_fits(obs: AbstractObservation[T]) -> Any:
            if demodulated:
                model = obs.get_demodulated_noise_model(stokes=stokes)
            else:
                model = if_none_raise_error(obs.get_noise_model())
            fits = model.to_array()
            return jax.tree.map(lambda x: x.astype(target_dtype), fits)

        def get_timestamps(obs: AbstractObservation[T]) -> Any:
            # The pipeline uses only time differences, so anchor each observation at its
            # own start. Subtracting in float64 keeps the samples resolved when cast to
            # float32; the absolute epoch is read from the interface where needed.
            timestamps = np.asarray(obs.get_timestamps(), dtype=np.float64)
            return (timestamps - timestamps[0]).astype(target_dtype)

        return {
            ReaderField.METADATA: lambda obs: HashedObservationMetadata.from_observation(obs),
            ReaderField.SAMPLE_DATA: get_sample_data,
            ReaderField.VALID_SAMPLE_MASKS: lambda obs: obs.get_sample_mask(),
            ReaderField.VALID_SCANNING_MASKS: lambda obs: obs.get_scanning_mask(),
            ReaderField.TIMESTAMPS: get_timestamps,
            ReaderField.HWP_ANGLES: lambda obs: obs.get_hwp_angles().astype(target_dtype),
            ReaderField.DETECTOR_QUATERNIONS: lambda obs: obs.get_detector_quaternions().astype(
                target_dtype
            ),
            ReaderField.BORESIGHT_QUATERNIONS: lambda obs: obs.get_boresight_quaternions().astype(
                target_dtype
            ),
            ReaderField.NOISE_MODEL_FITS: get_noise_model_fits,
            ReaderField.AZIMUTH: lambda obs: obs.get_azimuth().astype(target_dtype),
            ReaderField.ELEVATION: lambda obs: obs.get_elevation().astype(target_dtype),
            ReaderField.LEFT_SCAN_MASK: lambda obs: obs.get_left_scan_mask().astype(bool),
            ReaderField.RIGHT_SCAN_MASK: lambda obs: obs.get_right_scan_mask().astype(bool),
            ReaderField.SCANNING_INTERVALS: lambda obs: np.asarray(
                obs.get_scanning_intervals(), dtype=np.int32
            ),
        }

    def _failure_filler(self) -> dict[str, Any]:
        """Finite, ``out_structure``-shaped data for an observation that could not be read.

        The values are finite and non-degenerate (identity quaternions, a strictly increasing time
        vector, a broadband signal, unit white-noise fits) so every operator built from this
        observation stays finite. The observation is gated out (all-False masker), so these values
        never enter the maps; finiteness only matters so the gated contribution is ``0`` and not
        ``NaN``.
        """
        rng = np.random.default_rng(0)

        def identity_quaternions(s: jax.ShapeDtypeStruct) -> np.ndarray:
            quats = np.zeros(s.shape, s.dtype)
            quats[..., 0] = 1.0  # (1, 0, 0, 0)
            return quats

        def unit_noise_fit(s: jax.ShapeDtypeStruct) -> np.ndarray:
            fit = np.zeros(s.shape, s.dtype)  # columns: white_noise, alpha, fknee, f0
            fit[..., 0] = 1.0  # unit white noise so the inverse noise covariance stays finite
            fit[..., 2] = 1.0
            fit[..., 3] = 0.1
            return fit

        def zeros(s: jax.ShapeDtypeStruct) -> np.ndarray:
            return np.zeros(s.shape, s.dtype)

        def fill(field: str, struct: PyTree[jax.ShapeDtypeStruct]) -> Any:
            if field in (ReaderField.TIMESTAMPS, ReaderField.AZIMUTH, ReaderField.ELEVATION):
                # strictly increasing: non-zero range so Legendre/bin normalisation of azimuth (and
                # the time axis) stays finite when template operators are built from this filler
                return np.arange(struct.shape[0], dtype=struct.dtype)
            if field in (ReaderField.DETECTOR_QUATERNIONS, ReaderField.BORESIGHT_QUATERNIONS):
                return identity_quaternions(struct)
            if field == ReaderField.SAMPLE_DATA:
                # broadband so a fitted noise model has non-zero white-noise level
                return jax.tree.map(lambda s: rng.standard_normal(s.shape).astype(s.dtype), struct)
            if field == ReaderField.NOISE_MODEL_FITS:
                return jax.tree.map(unit_noise_fit, struct)
            # metadata, masks (all-False), hwp angles: plain zeros per leaf
            return jax.tree.map(zeros, struct)

        return {field: fill(field, struct) for field, struct in self.out_structure.items()}

    def _read_structure_impure(
        self, observation: AbstractLazyObservation[T], data_field_names: Collection[str]
    ) -> PyTree[jax.ShapeDtypeStruct]:
        return self._get_data_field_structures_for(
            observation.probe_shape(intervals=ReaderField.SCANNING_INTERVALS in data_field_names),
            data_field_names,
        )

    def _read_data_impure(
        self, observation: AbstractLazyObservation[T], data_field_names: Collection[str]
    ) -> PyTree[Array]:
        data = observation.get_data(data_field_names)
        field_reader = self._get_data_field_readers()
        return {field: field_reader[field](data) for field in data_field_names}

demodulated = demodulated instance-attribute

stokes = stokes instance-attribute

dtype = dtype instance-attribute

__init__(*args, demodulated, stokes, dtype=jnp.float64, common_keywords=None, shapes=None, known_failures=None, **keywords)

Source code in src/furax/mapmaking/_reader.py
def __init__(
    self,
    *args: Sequence[Any],
    demodulated: bool,
    stokes: ValidStokesType,
    dtype: DTypeLike = jnp.float64,
    common_keywords: dict[str, Any] | None = None,
    shapes: list[tuple[int, ...]] | None = None,
    known_failures: Sequence[int] | None = None,
    **keywords: Sequence[Any],
) -> None:
    # Set before super().__init__ so the structure/reader builders can use them
    self.demodulated = demodulated
    self.stokes = stokes
    self.dtype = dtype
    # Distributed mode passes pre-gathered (n_detectors, n_samples) shapes; turn them into
    # structures here, now that self carries demodulated/stokes/dtype. Otherwise leave them
    # unset and super() opens every observation via _read_structure_impure.
    structures = None
    if shapes is not None:
        fields = (common_keywords or {})['data_field_names']
        structures = [self._get_data_field_structures_for(shape, fields) for shape in shapes]
    super().__init__(
        *args,
        common_keywords=common_keywords,
        structures=structures,
        known_failures=known_failures,
        **keywords,
    )

from_observations(observations, *, read_indices=None, requested_fields=None, demodulated=False, stokes='IQU', dtype=jnp.float64) classmethod

Create a reader, performing I/O to infer data structures.

Parameters:

  • observations (Sequence[AbstractLazyObservation[T]]) –

    Full list of lazy observations.

  • read_indices (Sequence[int] | None, default: None ) –

    Optional indices into observations; when set, only those are opened on this process to infer shapes, and shapes are synchronised across processes (distributed-mode shortcut).

  • requested_fields (Collection[str] | None, default: None ) –

    Optional list of fields to load. If None, read all non-optional fields.

  • demodulated (bool, default: False ) –

    Whether to read demodulated TODs.

  • stokes (ValidStokesType, default: 'IQU' ) –

    Stokes components to read when demodulated.

  • dtype (DTypeLike, default: float64 ) –

    Floating-point dtype applied to every floating-point field the reader returns: sample data, noise model fits and the geometry (timestamps, HWP angles, quaternions). Use jnp.float32 to run a float32 mapmaking pipeline (MapMakingConfig.double_precision=False). Casting the geometry is also required there: under jax_enable_x64=False a float64 array is illegal, so no field may stay float64. Timestamps are rebased to a per-observation zero origin (in float64, before the downcast) so the float32 cast does not collapse the absolute POSIX epoch onto a single value; see the timestamps reader in _get_data_field_readers.

Source code in src/furax/mapmaking/_reader.py
@classmethod
def from_observations(
    cls,
    observations: Sequence[AbstractLazyObservation[T]],
    *,
    read_indices: Sequence[int] | None = None,
    requested_fields: Collection[str] | None = None,
    demodulated: bool = False,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = jnp.float64,
) -> Self:
    """Create a reader, performing I/O to infer data structures.

    Args:
        observations: Full list of lazy observations.
        read_indices: Optional indices into ``observations``; when set, only
            those are opened on this process to infer shapes, and shapes are
            synchronised across processes (distributed-mode shortcut).
        requested_fields: Optional list of fields to load. If None, read all non-optional fields.
        demodulated: Whether to read demodulated TODs.
        stokes: Stokes components to read when demodulated.
        dtype: Floating-point dtype applied to every floating-point field the reader
            returns: sample data, noise model fits and the geometry (timestamps, HWP
            angles, quaternions). Use jnp.float32 to run a float32 mapmaking pipeline
            (MapMakingConfig.double_precision=False). Casting the geometry is also
            required there: under jax_enable_x64=False a float64 array is illegal, so
            no field may stay float64. Timestamps are rebased to a per-observation
            zero origin (in float64, before the downcast) so the float32 cast does not
            collapse the absolute POSIX epoch onto a single value; see the timestamps
            reader in ``_get_data_field_readers``.
    """
    fields = cls._resolve_fields(observations, requested_fields)
    # In the default path, leave ``shapes`` unset so AbstractReader.__init__ opens every
    # observation on this process to infer its structure. In distributed mode, gather the
    # per-observation shapes from the local subset and all-gather them (see ``_gather_shapes``).
    shapes = None
    known_failures = None
    if read_indices is not None:
        shapes, known_failures = cls._gather_shapes(observations, read_indices, fields)
    return cls(
        observations,
        common_keywords={'data_field_names': fields},
        demodulated=demodulated,
        stokes=stokes,
        dtype=dtype,
        shapes=shapes,
        known_failures=known_failures,
    )

furax.mapmaking.MapMakingConfig dataclass

Methods:

  • for_method

    Return a default MapMakingConfig pre-configured for the given method.

  • full_defaults

    Create a config with default values for all fields including optional ones.

  • load_yaml

    Load and instantiate a MapMakingConfig from a YAML file.

  • load_dict

    Load and instantiate a MapMakingConfig from a dictionary.

  • dump_yaml

    Dump the config to a YAML file.

  • __init__

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class MapMakingConfig:
    method: Methods = Methods.BINNED
    scanning_mask: bool = False
    sample_mask: bool = False
    hits_cut: float = 1e-2
    cond_cut: float = 1e-2
    double_precision: bool = True
    pointing: PointingConfig = field(default_factory=PointingConfig)
    weighting: WeightingConfig = field(default_factory=WeightingConfig)
    debug: bool = True
    solver: SolverConfig = field(default_factory=SolverConfig)
    gaps: GapsConfig = field(default_factory=GapsConfig)
    landscape: LandscapeConfig = field(
        default_factory=lambda: LandscapeConfig(healpix=HealpixConfig())
    )
    templates: TemplatesConfig | None = None
    atop_tau: int = 0
    sotodlib: SotodlibConfig | None = None

    @classmethod
    def for_method(cls, method: 'Methods | str') -> 'MapMakingConfig':
        """Return a default MapMakingConfig pre-configured for the given method.

        Args:
            method: A ``Methods`` enum value or its string name (e.g. ``'binned'``,
                ``'ml'``, ``'twostep'``, ``'atop'``), case-insensitive.
        """
        if isinstance(method, str):
            upper = method.upper()
            try:
                method = Methods[upper]
            except KeyError:
                # Fall back to matching by value (e.g. 'ML' → Methods.MAXL)
                matched = next((m for m in Methods if m.value.upper() == upper), None)
                if matched is None:
                    raise ValueError(f'Unknown method: {method!r}') from None
                method = matched

        if method == Methods.BINNED:
            return cls(
                method=Methods.BINNED,
                weighting=WeightingConfig(),
                templates=None,
            )
        elif method == Methods.MAXL:
            return cls(
                method=Methods.MAXL,
                weighting=WeightingConfig(
                    mode=WeightingMode.TOEPLITZ,
                    correlation_length=1_000,
                ),
                solver=SolverConfig(
                    rtol=1e-6,
                    atol=0,
                    max_steps=1_000,
                ),
                templates=None,
            )
        elif method == Methods.TWOSTEP:
            return cls(
                method=Methods.TWOSTEP,
                weighting=WeightingConfig(),
                solver=SolverConfig(
                    rtol=1e-6,
                    atol=0,
                    max_steps=1_000,
                ),
                templates=TemplatesConfig(
                    polynomial=PolynomialConfig(),
                ),
            )
        elif method == Methods.ATOP:
            return cls(
                method=Methods.ATOP,
                weighting=WeightingConfig(),
                solver=SolverConfig(
                    rtol=1e-6,
                    atol=0,
                    max_steps=100,
                ),
                atop_tau=37,
                templates=None,
            )
        else:
            raise ValueError(f'Unknown method: {method}')

    @classmethod
    def full_defaults(cls) -> 'MapMakingConfig':
        """Create a config with default values for all fields including optional ones."""
        return cls(templates=TemplatesConfig.full_defaults())

    @classmethod
    def load_yaml(cls, path: str | Path) -> 'MapMakingConfig':
        """Load and instantiate a ``MapMakingConfig`` from a YAML file."""
        data = yaml.safe_load(Path(path).read_text())
        return cls.load_dict(data)

    @classmethod
    def load_dict(cls, data: dict[str, Any]) -> 'MapMakingConfig':
        """Load and instantiate a ``MapMakingConfig`` from a dictionary."""
        return deserialize(MapMakingConfig, data)

    def dump_yaml(self, path: str | Path) -> None:
        """Dump the config to a YAML file.

        The '.yaml' suffix is automatically added if not already present.
        """
        filename = Path(path).with_suffix('.yaml')
        filename.parent.mkdir(parents=True, exist_ok=True)
        filename.write_text(self._to_yaml())

    def _to_yaml(self) -> str:
        """Serialize the config to a YAML string."""
        data = serialize(MapMakingConfig, self)
        return yaml.dump(data, indent=2)

    @property
    def binned(self) -> bool:
        return self.weighting.diagonal_matrix

    @property
    def demodulated(self) -> bool:
        return self.sotodlib.demodulated if self.sotodlib is not None else False

    @property
    def use_templates(self) -> bool:
        return (self.templates is not None) and (not self.templates.empty)

    @property
    def dtype(self) -> DTypeLike:
        return jnp.float64 if self.double_precision else jnp.float32  # type: ignore[no-any-return]

method = Methods.BINNED class-attribute instance-attribute

scanning_mask = False class-attribute instance-attribute

sample_mask = False class-attribute instance-attribute

hits_cut = 0.01 class-attribute instance-attribute

cond_cut = 0.01 class-attribute instance-attribute

double_precision = True class-attribute instance-attribute

pointing = field(default_factory=PointingConfig) class-attribute instance-attribute

weighting = field(default_factory=WeightingConfig) class-attribute instance-attribute

debug = True class-attribute instance-attribute

solver = field(default_factory=SolverConfig) class-attribute instance-attribute

gaps = field(default_factory=GapsConfig) class-attribute instance-attribute

landscape = field(default_factory=(lambda: LandscapeConfig(healpix=(HealpixConfig())))) class-attribute instance-attribute

templates = None class-attribute instance-attribute

atop_tau = 0 class-attribute instance-attribute

sotodlib = None class-attribute instance-attribute

binned property

demodulated property

use_templates property

dtype property

for_method(method) classmethod

Return a default MapMakingConfig pre-configured for the given method.

Parameters:

  • method (Methods | str) –

    A Methods enum value or its string name (e.g. 'binned', 'ml', 'twostep', 'atop'), case-insensitive.

Source code in src/furax/mapmaking/config.py
@classmethod
def for_method(cls, method: 'Methods | str') -> 'MapMakingConfig':
    """Return a default MapMakingConfig pre-configured for the given method.

    Args:
        method: A ``Methods`` enum value or its string name (e.g. ``'binned'``,
            ``'ml'``, ``'twostep'``, ``'atop'``), case-insensitive.
    """
    if isinstance(method, str):
        upper = method.upper()
        try:
            method = Methods[upper]
        except KeyError:
            # Fall back to matching by value (e.g. 'ML' → Methods.MAXL)
            matched = next((m for m in Methods if m.value.upper() == upper), None)
            if matched is None:
                raise ValueError(f'Unknown method: {method!r}') from None
            method = matched

    if method == Methods.BINNED:
        return cls(
            method=Methods.BINNED,
            weighting=WeightingConfig(),
            templates=None,
        )
    elif method == Methods.MAXL:
        return cls(
            method=Methods.MAXL,
            weighting=WeightingConfig(
                mode=WeightingMode.TOEPLITZ,
                correlation_length=1_000,
            ),
            solver=SolverConfig(
                rtol=1e-6,
                atol=0,
                max_steps=1_000,
            ),
            templates=None,
        )
    elif method == Methods.TWOSTEP:
        return cls(
            method=Methods.TWOSTEP,
            weighting=WeightingConfig(),
            solver=SolverConfig(
                rtol=1e-6,
                atol=0,
                max_steps=1_000,
            ),
            templates=TemplatesConfig(
                polynomial=PolynomialConfig(),
            ),
        )
    elif method == Methods.ATOP:
        return cls(
            method=Methods.ATOP,
            weighting=WeightingConfig(),
            solver=SolverConfig(
                rtol=1e-6,
                atol=0,
                max_steps=100,
            ),
            atop_tau=37,
            templates=None,
        )
    else:
        raise ValueError(f'Unknown method: {method}')

full_defaults() classmethod

Create a config with default values for all fields including optional ones.

Source code in src/furax/mapmaking/config.py
@classmethod
def full_defaults(cls) -> 'MapMakingConfig':
    """Create a config with default values for all fields including optional ones."""
    return cls(templates=TemplatesConfig.full_defaults())

load_yaml(path) classmethod

Load and instantiate a MapMakingConfig from a YAML file.

Source code in src/furax/mapmaking/config.py
@classmethod
def load_yaml(cls, path: str | Path) -> 'MapMakingConfig':
    """Load and instantiate a ``MapMakingConfig`` from a YAML file."""
    data = yaml.safe_load(Path(path).read_text())
    return cls.load_dict(data)

load_dict(data) classmethod

Load and instantiate a MapMakingConfig from a dictionary.

Source code in src/furax/mapmaking/config.py
@classmethod
def load_dict(cls, data: dict[str, Any]) -> 'MapMakingConfig':
    """Load and instantiate a ``MapMakingConfig`` from a dictionary."""
    return deserialize(MapMakingConfig, data)

dump_yaml(path)

Dump the config to a YAML file.

The '.yaml' suffix is automatically added if not already present.

Source code in src/furax/mapmaking/config.py
def dump_yaml(self, path: str | Path) -> None:
    """Dump the config to a YAML file.

    The '.yaml' suffix is automatically added if not already present.
    """
    filename = Path(path).with_suffix('.yaml')
    filename.parent.mkdir(parents=True, exist_ok=True)
    filename.write_text(self._to_yaml())

__init__(method=Methods.BINNED, scanning_mask=False, sample_mask=False, hits_cut=0.01, cond_cut=0.01, double_precision=True, pointing=PointingConfig(), weighting=WeightingConfig(), debug=True, solver=SolverConfig(), gaps=GapsConfig(), landscape=(lambda: LandscapeConfig(healpix=(HealpixConfig())))(), templates=None, atop_tau=0, sotodlib=None)

furax.mapmaking.MultiObservationMapMaker

Bases: Generic[T]

Class for mapping multiple observations together.

Methods:

Attributes:

Source code in src/furax/mapmaking/mapmaker.py
 85
 86
 87
 88
 89
 90
 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
class MultiObservationMapMaker(Generic[T]):
    """Class for mapping multiple observations together."""

    def __init__(
        self,
        observations: Sequence[AbstractLazyObservation[T]],
        config: MapMakingConfig | None = None,
        logger: Logger | None = None,
    ) -> None:
        self.observations = observations
        self.config = config or MapMakingConfig()  # use defaults if not provided
        self.logger = logger or furax_logger
        self._check_config()
        self.landscape = (
            _static_landscape(self.config.landscape, self.config.dtype)
            or self._scan_wcs_footprint()
        )
        # Build the reader once, up front: it only probes shapes (no mesh context needed)
        rhs_fields = {ReaderField.METADATA, ReaderField.SAMPLE_DATA}
        model_fields = ObservationModel.required_reader_fields(self.config)
        self.reader = self.get_reader(model_fields | rhs_fields)

    def _check_config(self) -> None:
        """Validate and adjust config for method-specific compatibility."""
        if self.config.method == Methods.ATOP:
            if not self.config.binned:
                raise ValueError('ATOP requires diagonal weighting (weighting.mode=DIAGONAL).')
            if 'I' in (stokes := self.config.landscape.stokes):
                if stokes != 'IQU':
                    raise ValueError(
                        f'ATOP does not support intensity map reconstruction and {stokes=!r}'
                        " cannot be reduced to a supported type. Use stokes='QU' instead."
                    )
                self.logger.info(
                    "Received stokes='IQU', but ATOP does not support intensity map reconstruction."
                    " Falling back to stokes='QU' instead."
                )
                self.config.landscape.stokes = 'QU'

    @cached_property
    def mesh(self) -> Mesh:
        return jax.make_mesh((jax.device_count(),), ('obs',))

    @property
    def sharding(self) -> NamedSharding:
        return NamedSharding(self.mesh, P('obs'))

    def distribute(self, x: PyTree) -> PyTree:
        """Shard a pytree of process-local arrays along the leading 'obs' axis."""
        return jax.tree.map(lambda a: jax.make_array_from_process_local_data(self.sharding, a), x)

    @property
    def n_observations(self) -> int:
        """Total number of observations across all processes."""
        return len(self.observations)

    @property
    def obs_distribution(self) -> tuple[int, int, int]:
        """``(start, n_owned, n_pad)`` for this process."""
        return get_obs_distribution_to_process(self.n_observations)

    def get_padded_read_indices(self) -> np.ndarray:
        start, n_owned, n_pad = self.obs_distribution
        indices = np.arange(start, start + n_owned)
        return np.pad(indices, (0, n_pad), mode='edge')

    def get_reader(self, required_fields: Collection[str]) -> ObservationReader[T]:
        """Build an ObservationReader for this process's local observations."""
        # Pass padded indices: process_allgather inside from_observations needs every
        # rank to send the same shape, so all ranks must report the same obs count.
        return ObservationReader.from_observations(
            self.observations,
            read_indices=tuple(self.get_padded_read_indices()),
            requested_fields=required_fields,
            demodulated=self.config.demodulated,
            stokes=self.config.landscape.stokes,
            dtype=self.config.dtype,
        )

    def run(self, out_dir: str | Path | None = None) -> MapMakingResults:
        """Runs the mapmaker and return results after saving them to the given directory."""
        results = self.make_maps()

        # Save outputs on process 0 only (all processes hold the same replicated result)
        if out_dir is not None and jax.process_index() == 0:
            out_dir = Path(out_dir).resolve()
            results.save(out_dir)
            self.logger.info(f'saved results to {out_dir}')
            self.config.dump_yaml(out_dir / 'mapmaking_config.yaml')
            self.logger.info('saved mapmaking configuration to file')

        # Barrier so other ranks don't race ahead while rank 0 is still writing.
        mhu.sync_global_devices('mapmaker.run.save_done')

        return results

    def make_maps(self) -> MapMakingResults:
        """Computes the mapmaker results (maps and other products)."""
        logger_info = lambda msg: self.logger.info(f'MultiObsMapMaker: {msg}')

        n_processes = jax.process_count()
        rank = jax.process_index()
        n_local_devices = jax.local_device_count()
        n_devices = jax.device_count()

        # Information about how observations are distributed among processes
        start, n_owned, n_pad = self.obs_distribution
        n_per_proc = n_owned + n_pad
        n_per_dev = n_per_proc // n_local_devices
        n_slots_global = n_per_proc * n_processes

        # Every slot (real or padding) is padded to the same reader.out_structure
        per_slot_bytes = furax.tree.nbytes(self.reader.out_structure)
        global_bytes = per_slot_bytes * n_slots_global

        # The true, total data size (before observations are padded to a common structure)
        real_bytes = self.reader.total_nbytes

        slot_overhead = (n_slots_global - self.n_observations) / self.n_observations
        byte_overhead = (global_bytes - real_bytes) / real_bytes
        logger_info(
            f'layout procs={n_processes} dev_per_proc={n_local_devices} dev_total={n_devices}'
        )
        logger_info(
            f'dataset obs={self.n_observations} slots={n_slots_global} slot_overhead=+{slot_overhead:.1%} '
            f'slots_per_proc={n_per_proc} slots_per_dev={n_per_dev} slot_size={_format_bytes(per_slot_bytes)}'
        )
        logger_info(
            f'dataset real={_format_bytes(real_bytes)} global={_format_bytes(global_bytes)} '
            f'byte_overhead=+{byte_overhead:.1%}'
        )

        rank_pad = n_pad / n_per_proc
        logger_info(
            f'rank={rank} obs={start}:{start + n_owned} real={n_owned} pad={n_pad} '
            f'pad_pct={rank_pad:.1%} real_size={_format_bytes(per_slot_bytes * n_owned)} '
            f'pad_size={_format_bytes(per_slot_bytes * n_pad)}'
        )

        with jax.set_mesh(self.mesh):
            # Single read pass (sharded over observations): build the model and accumulate the
            # hit map + RHS together, so each observation is read/preprocessed exactly once. The
            # cross-process reduction of hits/rhs happens via psum inside the kernel.
            model, hits, rhs = self.build_model_and_accumulate()
            jax.block_until_ready((hits, rhs))
            logger_info('Accumulated hit map and RHS vector')

            failed_observations = self._collect_failed_observations()
            if failed_observations:
                logger_info(f'{len(failed_observations)} observation(s) failed and were excluded')

            # System operator (full/diagonal)
            A = self.get_system_operator(model)
            diag_A = A if self.config.binned else self.get_system_operator(model, diag=True)
            BJ = BJPreconditioner.create(diag_A)
            icov = BJ.blocks.block_until_ready()
            logger_info('Computed white noise inverse covariance')

            valid_pixels = self.pixel_selection(hits, icov)
            # Select valid pixels on the (trailing) sky axes, leaving the leading Stokes axis intact.
            selector = IndexOperator((..., *jnp.where(valid_pixels)), in_structure=A.out_structure)
            n_selected = jnp.sum(valid_pixels)
            n_observed = jnp.sum(hits > 0)
            n_total = valid_pixels.size
            logger_info(f'Selected {n_selected} pixels ({n_observed} seen, {n_total} total)')

            hits = hits.at[~valid_pixels].set(0)  # excluded pixels have zero hits
            icov = jnp.moveaxis(icov, [-2, -1], [0, 1])  # (*pixels, ns, ns) → (ns, ns, *pixels)

            # Solve the mapmaking system
            solver = lineax.CG(**asdict(self.config.solver))
            spd = OperatorTag.POSITIVE_SEMIDEFINITE
            lx_system = as_lineax_operator(selector @ A @ selector.T, spd)
            M = (selector @ BJ.I @ selector.T).reduce()  # preconditioner
            lx_precond = as_lineax_operator(M, spd)
            rhs_reduced = selector(rhs)
            y0 = M(rhs_reduced)

            solution = lineax.linear_solve(
                lx_system,
                rhs_reduced,
                solver=solver,
                options={'preconditioner': lx_precond, 'y0': y0},
                throw=False,
            )
            estimate = selector.T(solution.value)
            num_steps = solution.stats['num_steps']
            logger_info(f'Finished mapmaking (iteration steps: {num_steps})')

        return MapMakingResults(
            map=estimate,
            icov=icov,
            hit_map=hits,
            solver_stats=solution.stats,
            landscape=self.landscape,
            failed_observations=failed_observations,
        )

    def _collect_failed_observations(self) -> list[str]:
        """Names of observations that failed to load, gathered across all processes.

        Each process records the local indices it could not read (``reader.failed_indices``); a
        boolean mask over all observations is all-gathered and OR-reduced so every process reports
        the same global set.
        """
        local = np.zeros(self.n_observations, dtype=bool)
        local[sorted(self.reader.failed_indices)] = True
        gathered = np.asarray(mhu.process_allgather(local)).reshape(-1, self.n_observations)
        failed = np.flatnonzero(gathered.any(axis=0))
        return [self.observations[int(i)].name for i in failed]

    def build_model_and_accumulate(
        self,
    ) -> tuple[ObservationModel, Int64[Array, '...'], StokesType]:
        """Build the model and accumulate the hit map and RHS in a single, sharded read pass.

        Sharded over observations: each observation is read (and, for preproc-backed observations,
        preprocessed) exactly once, and contributes to the model, the hit map and the RHS from that
        single read. This avoids reading every observation twice (once to build the model, once to
        accumulate the RHS). The hit map and RHS are reduced across all processes with ``psum``
        inside the kernel.

        Observations that contribute nothing -- padding (added so every device carries the same
        workload) and failed loads (the preprocessing pipeline raised) -- are gated out by zeroing
        their masker inside the kernel, so they drop out of the hit map, the RHS, and the CG system
        operator alike. Failed loads are reported afterwards from ``self.reader.failed_indices``
        (see ``make_maps``).

        Must run under ``jax.set_mesh(self.mesh)``.

        Returns ``(model, hits, rhs)`` with the model sharded along the observation axis and the
        hit map / RHS replicated (already reduced across processes).
        """
        config = self.config
        landscape = self.landscape
        reader = self.reader
        reader.reset_failures()  # fresh pass: drop failures recorded by any previous read
        fill_gaps = config.gaps.treatment == GapTreatment.FILL and not config.binned

        indices = self.distribute(self.get_padded_read_indices())
        is_real = self.distribute(self._real_observation_mask())
        axis = jax.sharding.get_abstract_mesh().axis_names[0]

        @jax.shard_map(out_specs=(P('obs'), P(), P()), check_vma=False)
        def kernel(indices, is_real):  # type: ignore[no-untyped-def]
            def step(carry, args):  # type: ignore[no-untyped-def]
                hits_acc, rhs_acc = carry
                i, real = args

                # Skip the load for padding slots: only the real branch hits the io_callback,
                # so a padded observation is never read or preprocessed just to be masked away.
                # (lax.cond evaluates a single branch at runtime)
                data, padding, valid = jax.lax.cond(
                    real,
                    lambda: reader.read(i),
                    lambda: reader.read_filler(),
                )
                obs = ObservationModel.create(data, padding, config, landscape)

                # Padding/failed observations contribute nothing
                obs.M = obs.M.restrict(real & valid)

                # Hit map contribution.
                assert isinstance(obs.H, CompositionOperator)  # mypy
                pointing = obs.H.operands[-1]
                assert isinstance(pointing, PointingOperator)  # mypy
                pointing_i = pointing.as_stokes_i(interpolate=False)
                ones = furax.tree.ones_like(obs.M.in_structure)
                masked_tod = obs.M(ones)
                # The sample mask is per-detector (identical across Stokes legs); take one leg so
                # StokesI wraps a (ndet, nsamp) array rather than a whole demodulated Stokes backing.
                masked = masked_tod.data[0] if isinstance(masked_tod, Stokes) else masked_tod
                hits_i = jnp.int64(pointing_i.T(StokesI(masked)).i)

                # RHS contribution (optionally gap-filled).
                def func_gapfill(tod):  # type: ignore[no-untyped-def]
                    # Only reached under GapTreatment.FILL, where W is the plain inner-mask weight.
                    assert isinstance(obs.W, WeightOperator)
                    # Optional M_b N M_b preconditioner (covariance from the noise model).
                    preconditioner = None
                    if config.gaps.fill_options.precondition:
                        cov = obs.noise_operator(config.weighting.correlation_length, inverse=False)
                        m_bad = obs.M.complement()
                        preconditioner = (m_bad @ cov @ m_bad).reduce()
                    return gap_fill(
                        jax.random.key(config.gaps.fill_options.seed),
                        tod,
                        obs.W.weight,
                        obs.M,
                        rate=obs.sample_rate,
                        max_cg_steps=config.gaps.fill_options.max_steps,
                        rtol=config.gaps.fill_options.rtol,
                        preconditioner=preconditioner,
                        metadata=data[ReaderField.METADATA],
                    )

                # Use Python `if` for static `fill_gaps`, so gap-filling branch is not traced
                tod = data[ReaderField.SAMPLE_DATA]
                if fill_gaps:
                    tod = jax.lax.cond(
                        real & valid,
                        func_gapfill,
                        lambda _: _,  # return raw data as-is
                        tod,
                    )
                    # Gaps filled: skip the data-side mask so the fill survives N⁻¹.
                    rhs_i = obs.rhs_operator_prefilled(tod)
                else:
                    rhs_i = obs.rhs_operator(tod)
                return (hits_acc + hits_i, furax.tree.add(rhs_acc, rhs_i)), obs

            init_hits = jax.lax.pcast(jnp.zeros(landscape.shape, jnp.int64), axis, to='varying')
            init_rhs = jax.lax.pcast(landscape.zeros(), axis, to='varying')
            (hits, rhs), model = jax.lax.scan(step, (init_hits, init_rhs), (indices, is_real))
            return model, jax.lax.psum(hits, axis), jax.lax.psum(rhs, axis)

        model, hits, rhs = kernel(indices, is_real)
        return model, hits, rhs

    def _real_observation_mask(self) -> np.ndarray:
        """Boolean flag per padded slot: True for real observations, False for padding."""
        _, n_owned, n_pad = self.obs_distribution
        return np.concatenate([np.ones(n_owned, dtype=bool), np.zeros(n_pad, dtype=bool)])

    def get_system_operator(
        self, model: ObservationModel, *, diag: bool = False
    ) -> AbstractLinearOperator:
        H = ScanBlockColumnOperator.create(model.H)
        # filter_vmap: array leaves mapped, static fields held
        weight = eqx.filter_vmap(ObservationModel.diag_W)(model) if diag else model.W
        W = ScanBlockDiagonalOperator.create(weight)
        # specify leading axis dimension because F can be trivial
        _, n_own, n_pad = self.obs_distribution
        F = ScanBlockDiagonalOperator.create(model.F, n_lead=n_own + n_pad)
        return (H.T @ W @ F @ H).reduce()

    def pixel_selection(
        self, hits: Integer[Array, ' pixels'], weights: Float[Array, 'pixels stokes stokes']
    ) -> Bool[Array, ' pixels']:
        """Compute pixel selection according to hit and condition number cuts."""
        # Cut pixels with low number of samples
        hits_quantile = jnp.quantile(hits[hits > 0], q=0.95)
        valid = hits > self.config.hits_cut * hits_quantile

        if self.config.cond_cut > 0:
            eigs = furax.linalg.eigvalsh(weights)
            valid = jnp.logical_and(
                valid,
                eigs[..., 0] > self.config.cond_cut * eigs[..., -1],
            )

        return valid

    def _scan_wcs_footprint(self) -> WCSLandscape:
        """Scan observations to determine the combined WCS footprint and build a WCSLandscape.

        Performs a preliminary pass over all observations, loading the pointing data needed
        to compute each observation's sky coverage, then combines them into a unified footprint.

        Restricted to single-process runs over file-backed observations: the scan is an
        unsharded loop calling ``get_data`` per observation. For file-backed observations that
        cheaply loads only the requested pointing fields, but for sources that cannot subset
        their fields (e.g. preproc-backed observations) it would run the full load/preprocess
        pipeline on every observation, on every process. Pre-compute the footprint and pass an
        explicit WCS landscape instead in those cases.
        """
        if jax.process_count() > 1:
            msg = (
                'Automatic WCS footprint scanning is only supported on a single process. '
                'Pre-compute the footprint and pass an explicit WCS landscape instead.'
            )
            raise RuntimeError(msg)
        if any(not isinstance(obs, FileBackedLazyObservation) for obs in self.observations):
            msg = (
                'Automatic WCS footprint scanning requires file-backed observations '
                '(get_data must cheaply subset pointing fields). Pre-compute the footprint'
                'and pass an explicit WCS landscape instead.'
            )
            raise RuntimeError(msg)
        lc = self.config.landscape
        if lc.wcs is None:
            raise ValueError('WCS landscape config is required for auto footprint scanning.')
        wcs_config = lc.wcs
        res = wcs_config.resolution * pixell.utils.arcmin
        proj = wcs_config.projection.name.lower()
        pointing_fields = [ReaderField.BORESIGHT_QUATERNIONS, ReaderField.DETECTOR_QUATERNIONS]

        n = len(self.observations)
        corners_rad = np.empty((2, 2, n))  # [bottom-left, top-right] corners per observation
        for i, lazy_obs in enumerate(self.observations):
            obs = lazy_obs.get_data(pointing_fields)
            shape, wcs = obs.get_wcs_shape_and_kernel(
                resolution_arcmin=wcs_config.resolution, projection=wcs_config.projection
            )
            # RA _decreases_ left-to-right (increases toward the East)
            # so each corner pair is technically [[dec_lo, ra_hi], [dec_hi, ra_lo]]
            corners_rad[..., i] = pixell.enmap.corners(shape, wcs, corner=True)

        # find the corners of the smallest box covering all the individual patches
        dec_lo = corners_rad[0, 0].min()
        dec_hi = corners_rad[1, 0].max()
        # minimum_enclosing_arc expects [lo, hi] intervals with shape (2, n)
        ra_lo, ra_hi = minimum_enclosing_arc(corners_rad[::-1, 1].T)
        union_box = np.array([[dec_lo, ra_hi], [dec_hi, ra_lo]])

        # create the final shape and WCS objects for this covering box
        shape, wcs = pixell.enmap.geometry(pos=union_box, res=res, proj=proj)
        return WCSLandscape.from_wcs(shape, wcs, lc.stokes, self.config.dtype)

observations = observations instance-attribute

config = config or MapMakingConfig() instance-attribute

logger = logger or furax_logger instance-attribute

landscape = _static_landscape(self.config.landscape, self.config.dtype) or self._scan_wcs_footprint() instance-attribute

reader = self.get_reader(model_fields | rhs_fields) instance-attribute

mesh cached property

sharding property

n_observations property

Total number of observations across all processes.

obs_distribution property

(start, n_owned, n_pad) for this process.

__init__(observations, config=None, logger=None)

Source code in src/furax/mapmaking/mapmaker.py
def __init__(
    self,
    observations: Sequence[AbstractLazyObservation[T]],
    config: MapMakingConfig | None = None,
    logger: Logger | None = None,
) -> None:
    self.observations = observations
    self.config = config or MapMakingConfig()  # use defaults if not provided
    self.logger = logger or furax_logger
    self._check_config()
    self.landscape = (
        _static_landscape(self.config.landscape, self.config.dtype)
        or self._scan_wcs_footprint()
    )
    # Build the reader once, up front: it only probes shapes (no mesh context needed)
    rhs_fields = {ReaderField.METADATA, ReaderField.SAMPLE_DATA}
    model_fields = ObservationModel.required_reader_fields(self.config)
    self.reader = self.get_reader(model_fields | rhs_fields)

distribute(x)

Shard a pytree of process-local arrays along the leading 'obs' axis.

Source code in src/furax/mapmaking/mapmaker.py
def distribute(self, x: PyTree) -> PyTree:
    """Shard a pytree of process-local arrays along the leading 'obs' axis."""
    return jax.tree.map(lambda a: jax.make_array_from_process_local_data(self.sharding, a), x)

get_padded_read_indices()

Source code in src/furax/mapmaking/mapmaker.py
def get_padded_read_indices(self) -> np.ndarray:
    start, n_owned, n_pad = self.obs_distribution
    indices = np.arange(start, start + n_owned)
    return np.pad(indices, (0, n_pad), mode='edge')

get_reader(required_fields)

Build an ObservationReader for this process's local observations.

Source code in src/furax/mapmaking/mapmaker.py
def get_reader(self, required_fields: Collection[str]) -> ObservationReader[T]:
    """Build an ObservationReader for this process's local observations."""
    # Pass padded indices: process_allgather inside from_observations needs every
    # rank to send the same shape, so all ranks must report the same obs count.
    return ObservationReader.from_observations(
        self.observations,
        read_indices=tuple(self.get_padded_read_indices()),
        requested_fields=required_fields,
        demodulated=self.config.demodulated,
        stokes=self.config.landscape.stokes,
        dtype=self.config.dtype,
    )

run(out_dir=None)

Runs the mapmaker and return results after saving them to the given directory.

Source code in src/furax/mapmaking/mapmaker.py
def run(self, out_dir: str | Path | None = None) -> MapMakingResults:
    """Runs the mapmaker and return results after saving them to the given directory."""
    results = self.make_maps()

    # Save outputs on process 0 only (all processes hold the same replicated result)
    if out_dir is not None and jax.process_index() == 0:
        out_dir = Path(out_dir).resolve()
        results.save(out_dir)
        self.logger.info(f'saved results to {out_dir}')
        self.config.dump_yaml(out_dir / 'mapmaking_config.yaml')
        self.logger.info('saved mapmaking configuration to file')

    # Barrier so other ranks don't race ahead while rank 0 is still writing.
    mhu.sync_global_devices('mapmaker.run.save_done')

    return results

make_maps()

Computes the mapmaker results (maps and other products).

Source code in src/furax/mapmaking/mapmaker.py
def make_maps(self) -> MapMakingResults:
    """Computes the mapmaker results (maps and other products)."""
    logger_info = lambda msg: self.logger.info(f'MultiObsMapMaker: {msg}')

    n_processes = jax.process_count()
    rank = jax.process_index()
    n_local_devices = jax.local_device_count()
    n_devices = jax.device_count()

    # Information about how observations are distributed among processes
    start, n_owned, n_pad = self.obs_distribution
    n_per_proc = n_owned + n_pad
    n_per_dev = n_per_proc // n_local_devices
    n_slots_global = n_per_proc * n_processes

    # Every slot (real or padding) is padded to the same reader.out_structure
    per_slot_bytes = furax.tree.nbytes(self.reader.out_structure)
    global_bytes = per_slot_bytes * n_slots_global

    # The true, total data size (before observations are padded to a common structure)
    real_bytes = self.reader.total_nbytes

    slot_overhead = (n_slots_global - self.n_observations) / self.n_observations
    byte_overhead = (global_bytes - real_bytes) / real_bytes
    logger_info(
        f'layout procs={n_processes} dev_per_proc={n_local_devices} dev_total={n_devices}'
    )
    logger_info(
        f'dataset obs={self.n_observations} slots={n_slots_global} slot_overhead=+{slot_overhead:.1%} '
        f'slots_per_proc={n_per_proc} slots_per_dev={n_per_dev} slot_size={_format_bytes(per_slot_bytes)}'
    )
    logger_info(
        f'dataset real={_format_bytes(real_bytes)} global={_format_bytes(global_bytes)} '
        f'byte_overhead=+{byte_overhead:.1%}'
    )

    rank_pad = n_pad / n_per_proc
    logger_info(
        f'rank={rank} obs={start}:{start + n_owned} real={n_owned} pad={n_pad} '
        f'pad_pct={rank_pad:.1%} real_size={_format_bytes(per_slot_bytes * n_owned)} '
        f'pad_size={_format_bytes(per_slot_bytes * n_pad)}'
    )

    with jax.set_mesh(self.mesh):
        # Single read pass (sharded over observations): build the model and accumulate the
        # hit map + RHS together, so each observation is read/preprocessed exactly once. The
        # cross-process reduction of hits/rhs happens via psum inside the kernel.
        model, hits, rhs = self.build_model_and_accumulate()
        jax.block_until_ready((hits, rhs))
        logger_info('Accumulated hit map and RHS vector')

        failed_observations = self._collect_failed_observations()
        if failed_observations:
            logger_info(f'{len(failed_observations)} observation(s) failed and were excluded')

        # System operator (full/diagonal)
        A = self.get_system_operator(model)
        diag_A = A if self.config.binned else self.get_system_operator(model, diag=True)
        BJ = BJPreconditioner.create(diag_A)
        icov = BJ.blocks.block_until_ready()
        logger_info('Computed white noise inverse covariance')

        valid_pixels = self.pixel_selection(hits, icov)
        # Select valid pixels on the (trailing) sky axes, leaving the leading Stokes axis intact.
        selector = IndexOperator((..., *jnp.where(valid_pixels)), in_structure=A.out_structure)
        n_selected = jnp.sum(valid_pixels)
        n_observed = jnp.sum(hits > 0)
        n_total = valid_pixels.size
        logger_info(f'Selected {n_selected} pixels ({n_observed} seen, {n_total} total)')

        hits = hits.at[~valid_pixels].set(0)  # excluded pixels have zero hits
        icov = jnp.moveaxis(icov, [-2, -1], [0, 1])  # (*pixels, ns, ns) → (ns, ns, *pixels)

        # Solve the mapmaking system
        solver = lineax.CG(**asdict(self.config.solver))
        spd = OperatorTag.POSITIVE_SEMIDEFINITE
        lx_system = as_lineax_operator(selector @ A @ selector.T, spd)
        M = (selector @ BJ.I @ selector.T).reduce()  # preconditioner
        lx_precond = as_lineax_operator(M, spd)
        rhs_reduced = selector(rhs)
        y0 = M(rhs_reduced)

        solution = lineax.linear_solve(
            lx_system,
            rhs_reduced,
            solver=solver,
            options={'preconditioner': lx_precond, 'y0': y0},
            throw=False,
        )
        estimate = selector.T(solution.value)
        num_steps = solution.stats['num_steps']
        logger_info(f'Finished mapmaking (iteration steps: {num_steps})')

    return MapMakingResults(
        map=estimate,
        icov=icov,
        hit_map=hits,
        solver_stats=solution.stats,
        landscape=self.landscape,
        failed_observations=failed_observations,
    )

build_model_and_accumulate()

Build the model and accumulate the hit map and RHS in a single, sharded read pass.

Sharded over observations: each observation is read (and, for preproc-backed observations, preprocessed) exactly once, and contributes to the model, the hit map and the RHS from that single read. This avoids reading every observation twice (once to build the model, once to accumulate the RHS). The hit map and RHS are reduced across all processes with psum inside the kernel.

Observations that contribute nothing -- padding (added so every device carries the same workload) and failed loads (the preprocessing pipeline raised) -- are gated out by zeroing their masker inside the kernel, so they drop out of the hit map, the RHS, and the CG system operator alike. Failed loads are reported afterwards from self.reader.failed_indices (see make_maps).

Must run under jax.set_mesh(self.mesh).

Returns (model, hits, rhs) with the model sharded along the observation axis and the hit map / RHS replicated (already reduced across processes).

Source code in src/furax/mapmaking/mapmaker.py
def build_model_and_accumulate(
    self,
) -> tuple[ObservationModel, Int64[Array, '...'], StokesType]:
    """Build the model and accumulate the hit map and RHS in a single, sharded read pass.

    Sharded over observations: each observation is read (and, for preproc-backed observations,
    preprocessed) exactly once, and contributes to the model, the hit map and the RHS from that
    single read. This avoids reading every observation twice (once to build the model, once to
    accumulate the RHS). The hit map and RHS are reduced across all processes with ``psum``
    inside the kernel.

    Observations that contribute nothing -- padding (added so every device carries the same
    workload) and failed loads (the preprocessing pipeline raised) -- are gated out by zeroing
    their masker inside the kernel, so they drop out of the hit map, the RHS, and the CG system
    operator alike. Failed loads are reported afterwards from ``self.reader.failed_indices``
    (see ``make_maps``).

    Must run under ``jax.set_mesh(self.mesh)``.

    Returns ``(model, hits, rhs)`` with the model sharded along the observation axis and the
    hit map / RHS replicated (already reduced across processes).
    """
    config = self.config
    landscape = self.landscape
    reader = self.reader
    reader.reset_failures()  # fresh pass: drop failures recorded by any previous read
    fill_gaps = config.gaps.treatment == GapTreatment.FILL and not config.binned

    indices = self.distribute(self.get_padded_read_indices())
    is_real = self.distribute(self._real_observation_mask())
    axis = jax.sharding.get_abstract_mesh().axis_names[0]

    @jax.shard_map(out_specs=(P('obs'), P(), P()), check_vma=False)
    def kernel(indices, is_real):  # type: ignore[no-untyped-def]
        def step(carry, args):  # type: ignore[no-untyped-def]
            hits_acc, rhs_acc = carry
            i, real = args

            # Skip the load for padding slots: only the real branch hits the io_callback,
            # so a padded observation is never read or preprocessed just to be masked away.
            # (lax.cond evaluates a single branch at runtime)
            data, padding, valid = jax.lax.cond(
                real,
                lambda: reader.read(i),
                lambda: reader.read_filler(),
            )
            obs = ObservationModel.create(data, padding, config, landscape)

            # Padding/failed observations contribute nothing
            obs.M = obs.M.restrict(real & valid)

            # Hit map contribution.
            assert isinstance(obs.H, CompositionOperator)  # mypy
            pointing = obs.H.operands[-1]
            assert isinstance(pointing, PointingOperator)  # mypy
            pointing_i = pointing.as_stokes_i(interpolate=False)
            ones = furax.tree.ones_like(obs.M.in_structure)
            masked_tod = obs.M(ones)
            # The sample mask is per-detector (identical across Stokes legs); take one leg so
            # StokesI wraps a (ndet, nsamp) array rather than a whole demodulated Stokes backing.
            masked = masked_tod.data[0] if isinstance(masked_tod, Stokes) else masked_tod
            hits_i = jnp.int64(pointing_i.T(StokesI(masked)).i)

            # RHS contribution (optionally gap-filled).
            def func_gapfill(tod):  # type: ignore[no-untyped-def]
                # Only reached under GapTreatment.FILL, where W is the plain inner-mask weight.
                assert isinstance(obs.W, WeightOperator)
                # Optional M_b N M_b preconditioner (covariance from the noise model).
                preconditioner = None
                if config.gaps.fill_options.precondition:
                    cov = obs.noise_operator(config.weighting.correlation_length, inverse=False)
                    m_bad = obs.M.complement()
                    preconditioner = (m_bad @ cov @ m_bad).reduce()
                return gap_fill(
                    jax.random.key(config.gaps.fill_options.seed),
                    tod,
                    obs.W.weight,
                    obs.M,
                    rate=obs.sample_rate,
                    max_cg_steps=config.gaps.fill_options.max_steps,
                    rtol=config.gaps.fill_options.rtol,
                    preconditioner=preconditioner,
                    metadata=data[ReaderField.METADATA],
                )

            # Use Python `if` for static `fill_gaps`, so gap-filling branch is not traced
            tod = data[ReaderField.SAMPLE_DATA]
            if fill_gaps:
                tod = jax.lax.cond(
                    real & valid,
                    func_gapfill,
                    lambda _: _,  # return raw data as-is
                    tod,
                )
                # Gaps filled: skip the data-side mask so the fill survives N⁻¹.
                rhs_i = obs.rhs_operator_prefilled(tod)
            else:
                rhs_i = obs.rhs_operator(tod)
            return (hits_acc + hits_i, furax.tree.add(rhs_acc, rhs_i)), obs

        init_hits = jax.lax.pcast(jnp.zeros(landscape.shape, jnp.int64), axis, to='varying')
        init_rhs = jax.lax.pcast(landscape.zeros(), axis, to='varying')
        (hits, rhs), model = jax.lax.scan(step, (init_hits, init_rhs), (indices, is_real))
        return model, jax.lax.psum(hits, axis), jax.lax.psum(rhs, axis)

    model, hits, rhs = kernel(indices, is_real)
    return model, hits, rhs

get_system_operator(model, *, diag=False)

Source code in src/furax/mapmaking/mapmaker.py
def get_system_operator(
    self, model: ObservationModel, *, diag: bool = False
) -> AbstractLinearOperator:
    H = ScanBlockColumnOperator.create(model.H)
    # filter_vmap: array leaves mapped, static fields held
    weight = eqx.filter_vmap(ObservationModel.diag_W)(model) if diag else model.W
    W = ScanBlockDiagonalOperator.create(weight)
    # specify leading axis dimension because F can be trivial
    _, n_own, n_pad = self.obs_distribution
    F = ScanBlockDiagonalOperator.create(model.F, n_lead=n_own + n_pad)
    return (H.T @ W @ F @ H).reduce()

pixel_selection(hits, weights)

Compute pixel selection according to hit and condition number cuts.

Source code in src/furax/mapmaking/mapmaker.py
def pixel_selection(
    self, hits: Integer[Array, ' pixels'], weights: Float[Array, 'pixels stokes stokes']
) -> Bool[Array, ' pixels']:
    """Compute pixel selection according to hit and condition number cuts."""
    # Cut pixels with low number of samples
    hits_quantile = jnp.quantile(hits[hits > 0], q=0.95)
    valid = hits > self.config.hits_cut * hits_quantile

    if self.config.cond_cut > 0:
        eigs = furax.linalg.eigvalsh(weights)
        valid = jnp.logical_and(
            valid,
            eigs[..., 0] > self.config.cond_cut * eigs[..., -1],
        )

    return valid

furax.mapmaking.BJPreconditioner

Bases: AbstractLinearOperator

Block-diagonal (per-pixel) Jacobi preconditioner for Stokes sky maps.

Holds one dense (n, n) block per pixel (n = len(stokes)), coupling the Stokes components at that pixel: blocks has shape (*sky, n, n) with blocks[..., i, j] the response of output component i to input component j. Applied by an einsum over the Stokes axis of the map's backing array; symmetric (self-adjoint) for a symmetric operator.

Methods:

  • __init__
  • create

    Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

  • mv
  • inverse

Attributes:

  • blocks (Float[Array, '*sky n n']) –
Source code in src/furax/mapmaking/preconditioner.py
@symmetric
class BJPreconditioner(AbstractLinearOperator):
    """Block-diagonal (per-pixel) Jacobi preconditioner for Stokes sky maps.

    Holds one dense ``(n, n)`` block per pixel (``n = len(stokes)``), coupling the Stokes
    components at that pixel: ``blocks`` has shape ``(*sky, n, n)`` with ``blocks[..., i, j]`` the
    response of output component ``i`` to input component ``j``. Applied by an einsum over the
    Stokes axis of the map's backing array; symmetric (self-adjoint) for a symmetric operator.
    """

    blocks: Float[Array, '*sky n n']

    def __init__(
        self,
        blocks: Float[Array, '*sky n n'],
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        object.__setattr__(self, 'blocks', blocks)
        super().__init__(in_structure=in_structure)

    @classmethod
    def create(cls, op: AbstractLinearOperator) -> 'BJPreconditioner':
        """Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

        The operator is assumed diagonal over the pixel (map) axes. Each Stokes component is probed
        with a unit map; the response is that column of every pixel's block at once.
        """
        in_struct = op.in_structure
        if not isinstance(in_struct, Stokes):
            raise ValueError('operator must act on Stokes pytrees (sky maps)')
        if not structure_equal(in_struct, op.out_structure):
            raise ValueError('operator must be square')

        stokes_cls = type(in_struct)
        n = len(in_struct.stokes)
        sky_shape = in_struct.shape
        dtype = in_struct.dtype

        columns = []
        for j in range(n):
            # unit map on component j (one everywhere on that component, zero on the others);
            # the backing array has the Stokes components on the leading axis.
            probe = stokes_cls.from_array(jnp.zeros((n, *sky_shape), dtype).at[j].set(1.0))
            columns.append(_apply(op, probe).data)  # (n_i, *sky) = column j, indexed by row i
        blocks = jnp.moveaxis(jnp.stack(columns, axis=-1), 0, -2)  # (i, *sky, j) -> (*sky, i, j)
        return cls(blocks, in_structure=in_struct)

    def mv(self, x: Stokes) -> Stokes:
        # einsum aligns the blocks' trailing (i, j) axes against x.data's leading Stokes axis
        # directly, without physically transposing either array.
        return type(x).from_array(jnp.einsum('...ij,j...->i...', self.blocks, x.data))

    def inverse(self) -> 'BJPreconditioner':
        # Per-pixel matrix inverse; stays a BJPreconditioner (keeps the @symmetric tag).
        return BJPreconditioner(jnp.linalg.inv(self.blocks), in_structure=self.in_structure)

blocks instance-attribute

__init__(blocks, *, in_structure)

Source code in src/furax/mapmaking/preconditioner.py
def __init__(
    self,
    blocks: Float[Array, '*sky n n'],
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    object.__setattr__(self, 'blocks', blocks)
    super().__init__(in_structure=in_structure)

create(op) classmethod

Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

The operator is assumed diagonal over the pixel (map) axes. Each Stokes component is probed with a unit map; the response is that column of every pixel's block at once.

Source code in src/furax/mapmaking/preconditioner.py
@classmethod
def create(cls, op: AbstractLinearOperator) -> 'BJPreconditioner':
    """Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

    The operator is assumed diagonal over the pixel (map) axes. Each Stokes component is probed
    with a unit map; the response is that column of every pixel's block at once.
    """
    in_struct = op.in_structure
    if not isinstance(in_struct, Stokes):
        raise ValueError('operator must act on Stokes pytrees (sky maps)')
    if not structure_equal(in_struct, op.out_structure):
        raise ValueError('operator must be square')

    stokes_cls = type(in_struct)
    n = len(in_struct.stokes)
    sky_shape = in_struct.shape
    dtype = in_struct.dtype

    columns = []
    for j in range(n):
        # unit map on component j (one everywhere on that component, zero on the others);
        # the backing array has the Stokes components on the leading axis.
        probe = stokes_cls.from_array(jnp.zeros((n, *sky_shape), dtype).at[j].set(1.0))
        columns.append(_apply(op, probe).data)  # (n_i, *sky) = column j, indexed by row i
    blocks = jnp.moveaxis(jnp.stack(columns, axis=-1), 0, -2)  # (i, *sky, j) -> (*sky, i, j)
    return cls(blocks, in_structure=in_struct)

mv(x)

Source code in src/furax/mapmaking/preconditioner.py
def mv(self, x: Stokes) -> Stokes:
    # einsum aligns the blocks' trailing (i, j) axes against x.data's leading Stokes axis
    # directly, without physically transposing either array.
    return type(x).from_array(jnp.einsum('...ij,j...->i...', self.blocks, x.data))

inverse()

Source code in src/furax/mapmaking/preconditioner.py
def inverse(self) -> 'BJPreconditioner':
    # Per-pixel matrix inverse; stays a BJPreconditioner (keeps the @symmetric tag).
    return BJPreconditioner(jnp.linalg.inv(self.blocks), in_structure=self.in_structure)

furax.mapmaking.MapMakingResults dataclass

Methods:

  • save
  • load

    Load a previously saved MapMakingResults from disk.

  • __init__

Attributes:

  • map (StokesType) –

    The estimated sky map

  • landscape (StokesLandscape) –

    The landscape corresponding to the map

  • hit_map (Integer[Array, ' *dims']) –

    The map of hit counts per pixel

  • icov (Float[Array, 'stokes stokes *dims']) –

    The per-pixel inverse noise covariance matrix (H^T N^{-1} H)

  • solver_stats (dict[str, Any] | None) –

    Statistics from the linear solver (e.g. num_steps, max_steps)

  • noise_fits (Float[Array, ...] | None) –

    The fitted noise PSD parameters

  • failed_observations (list[str] | None) –

    Names of observations that failed to load and were excluded from the maps

Source code in src/furax/mapmaking/results.py
@dataclass
class MapMakingResults:
    map: StokesType
    """The estimated sky map"""

    landscape: StokesLandscape
    """The landscape corresponding to the map"""

    hit_map: Integer[Array, ' *dims']
    """The map of hit counts per pixel"""

    icov: Float[Array, 'stokes stokes *dims']
    """The per-pixel inverse noise covariance matrix (H^T N^{-1} H)"""

    solver_stats: dict[str, Any] | None = None
    """Statistics from the linear solver (e.g. num_steps, max_steps)"""

    noise_fits: Float[Array, '...'] | None = None
    """The fitted noise PSD parameters"""

    failed_observations: list[str] | None = None
    """Names of observations that failed to load and were excluded from the maps"""

    def save(self, out_dir: str | Path) -> None:
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
        self._save_array(np.array(self.map.data), 'map', out_dir)
        self._save_array(np.array(self.hit_map), 'hit_map', out_dir, column_names=['HITS'])
        self._save_icov(np.array(self.icov), out_dir)
        if self.noise_fits is not None:
            np.save(out_dir / 'noise_fits', np.array(self.noise_fits))
        if self.solver_stats is not None:
            with open(out_dir / 'solver_stats.json', 'w') as f:
                json.dump(self.solver_stats, f, indent=2, cls=_JsonEncoder)
        if self.failed_observations:
            with open(out_dir / 'failed_observations.txt', 'w') as f:
                for name in self.failed_observations:
                    f.write(f'{name}\n')

    @classmethod
    def load(
        cls,
        out_dir: str | Path,
        landscape: StokesLandscape,
        fields: set[str] | list[str] | None = None,
    ) -> 'MapMakingResults':
        """Load a previously saved MapMakingResults from disk.

        Args:
            out_dir: Directory containing the saved files.
            landscape: The landscape used when the results were saved.
            fields: Fields to load. Defaults to all fields. Required fields
                (map, hit_map, icov) must always be included if specified.
        """
        out_dir = Path(out_dir)
        if not out_dir.exists():
            raise FileNotFoundError(f'Output directory not found: {out_dir}')

        if fields is None:
            fields_to_load = _VALID_FIELDS
        else:
            fields_to_load = frozenset(fields)
            invalid = fields_to_load - _VALID_FIELDS
            if invalid:
                raise ValueError(
                    f'Unknown fields: {sorted(invalid)}. Valid fields: {sorted(_VALID_FIELDS)}'
                )
            missing_required = _REQUIRED_FIELDS - fields_to_load
            if missing_required:
                raise ValueError(f'Required fields cannot be excluded: {sorted(missing_required)}')

        sky_map = cls._load_map(out_dir, landscape)
        hit_map = cls._load_hit_map(out_dir, landscape)
        icov = cls._load_icov(out_dir, landscape)

        noise_fits = cls._load_noise_fits(out_dir) if 'noise_fits' in fields_to_load else None
        solver_stats = cls._load_solver_stats(out_dir) if 'solver_stats' in fields_to_load else None

        return cls(
            map=sky_map,
            landscape=landscape,
            hit_map=hit_map,
            icov=icov,
            solver_stats=solver_stats,
            noise_fits=noise_fits,
        )

    @staticmethod
    def _load_array(
        name: str, out_dir: Path, landscape: StokesLandscape, n_fields: int
    ) -> np.ndarray:
        """Load a [n_fields, *pixel_dims] array from FITS or npy.

        For HEALPix landscapes with n_fields=1, a leading dimension is added
        so the returned shape is always [n_fields, npix].
        """
        if isinstance(landscape, (WCSLandscape, AstropyWCSLandscape)):
            path = out_dir / f'{name}.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            with fits.open(path) as hdul:
                arr = np.asarray(hdul[0].data)
                return arr.astype(arr.dtype.newbyteorder('='), copy=False)
        elif isinstance(landscape, HealpixLandscape):
            path = out_dir / f'{name}.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            if n_fields == 1:
                arr = np.array(hp.read_map(str(path), field=0))
            else:
                maps = hp.read_map(str(path), field=list(range(n_fields)))
                arr = np.stack(maps, axis=0)
            # hp.read_map with field=0 drops the leading dim; restore it
            if arr.ndim == len(landscape.shape):
                arr = arr[np.newaxis]
            return arr.astype(arr.dtype.newbyteorder('='), copy=False)
        else:
            path = out_dir / f'{name}.npy'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            return np.load(path)  # type: ignore[no-any-return]

    @staticmethod
    def _load_map(out_dir: Path, landscape: StokesLandscape) -> StokesType:
        ns = len(landscape.stokes)
        arr = MapMakingResults._load_array('map', out_dir, landscape, ns)
        stokes_cls = Stokes.class_for(landscape.stokes)
        return stokes_cls(*[jnp.array(arr[i]) for i in range(ns)])

    @staticmethod
    def _load_hit_map(out_dir: Path, landscape: StokesLandscape) -> Array:
        if isinstance(landscape, (WCSLandscape, AstropyWCSLandscape)):
            path = out_dir / 'hit_map.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            with fits.open(path) as hdul:
                arr = hdul[0].data
                return jnp.array(arr.astype(arr.dtype.newbyteorder('='), copy=False))
        elif isinstance(landscape, HealpixLandscape):
            path = out_dir / 'hit_map.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            hits = hp.read_map(str(path), field=0)
            return jnp.array(hits.astype(hits.dtype.newbyteorder('='), copy=False))
        else:
            path = out_dir / 'hit_map.npy'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            return jnp.array(np.load(path))

    @staticmethod
    def _load_icov(out_dir: Path, landscape: StokesLandscape) -> Array:
        stokes = landscape.stokes
        ns = len(stokes)
        n_upper = ns * (ns + 1) // 2
        arr_upper = MapMakingResults._load_array('icov', out_dir, landscape, n_upper)

        upper = [(i, j) for i in range(ns) for j in range(i, ns)]
        pixel_shape = arr_upper.shape[1:]
        icov = np.zeros((ns, ns, *pixel_shape), dtype=arr_upper.dtype)
        for k, (i, j) in enumerate(upper):
            icov[i, j] = arr_upper[k]
            if i != j:
                icov[j, i] = arr_upper[k]
        return jnp.array(icov)

    @staticmethod
    def _load_noise_fits(out_dir: Path) -> Array | None:
        path = out_dir / 'noise_fits.npy'
        if not path.exists():
            return None
        return jnp.array(np.load(path))

    @staticmethod
    def _load_solver_stats(out_dir: Path) -> dict[str, Any] | None:
        path = out_dir / 'solver_stats.json'
        if not path.exists():
            return None
        with open(path) as f:
            return json.load(f)  # type: ignore[no-any-return]

    def _save_icov(self, arr: np.ndarray, out_dir: Path) -> None:
        """Save the inverse covariance, storing only the upper triangle with stokes-aware names."""
        stokes = self.landscape.stokes
        ns = len(stokes)
        upper = [(i, j) for i in range(ns) for j in range(i, ns)]
        column_names = [stokes[i] + stokes[j] for i, j in upper]
        arr_upper = np.stack([arr[i, j] for i, j in upper], axis=0)
        self._save_array(arr_upper, 'icov', out_dir, column_names=column_names)

    def _save_array(
        self, arr: np.ndarray, name: str, out_dir: Path, column_names: list[str] | None = None
    ) -> None:
        """Save a numpy array as FITS (WCS or HEALPix) or npy depending on the landscape."""
        if isinstance(self.landscape, WCSLandscape):
            hdu = fits.PrimaryHDU(arr, header=fits.Header(self.landscape.to_wcs().to_header()))
            hdu.writeto(out_dir / f'{name}.fits', overwrite=True)
        elif isinstance(self.landscape, AstropyWCSLandscape):
            hdu = fits.PrimaryHDU(arr, header=fits.Header(self.landscape.wcs.to_header()))
            hdu.writeto(out_dir / f'{name}.fits', overwrite=True)
        elif isinstance(self.landscape, HealpixLandscape):
            maps = [arr] if arr.ndim == 1 else list(arr.reshape(-1, arr.shape[-1]))
            hp.write_map(
                str(out_dir / f'{name}.fits'),
                maps,
                nest=self.landscape.nested,
                column_names=column_names,
                overwrite=True,
            )
        else:
            furax_logger.warning(
                f'saving {name} as npy: geometry information will not be embedded in the file'
            )
            np.save(out_dir / name, arr)

map instance-attribute

The estimated sky map

landscape instance-attribute

The landscape corresponding to the map

hit_map instance-attribute

The map of hit counts per pixel

icov instance-attribute

The per-pixel inverse noise covariance matrix (H^T N^{-1} H)

solver_stats = None class-attribute instance-attribute

Statistics from the linear solver (e.g. num_steps, max_steps)

noise_fits = None class-attribute instance-attribute

The fitted noise PSD parameters

failed_observations = None class-attribute instance-attribute

Names of observations that failed to load and were excluded from the maps

save(out_dir)

Source code in src/furax/mapmaking/results.py
def save(self, out_dir: str | Path) -> None:
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    self._save_array(np.array(self.map.data), 'map', out_dir)
    self._save_array(np.array(self.hit_map), 'hit_map', out_dir, column_names=['HITS'])
    self._save_icov(np.array(self.icov), out_dir)
    if self.noise_fits is not None:
        np.save(out_dir / 'noise_fits', np.array(self.noise_fits))
    if self.solver_stats is not None:
        with open(out_dir / 'solver_stats.json', 'w') as f:
            json.dump(self.solver_stats, f, indent=2, cls=_JsonEncoder)
    if self.failed_observations:
        with open(out_dir / 'failed_observations.txt', 'w') as f:
            for name in self.failed_observations:
                f.write(f'{name}\n')

load(out_dir, landscape, fields=None) classmethod

Load a previously saved MapMakingResults from disk.

Parameters:

  • out_dir (str | Path) –

    Directory containing the saved files.

  • landscape (StokesLandscape) –

    The landscape used when the results were saved.

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

    Fields to load. Defaults to all fields. Required fields (map, hit_map, icov) must always be included if specified.

Source code in src/furax/mapmaking/results.py
@classmethod
def load(
    cls,
    out_dir: str | Path,
    landscape: StokesLandscape,
    fields: set[str] | list[str] | None = None,
) -> 'MapMakingResults':
    """Load a previously saved MapMakingResults from disk.

    Args:
        out_dir: Directory containing the saved files.
        landscape: The landscape used when the results were saved.
        fields: Fields to load. Defaults to all fields. Required fields
            (map, hit_map, icov) must always be included if specified.
    """
    out_dir = Path(out_dir)
    if not out_dir.exists():
        raise FileNotFoundError(f'Output directory not found: {out_dir}')

    if fields is None:
        fields_to_load = _VALID_FIELDS
    else:
        fields_to_load = frozenset(fields)
        invalid = fields_to_load - _VALID_FIELDS
        if invalid:
            raise ValueError(
                f'Unknown fields: {sorted(invalid)}. Valid fields: {sorted(_VALID_FIELDS)}'
            )
        missing_required = _REQUIRED_FIELDS - fields_to_load
        if missing_required:
            raise ValueError(f'Required fields cannot be excluded: {sorted(missing_required)}')

    sky_map = cls._load_map(out_dir, landscape)
    hit_map = cls._load_hit_map(out_dir, landscape)
    icov = cls._load_icov(out_dir, landscape)

    noise_fits = cls._load_noise_fits(out_dir) if 'noise_fits' in fields_to_load else None
    solver_stats = cls._load_solver_stats(out_dir) if 'solver_stats' in fields_to_load else None

    return cls(
        map=sky_map,
        landscape=landscape,
        hit_map=hit_map,
        icov=icov,
        solver_stats=solver_stats,
        noise_fits=noise_fits,
    )

__init__(map, landscape, hit_map, icov, solver_stats=None, noise_fits=None, failed_observations=None)

furax.mapmaking.gap_fill(key, x, ninv, mask, *, rate=1.0, max_cg_steps=50, rtol=0.0001, atol=0.0, preconditioner=None, metadata=None)

Fill flagged time samples with a constrained noise realization, leaving good samples unchanged.

Replaces each flagged sample with a draw from the noise model conditioned on the surrounding good data (a constrained realization), so the gap is filled consistently with the noise correlations rather than by interpolation. The fill is a free realization \(\xi\) plus a correction that pins the good samples back to their data values:

\[ x_{\mathrm{fill}} = \xi + d, \quad d_{\mathrm{good}} = x_{\mathrm{good}} - \xi_{\mathrm{good}}, \quad (M_b\, N^{-1} M_b)\, d_{\mathrm{bad}} = -M_b\, N^{-1} d_{\mathrm{good}}, \]

where \(M_b\) masks the flagged samples. Three things to note:

  • Good samples are returned bit-exact; only the gaps change.
  • The CG solve acts only on the flagged subspace.
  • \(\xi\) is drawn from \(N\), obtained as the reciprocal of the inverse-noise PSD.

For a few gaps wider than the correlation length the bare \(M_b N^{-1} M_b\) system is ill-conditioned; passing the flagged-block covariance \(M_b N M_b\) as preconditioner, i.e. mask.complement() @ cov @ mask.complement() with cov a cheap approximation of \(N\) (its banded/Fourier form), then helps a lot. Its product with the system operator differs from the identity by a boundary term of rank \(\approx 2\times\) (correlation bandwidth) \(\times\) (number of gaps), and CG converges in roughly that many steps.

This is only worthwhile when that rank is below the iteration count of the bare system. For the common case of many short gaps (turnarounds, glitches) the gaps are shorter than the correlation length, the bare flagged block is already well-conditioned, and preconditioning is a net loss -- leave preconditioner=None there. The preconditioner must be symmetric positive definite and live in the flagged subspace (zero on the good samples).

Parameters:

  • key (Key[Array, '']) –

    A PRNG key generated by the user.

  • x (PyTree[Float[Array, ' *shape']]) –

    The time-ordered vector to be filled.

  • ninv (AbstractLinearOperator) –

    The inverse-noise operator \(N^{-1}\).

  • mask (MaskOperator) –

    The good-sample mask \(M\); its zeros are the gaps.

  • rate (ArrayLike, default: 1.0 ) –

    The sampling rate of the data.

  • max_cg_steps (int, default: 50 ) –

    Maximum number of CG steps for the flagged-subspace solve.

  • rtol (float, default: 0.0001 ) –

    Relative convergence tolerance for the solver.

  • atol (float, default: 0.0 ) –

    Absolute convergence tolerance for the solver.

  • preconditioner (AbstractLinearOperator | None, default: None ) –

    Optional SPD preconditioner for the flagged-subspace CG (see above).

  • metadata (HashedObservationMetadata | None, default: None ) –

    Metadata folded into the random key for reproducibility.

Returns:

  • PyTree[Float[Array, ' *shape']]

    x with its flagged samples replaced by the constrained realization.

Examples:

Gap-filling a single-detector timestream

>>> key = jax.random.key(0)
>>> ndet, nsamples = 3, 10
>>> x = jax.random.normal(key, (ndet, nsamples))
>>> in_structure = jax.ShapeDtypeStruct(x.shape, x.dtype)
>>> good = jnp.broadcast_to(
...     jnp.array([1, 1, 1, 0, 0, 1, 1, 1, 1, 1], dtype=bool), x.shape
... )
>>> mask = MaskOperator.from_boolean_mask(good, in_structure=in_structure)
>>> ninv = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)
>>> cov = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)  # N ≈ cov
>>> preconditioner = (mask.complement() @ cov @ mask.complement()).reduce()
>>> filled = gap_fill(key, x, ninv, mask, preconditioner=preconditioner)
>>> assert jnp.all(filled[good] == x[good])
Source code in src/furax/mapmaking/gap_filling.py
def gap_fill(
    key: Key[Array, ''],
    x: PyTree[Float[Array, ' *shape']],
    ninv: AbstractLinearOperator,
    mask: MaskOperator,
    *,
    rate: ArrayLike = 1.0,
    max_cg_steps: int = 50,
    rtol: float = 1e-4,
    atol: float = 0.0,
    preconditioner: AbstractLinearOperator | None = None,
    metadata: HashedObservationMetadata | None = None,
) -> PyTree[Float[Array, ' *shape']]:
    r"""Fill flagged time samples with a constrained noise realization, leaving good samples unchanged.

    Replaces each flagged sample with a draw from the noise model conditioned on the surrounding
    good data (a *constrained realization*), so the gap is filled consistently with the noise
    correlations rather than by interpolation. The fill is a free realization $\xi$ plus a
    correction that pins the good samples back to their data values:

    $$
    x_{\mathrm{fill}} = \xi + d, \quad
    d_{\mathrm{good}} = x_{\mathrm{good}} - \xi_{\mathrm{good}}, \quad
    (M_b\, N^{-1} M_b)\, d_{\mathrm{bad}} = -M_b\, N^{-1} d_{\mathrm{good}},
    $$

    where $M_b$ masks the flagged samples. Three things to note:

    - Good samples are returned bit-exact; only the gaps change.
    - The CG solve acts only on the flagged subspace.
    - $\xi$ is drawn from $N$, obtained as the reciprocal of the inverse-noise PSD.

    For a few gaps wider than the correlation length the bare $M_b N^{-1} M_b$ system is
    ill-conditioned; passing the flagged-block covariance $M_b N M_b$ as ``preconditioner``,
    i.e. ``mask.complement() @ cov @ mask.complement()`` with ``cov`` a cheap approximation of
    $N$ (its banded/Fourier form), then helps a lot. Its product with the system operator
    differs from the identity by a boundary term of rank $\approx 2\times$ (correlation
    bandwidth) $\times$ (number of gaps), and CG converges in roughly that many steps.

    This is only worthwhile when that rank is *below* the iteration count of the bare system. For
    the common case of many short gaps (turnarounds, glitches) the gaps are shorter than the
    correlation length, the bare flagged block is already well-conditioned, and preconditioning is a
    net loss -- leave ``preconditioner=None`` there. The preconditioner must be symmetric positive
    definite and live in the flagged subspace (zero on the good samples).

    Args:
        key: A PRNG key generated by the user.
        x: The time-ordered vector to be filled.
        ninv: The inverse-noise operator $N^{-1}$.
        mask: The good-sample mask $M$; its zeros are the gaps.
        rate: The sampling rate of the data.
        max_cg_steps: Maximum number of CG steps for the flagged-subspace solve.
        rtol: Relative convergence tolerance for the solver.
        atol: Absolute convergence tolerance for the solver.
        preconditioner: Optional SPD preconditioner for the flagged-subspace CG (see above).
        metadata: Metadata folded into the random key for reproducibility.

    Returns:
        ``x`` with its flagged samples replaced by the constrained realization.

    Examples:
        Gap-filling a single-detector timestream

        >>> key = jax.random.key(0)
        >>> ndet, nsamples = 3, 10
        >>> x = jax.random.normal(key, (ndet, nsamples))
        >>> in_structure = jax.ShapeDtypeStruct(x.shape, x.dtype)
        >>> good = jnp.broadcast_to(
        ...     jnp.array([1, 1, 1, 0, 0, 1, 1, 1, 1, 1], dtype=bool), x.shape
        ... )
        >>> mask = MaskOperator.from_boolean_mask(good, in_structure=in_structure)
        >>> ninv = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)
        >>> cov = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)  # N ≈ cov
        >>> preconditioner = (mask.complement() @ cov @ mask.complement()).reduce()
        >>> filled = gap_fill(key, x, ninv, mask, preconditioner=preconditioner)
        >>> assert jnp.all(filled[good] == x[good])
    """
    m = mask
    m_bad = m.complement()  # masks the flagged samples (gaps)
    # ξ ~ N, drawn from the reciprocal PSD of N⁻¹.
    xi = generate_noise_realization(key, ninv, rate, metadata=metadata, inverse=True)
    dg = m(tree.sub(x, xi))  # deviation on good samples, zero on the gaps
    # M_b N⁻¹ M_b is singular (zero on good) but SPD on the gaps; CG from zero stays on the gaps.
    a = m_bad @ ninv @ m_bad
    b = tree.mul(-1.0, m_bad(ninv(dg)))
    d_bad = cg(
        a,
        b,
        preconditioner=preconditioner,
        max_steps=max_cg_steps,
        rtol=rtol,
        atol=atol,
    ).solution
    # Keep good samples bit-exact; fill the gaps with ξ + d_bad
    return tree.add(m(x), m_bad(tree.add(xi, d_bad)))

furax.mapmaking.acquisition

Functions:

build_acquisition_operator(landscape, boresight_quaternions, detector_quaternions, hwp_angles=None, *, demodulated=False, pointing_on_the_fly=True, pointing_batch_size=16, pointing_interpolate=False, dtype=jnp.float64)

Build an acquisition operator for a single observation. Does not include masking.

Source code in src/furax/mapmaking/acquisition.py
def build_acquisition_operator(
    landscape: StokesLandscape,
    boresight_quaternions: Array,
    detector_quaternions: Array,
    hwp_angles: Array | None = None,
    *,
    demodulated: bool = False,
    pointing_on_the_fly: bool = True,
    pointing_batch_size: int = 16,
    pointing_interpolate: bool = False,
    dtype: DTypeLike = jnp.float64,
) -> AbstractLinearOperator:
    """Build an acquisition operator for a single observation. Does not include masking."""
    # The TOD shape
    ndet = detector_quaternions.shape[0]
    nsamp = boresight_quaternions.shape[0]
    data_shape = (ndet, nsamp)

    # Rotate into detector frame unless a HWP is present (even if demodulated)
    has_hwp = hwp_angles is not None or demodulated
    pointing = PointingOperator.create(
        landscape,
        boresight_quaternions,
        detector_quaternions,
        batch_size=pointing_batch_size,
        frame='boresight' if has_hwp else 'detector',
        interpolate=pointing_interpolate,
    )
    if not pointing_on_the_fly:
        pointing = pointing.as_expanded_operator()  # type: ignore[assignment]

    # If there is no HWP, we just add a polarizer at the end
    # NB: already in detector frame at this point
    polarizer = LinearPolarizerOperator.create(
        shape=data_shape,
        dtype=dtype,
        stokes=landscape.stokes,
    )
    if not has_hwp:
        return polarizer @ pointing

    # If there is a HWP, we are in the boresight frame so we need another rotation
    #
    # Use `atomic=True` to prevent merging with the HWP's internal rotation:
    # QURotationHWPRule commutes rot past the HWP, then QURotationRule would
    # absorb rot.T(gamma, ndet×1) into rot(hwp_angles, ndet×nsamp), broadcasting
    # the per-detector gamma across all samples unnecessarily.
    gamma = to_gamma_angles(detector_quaternions)[:, None]
    rot = QURotationOperator.create(
        data_shape,
        dtype,
        landscape.stokes,
        angles=gamma,
        atomic=True,
    )

    # In the demodulated case, there is no polarizer
    # And the gamma angle is flipped!
    if demodulated:
        return 0.5 * rot.T @ pointing

    # In the general case, we include polarizer and HWP
    hwp = HWPOperator.create(
        shape=data_shape,
        dtype=dtype,
        stokes=landscape.stokes,
        angles=hwp_angles,
    )
    return polarizer @ rot @ hwp @ pointing

furax.mapmaking.config

Classes:

Methods

Bases: Enum

Attributes:

Source code in src/furax/mapmaking/config.py
class Methods(Enum):
    BINNED = 'Binned'
    MAXL = 'ML'
    TWOSTEP = 'TwoStep'
    ATOP = 'ATOP'

BINNED = 'Binned' class-attribute instance-attribute

MAXL = 'ML' class-attribute instance-attribute

TWOSTEP = 'TwoStep' class-attribute instance-attribute

ATOP = 'ATOP' class-attribute instance-attribute

WeightingMode

Bases: Enum

Attributes:

Source code in src/furax/mapmaking/config.py
class WeightingMode(Enum):
    IDENTITY = 'identity'  # identity inverse-noise, no weighting
    DIAGONAL = 'diagonal'  # white (diagonal) weighting
    TOEPLITZ = 'toeplitz'  # atmospheric 1/f, banded Toeplitz weighting

IDENTITY = 'identity' class-attribute instance-attribute

DIAGONAL = 'diagonal' class-attribute instance-attribute

TOEPLITZ = 'toeplitz' class-attribute instance-attribute

NoiseSource

Bases: Enum

Attributes:

Source code in src/furax/mapmaking/config.py
class NoiseSource(Enum):
    FIT = 'fit'  # fit the noise model from the TOD PSD
    PRECOMPUTED = 'precomputed'  # read precomputed noise parameters from the data pipeline

FIT = 'fit' class-attribute instance-attribute

PRECOMPUTED = 'precomputed' class-attribute instance-attribute

GapTreatment

Bases: Enum

How flagged samples enter the correlated-noise GLS weighting.

For white/diagonal weights all three coincide (M W M = M W for mask M); the distinction only matters in the correlated (Toeplitz/atmospheric) regime.

Attributes:

Source code in src/furax/mapmaking/config.py
class GapTreatment(Enum):
    """How flagged samples enter the correlated-noise GLS weighting.

    For white/diagonal weights all three coincide (`M W M = M W` for mask `M`);
    the distinction only matters in the correlated (Toeplitz/atmospheric) regime.
    """

    INNER_MASK = 'inner_mask'  # W = M N⁻¹ M (unbiased but suboptimal; cheap)
    FILL = 'fill'  # gap-fill the RHS with a constrained noise realization (single-realization use)
    NESTED = 'nested'  # W = M (M N M)⁻¹ M = W_exact (unbiased and minimum-variance)

INNER_MASK = 'inner_mask' class-attribute instance-attribute

FILL = 'fill' class-attribute instance-attribute

NESTED = 'nested' class-attribute instance-attribute

SolverConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class SolverConfig:
    rtol: float = 1e-6
    atol: float = 0
    max_steps: int = 1_000

rtol = 1e-06 class-attribute instance-attribute

atol = 0 class-attribute instance-attribute

max_steps = 1000 class-attribute instance-attribute

__init__(rtol=1e-06, atol=0, max_steps=1000)

NoiseFitConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class NoiseFitConfig:
    nperseg: int = 2_048
    """Welch window length in samples for PSD estimation."""

    max_iter: int = 100
    """Maximum number of iterations"""

    tol: float = 1e-10
    """Relative minimiser tolerance (step size and function value change)"""

    min_freq_nyquist: float = 1e-8
    """Only use f >= min_freq * nyquist for noise fitting"""

    max_freq_nyquist: float = 1
    """Only use f < max_freq * nyquist for noise fitting"""

    low_freq_nyquist: float = 0.02
    """The PSD at f < low_freq * nyquist is assumed to be dominated by 1/f noise"""

    high_freq_nyquist: float = 0.02
    """The PSD at f > high_freq * nyquist is assumed to be dominated by white noise"""

    mask_hwp_harmonics: bool = True
    """Mask HWP harmonics: 1f, 2f, 4f"""

    mask_ptc_harmonics: bool = False
    """Mask PTC harmonics: 1f, 2f"""

    freq_mask_width: float = 0.5
    """Full width [Hz] of the frequency mask (if used) around HWP and PTC harmonics"""

    ptc_freq: float = 1.4
    """PTC frequency [Hz] used for masking (if used)"""

nperseg = 2048 class-attribute instance-attribute

Welch window length in samples for PSD estimation.

max_iter = 100 class-attribute instance-attribute

Maximum number of iterations

tol = 1e-10 class-attribute instance-attribute

Relative minimiser tolerance (step size and function value change)

min_freq_nyquist = 1e-08 class-attribute instance-attribute

Only use f >= min_freq * nyquist for noise fitting

max_freq_nyquist = 1 class-attribute instance-attribute

Only use f < max_freq * nyquist for noise fitting

low_freq_nyquist = 0.02 class-attribute instance-attribute

The PSD at f < low_freq * nyquist is assumed to be dominated by 1/f noise

high_freq_nyquist = 0.02 class-attribute instance-attribute

The PSD at f > high_freq * nyquist is assumed to be dominated by white noise

mask_hwp_harmonics = True class-attribute instance-attribute

Mask HWP harmonics: 1f, 2f, 4f

mask_ptc_harmonics = False class-attribute instance-attribute

Mask PTC harmonics: 1f, 2f

freq_mask_width = 0.5 class-attribute instance-attribute

Full width [Hz] of the frequency mask (if used) around HWP and PTC harmonics

ptc_freq = 1.4 class-attribute instance-attribute

PTC frequency [Hz] used for masking (if used)

__init__(nperseg=2048, max_iter=100, tol=1e-10, min_freq_nyquist=1e-08, max_freq_nyquist=1, low_freq_nyquist=0.02, high_freq_nyquist=0.02, mask_hwp_harmonics=True, mask_ptc_harmonics=False, freq_mask_width=0.5, ptc_freq=1.4)

WeightingConfig dataclass

Configuration for the inverse-noise / weighting matrix used in mapmaking.

mode selects the structure of the inverse-noise matrix:

  • IDENTITY: identity matrix (no weighting).
  • DIAGONAL: diagonal white-noise weighting (default).
  • TOEPLITZ: banded Toeplitz weighting for atmospheric (1/f) noise.

source selects where the noise model comes from: FIT (default) fits it from the TOD power spectral density using fitting; PRECOMPUTED reads noise parameters from the data pipeline (fitting is then ignored).

correlation_length sets the Toeplitz bandwidth (in samples) of the inverse-noise operator. It is only used in TOEPLITZ mode and is ignored otherwise.

Methods:

Attributes:

  • mode (WeightingMode) –

    Inverse-noise weighting matrix structure.

  • source (NoiseSource) –

    Where the noise model comes from: fit from the TOD PSD or read precomputed parameters.

  • correlation_length (int) –

    Toeplitz bandwidth in samples. Only relevant in TOEPLITZ mode.

  • fitting (NoiseFitConfig) –

    Options for fitting the noise PSD to the data. Ignored when source is PRECOMPUTED.

  • diagonal_matrix (bool) –

    True when the inverse-noise matrix is diagonal (identity or white).

Source code in src/furax/mapmaking/config.py
@dataclass
class WeightingConfig:
    """Configuration for the inverse-noise / weighting matrix used in mapmaking.

    ``mode`` selects the structure of the inverse-noise matrix:

    - ``IDENTITY``: identity matrix (no weighting).
    - ``DIAGONAL``: diagonal white-noise weighting (default).
    - ``TOEPLITZ``: banded Toeplitz weighting for atmospheric (1/f) noise.

    ``source`` selects where the noise model comes from: ``FIT`` (default) fits it from the
    TOD power spectral density using ``fitting``; ``PRECOMPUTED`` reads noise parameters from
    the data pipeline (``fitting`` is then ignored).

    ``correlation_length`` sets the Toeplitz bandwidth (in samples) of the inverse-noise
    operator.  It is only used in ``TOEPLITZ`` mode and is ignored otherwise.
    """

    mode: WeightingMode = WeightingMode.DIAGONAL
    """Inverse-noise weighting matrix structure."""

    source: NoiseSource = NoiseSource.FIT
    """Where the noise model comes from: fit from the TOD PSD or read precomputed parameters."""

    correlation_length: int = 1_000
    """Toeplitz bandwidth in samples.  Only relevant in ``TOEPLITZ`` mode."""

    fitting: NoiseFitConfig = field(default_factory=NoiseFitConfig)
    """Options for fitting the noise PSD to the data.  Ignored when ``source`` is PRECOMPUTED."""

    @property
    def diagonal_matrix(self) -> bool:
        """True when the inverse-noise matrix is diagonal (identity or white)."""
        return self.mode != WeightingMode.TOEPLITZ

mode = WeightingMode.DIAGONAL class-attribute instance-attribute

Inverse-noise weighting matrix structure.

source = NoiseSource.FIT class-attribute instance-attribute

Where the noise model comes from: fit from the TOD PSD or read precomputed parameters.

correlation_length = 1000 class-attribute instance-attribute

Toeplitz bandwidth in samples. Only relevant in TOEPLITZ mode.

fitting = field(default_factory=NoiseFitConfig) class-attribute instance-attribute

Options for fitting the noise PSD to the data. Ignored when source is PRECOMPUTED.

diagonal_matrix property

True when the inverse-noise matrix is diagonal (identity or white).

__init__(mode=WeightingMode.DIAGONAL, source=NoiseSource.FIT, correlation_length=1000, fitting=NoiseFitConfig())

HealpixConfig dataclass

Configuration for a HEALPix output map.

Examples:

In a YAML config file:

healpix: nside: 512

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class HealpixConfig:
    """Configuration for a HEALPix output map.

    Examples:
        In a YAML config file:

        healpix:
          nside: 512
    """

    nside: int = 512
    ordering: Literal['nest', 'ring'] = 'ring'

    def __post_init__(self) -> None:
        if self.ordering == 'nest':
            raise ValueError('NESTED ordering not supported')

nside = 512 class-attribute instance-attribute

ordering = 'ring' class-attribute instance-attribute

__init__(nside=512, ordering='ring')

__post_init__()

Source code in src/furax/mapmaking/config.py
def __post_init__(self) -> None:
    if self.ordering == 'nest':
        raise ValueError('NESTED ordering not supported')

SkyPatch dataclass

Explicit rectangular sky patch for WCS map construction.

Examples:

In a YAML config file:

patch: center: [30.0, -10.0] # ra, dec in degrees width: 20.0 height: 10.0

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class SkyPatch:
    """Explicit rectangular sky patch for WCS map construction.

    Examples:
        In a YAML config file:

        patch:
          center: [30.0, -10.0]  # ra, dec in degrees
          width: 20.0
          height: 10.0
    """

    center: tuple[float, float]
    """Center ``(ra, dec)`` in degrees."""

    width: float
    """Width in degrees."""

    height: float
    """Height in degrees."""

center instance-attribute

Center (ra, dec) in degrees.

width instance-attribute

Width in degrees.

height instance-attribute

Height in degrees.

__init__(center, width, height)

WCSConfig dataclass

Configuration for a WCS-projected output map.

projection applies to all modes except geometry_file, where it is read from the file.

The map extent is determined by exactly one of three mutually exclusive modes:

  1. geometry_file: read shape and WCS directly from a FITS/HDF file via pixell.enmap.read_map_geometry. All other fields are ignored.
  2. patch: build a rectangular patch of sky at the given resolution.
  3. auto (no geometry specified): scan the observations to compute each observation's bounding box, take their union, and pixelise at the given resolution.

Examples:

In a YAML config file:

Auto footprint at 4 arcmin resolution

car: resolution: 4.0

Explicit patch

car: resolution: 4.0 patch: center: [30.0, -10.0] width: 20.0 height: 10.0

Geometry from file

car: geometry_file: /path/to/map.fits

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class WCSConfig:
    """Configuration for a WCS-projected output map.

    ``projection`` applies to all modes except ``geometry_file``, where it is read from the file.

    The map extent is determined by exactly one of three mutually exclusive modes:

    1. **geometry_file**: read shape and WCS directly from a FITS/HDF file via
       ``pixell.enmap.read_map_geometry``. All other fields are ignored.
    2. **patch**: build a rectangular patch of sky at the given ``resolution``.
    3. **auto** (no geometry specified): scan the observations to compute each observation's
       bounding box, take their union, and pixelise at the given ``resolution``.

    Examples:
        In a YAML config file:

        # Auto footprint at 4 arcmin resolution
        car:
          resolution: 4.0

        # Explicit patch
        car:
          resolution: 4.0
          patch:
            center: [30.0, -10.0]
            width: 20.0
            height: 10.0

        # Geometry from file
        car:
          geometry_file: /path/to/map.fits
    """

    projection: ProjectionType = ProjectionType.CAR
    """WCS projection type."""

    resolution: float = 4.0
    """Pixel resolution in arcminutes."""

    geometry_file: str | None = None
    """Path to a FITS or HDF map file from which to read the output geometry."""

    patch: SkyPatch | None = None
    """Explicit sky patch definition. Mutually exclusive with ``geometry_file``."""

    def __post_init__(self) -> None:
        if self.geometry_file is not None and self.patch is not None:
            raise ValueError('geometry_file and patch are mutually exclusive.')

    @property
    def has_geometry(self) -> bool:
        return self.geometry_file is not None or self.patch is not None

projection = ProjectionType.CAR class-attribute instance-attribute

WCS projection type.

resolution = 4.0 class-attribute instance-attribute

Pixel resolution in arcminutes.

geometry_file = None class-attribute instance-attribute

Path to a FITS or HDF map file from which to read the output geometry.

patch = None class-attribute instance-attribute

Explicit sky patch definition. Mutually exclusive with geometry_file.

has_geometry property

__init__(projection=ProjectionType.CAR, resolution=4.0, geometry_file=None, patch=None)

__post_init__()

Source code in src/furax/mapmaking/config.py
def __post_init__(self) -> None:
    if self.geometry_file is not None and self.patch is not None:
        raise ValueError('geometry_file and patch are mutually exclusive.')

LandscapeConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class LandscapeConfig:
    stokes: ValidStokesType = 'IQU'
    healpix: HealpixConfig | None = None
    wcs: WCSConfig | None = None

    def __post_init__(self) -> None:
        if (self.healpix is None) == (self.wcs is None):
            raise ValueError('exactly one of healpix or wcs must be set.')

stokes = 'IQU' class-attribute instance-attribute

healpix = None class-attribute instance-attribute

wcs = None class-attribute instance-attribute

__init__(stokes='IQU', healpix=None, wcs=None)

__post_init__()

Source code in src/furax/mapmaking/config.py
def __post_init__(self) -> None:
    if (self.healpix is None) == (self.wcs is None):
        raise ValueError('exactly one of healpix or wcs must be set.')

PolynomialOrders

Bases: NamedTuple

A polynomial order range, inclusive.

Attributes:

Source code in src/furax/mapmaking/config.py
class PolynomialOrders(NamedTuple):
    """A polynomial order range, inclusive."""

    min_order: int = 0
    max_order: int = 3

    @property
    def n_orders(self) -> int:
        """Number of orders in the inclusive range."""
        return self.max_order - self.min_order + 1

min_order = 0 class-attribute instance-attribute

max_order = 3 class-attribute instance-attribute

n_orders property

Number of orders in the inclusive range.

BinsConfig dataclass

A piecewise basis that bins a variable into n_bins intervals.

With interpolate = False each sample is hard-assigned to its bin. With interpolate = True samples are spread over neighbouring bin centres using triangular (or, if smooth, sin^2) weights.

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class BinsConfig:
    """A piecewise basis that bins a variable into ``n_bins`` intervals.

    With ``interpolate = False`` each sample is hard-assigned to its bin. With
    ``interpolate = True`` samples are spread over neighbouring bin centres using
    triangular (or, if ``smooth``, sin^2) weights.
    """

    n_bins: int = 4
    interpolate: bool = False
    smooth: bool = False

n_bins = 4 class-attribute instance-attribute

interpolate = False class-attribute instance-attribute

smooth = False class-attribute instance-attribute

__init__(n_bins=4, interpolate=False, smooth=False)

PolynomialConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class PolynomialConfig:
    legendre: PolynomialOrders = PolynomialOrders(0, 3)
    """Legendre orders for the polynomial drift template."""

legendre = PolynomialOrders(0, 3) class-attribute instance-attribute

Legendre orders for the polynomial drift template.

__init__(legendre=PolynomialOrders(0, 3))

ScanSynchronousConfig dataclass

Scan-synchronous signal on a global Legendre basis.

Represents signals that depend only on the telescope's azimuth.

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class ScanSynchronousConfig:
    """Scan-synchronous signal on a global Legendre basis.

    Represents signals that depend only on the telescope's azimuth.
    """

    legendre: PolynomialOrders = PolynomialOrders(3, 7)

legendre = PolynomialOrders(3, 7) class-attribute instance-attribute

__init__(legendre=PolynomialOrders(3, 7))

BinAzSynchronousConfig dataclass

Binned azimuth-synchronous signal, no HWP coupling.

The binned counterpart of ScanSynchronousConfig.

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class BinAzSynchronousConfig:
    """Binned azimuth-synchronous signal, no HWP coupling.

    The binned counterpart of `ScanSynchronousConfig`.
    """

    bins: BinsConfig = field(default_factory=BinsConfig)

bins = field(default_factory=BinsConfig) class-attribute instance-attribute

__init__(bins=BinsConfig())

HWPSynchronousConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class HWPSynchronousConfig:
    n_harmonics: int = 3

n_harmonics = 3 class-attribute instance-attribute

__init__(n_harmonics=3)

AzHWPSynchronousConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class AzHWPSynchronousConfig:
    legendre: PolynomialOrders = PolynomialOrders(0, 3)
    n_harmonics: int = 4
    split_scans: bool = False

legendre = PolynomialOrders(0, 3) class-attribute instance-attribute

n_harmonics = 4 class-attribute instance-attribute

split_scans = False class-attribute instance-attribute

__init__(legendre=PolynomialOrders(0, 3), n_harmonics=4, split_scans=False)

BinAzHWPSynchronousConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class BinAzHWPSynchronousConfig:
    bins: BinsConfig = field(default_factory=BinsConfig)
    n_harmonics: int = 4

bins = field(default_factory=BinsConfig) class-attribute instance-attribute

n_harmonics = 4 class-attribute instance-attribute

__init__(bins=BinsConfig(), n_harmonics=4)

SplineHWPSSConfig dataclass

Methods:

Attributes:

  • n_knots (int | None) –

    Number of spline knots. If set, takes precedence over samples_per_knot.

  • samples_per_knot (int | None) –

    Number of samples per knot. Defaults to 4000.

  • harmonics (tuple[int, ...]) –

    HWP harmonics to fit with splines. Defaults to (4,).

Source code in src/furax/mapmaking/config.py
@dataclass
class SplineHWPSSConfig:
    n_knots: int | None = None
    """Number of spline knots. If set, takes precedence over samples_per_knot."""
    samples_per_knot: int | None = 4000
    """Number of samples per knot. Defaults to 4000."""
    harmonics: tuple[int, ...] = (4,)
    """HWP harmonics to fit with splines. Defaults to (4,)."""

    def __post_init__(self) -> None:
        if self.n_knots is None and self.samples_per_knot is None:
            raise ValueError("one of 'n_knots' or 'samples_per_knot' must be provided")

    def resolve_n_knots(self, n_samples: int) -> int:
        """Number of spline knots for `n_samples` samples (at least 2).

        Uses `n_knots` directly when set, otherwise derives it from `samples_per_knot`.
        """
        if self.n_knots is not None:
            return max(2, self.n_knots)
        assert self.samples_per_knot is not None  # guaranteed by __post_init__
        return max(2, n_samples // self.samples_per_knot)

n_knots = None class-attribute instance-attribute

Number of spline knots. If set, takes precedence over samples_per_knot.

samples_per_knot = 4000 class-attribute instance-attribute

Number of samples per knot. Defaults to 4000.

harmonics = (4,) class-attribute instance-attribute

HWP harmonics to fit with splines. Defaults to (4,).

__init__(n_knots=None, samples_per_knot=4000, harmonics=(4,))

__post_init__()

Source code in src/furax/mapmaking/config.py
def __post_init__(self) -> None:
    if self.n_knots is None and self.samples_per_knot is None:
        raise ValueError("one of 'n_knots' or 'samples_per_knot' must be provided")

resolve_n_knots(n_samples)

Number of spline knots for n_samples samples (at least 2).

Uses n_knots directly when set, otherwise derives it from samples_per_knot.

Source code in src/furax/mapmaking/config.py
def resolve_n_knots(self, n_samples: int) -> int:
    """Number of spline knots for `n_samples` samples (at least 2).

    Uses `n_knots` directly when set, otherwise derives it from `samples_per_knot`.
    """
    if self.n_knots is not None:
        return max(2, self.n_knots)
    assert self.samples_per_knot is not None  # guaranteed by __post_init__
    return max(2, n_samples // self.samples_per_knot)

GroundConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class GroundConfig:
    azimuth_resolution: float = 0.05  # ~3 deg
    elevation_resolution: float = 0.05  # ~3 deg

azimuth_resolution = 0.05 class-attribute instance-attribute

elevation_resolution = 0.05 class-attribute instance-attribute

__init__(azimuth_resolution=0.05, elevation_resolution=0.05)

TemplatesConfig dataclass

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class TemplatesConfig:
    polynomial: PolynomialConfig | None = None
    scan_synchronous: ScanSynchronousConfig | None = None
    binaz_synchronous: BinAzSynchronousConfig | None = None
    hwp_synchronous: HWPSynchronousConfig | None = None
    azhwp_synchronous: AzHWPSynchronousConfig | None = None
    binazhwp_synchronous: BinAzHWPSynchronousConfig | None = None
    spline_hwpss: SplineHWPSSConfig | None = None
    ground: GroundConfig | None = None
    regularization: float = 0.0

    @classmethod
    def full_defaults(cls) -> 'TemplatesConfig':
        """Create a template config with default values for all templates."""
        return cls(
            polynomial=PolynomialConfig(),
            scan_synchronous=ScanSynchronousConfig(),
            binaz_synchronous=BinAzSynchronousConfig(),
            hwp_synchronous=HWPSynchronousConfig(),
            azhwp_synchronous=AzHWPSynchronousConfig(),
            binazhwp_synchronous=BinAzHWPSynchronousConfig(),
            spline_hwpss=SplineHWPSSConfig(),
            ground=GroundConfig(),
        )

    @property
    def empty(self) -> bool:
        return all(getattr(self, f.name) is None for f in fields(self))

polynomial = None class-attribute instance-attribute

scan_synchronous = None class-attribute instance-attribute

binaz_synchronous = None class-attribute instance-attribute

hwp_synchronous = None class-attribute instance-attribute

azhwp_synchronous = None class-attribute instance-attribute

binazhwp_synchronous = None class-attribute instance-attribute

spline_hwpss = None class-attribute instance-attribute

ground = None class-attribute instance-attribute

regularization = 0.0 class-attribute instance-attribute

empty property

__init__(polynomial=None, scan_synchronous=None, binaz_synchronous=None, hwp_synchronous=None, azhwp_synchronous=None, binazhwp_synchronous=None, spline_hwpss=None, ground=None, regularization=0.0)

full_defaults() classmethod

Create a template config with default values for all templates.

Source code in src/furax/mapmaking/config.py
@classmethod
def full_defaults(cls) -> 'TemplatesConfig':
    """Create a template config with default values for all templates."""
    return cls(
        polynomial=PolynomialConfig(),
        scan_synchronous=ScanSynchronousConfig(),
        binaz_synchronous=BinAzSynchronousConfig(),
        hwp_synchronous=HWPSynchronousConfig(),
        azhwp_synchronous=AzHWPSynchronousConfig(),
        binazhwp_synchronous=BinAzHWPSynchronousConfig(),
        spline_hwpss=SplineHWPSSConfig(),
        ground=GroundConfig(),
    )

GapFillingConfig dataclass

Specific gap-filling options.

Methods:

Attributes:

  • seed (int) –

    An integer seed for the noise realization

  • max_steps (int) –

    The maximum number of iteration steps to invert the system

  • rtol (float) –

    The relative tolerance of the solver for the gap-filling solve

  • precondition (bool) –

    Precondition the flagged-subspace solve with the covariance from the noise model.

Source code in src/furax/mapmaking/config.py
@dataclass(frozen=True)
class GapFillingConfig:
    """Specific gap-filling options."""

    seed: int = 286502183
    """An integer seed for the noise realization"""

    max_steps: int = 50
    """The maximum number of iteration steps to invert the system"""

    rtol: float = 1e-4
    """The relative tolerance of the solver for the gap-filling solve"""

    precondition: bool = False
    """Precondition the flagged-subspace solve with the covariance from the noise model.

    Off by default: it speeds up convergence only for few gaps wider than the correlation length, and
    *slows* the common case of many short gaps (turnarounds/glitches) where the bare system is already
    well-conditioned. Enable for observations dominated by a few wide gaps.
    """

seed = 286502183 class-attribute instance-attribute

An integer seed for the noise realization

max_steps = 50 class-attribute instance-attribute

The maximum number of iteration steps to invert the system

rtol = 0.0001 class-attribute instance-attribute

The relative tolerance of the solver for the gap-filling solve

precondition = False class-attribute instance-attribute

Precondition the flagged-subspace solve with the covariance from the noise model.

Off by default: it speeds up convergence only for few gaps wider than the correlation length, and slows the common case of many short gaps (turnarounds/glitches) where the bare system is already well-conditioned. Enable for observations dominated by a few wide gaps.

__init__(seed=286502183, max_steps=50, rtol=0.0001, precondition=False)

NestedConfig dataclass

Inner-solver options for the nested-inverse gap weight (GapTreatment.NESTED).

Methods:

Attributes:

  • max_flag_fraction (float) –

    Flagged-fraction budget; observations flagged above this fall back to INNER_MASK.

  • inner_steps (int) –

    Fixed number of inner CG iterations for the flagged-block solve.

  • rtol (float) –

    Relative tolerance of the inner solver (0 forces exactly inner_steps iterations).

  • atol (float) –

    Absolute tolerance of the inner solver (0 forces exactly inner_steps iterations).

  • precondition (bool) –

    Precondition the inner flagged-block CG with the covariance from the noise model.

Source code in src/furax/mapmaking/config.py
@dataclass
class NestedConfig:
    """Inner-solver options for the nested-inverse gap weight (`GapTreatment.NESTED`)."""

    max_flag_fraction: float = 0.3
    """Flagged-fraction budget; observations flagged above this fall back to INNER_MASK."""

    inner_steps: int = 20
    """Fixed number of inner CG iterations for the flagged-block solve."""

    rtol: float = 0.0
    """Relative tolerance of the inner solver (0 forces exactly ``inner_steps`` iterations)."""

    atol: float = 0.0
    """Absolute tolerance of the inner solver (0 forces exactly ``inner_steps`` iterations)."""

    precondition: bool = False
    """Precondition the inner flagged-block CG with the covariance from the noise model.

    Off by default: it helps only for few gaps wider than the correlation length, and *slows* the
    common case of many short gaps (turnarounds/glitches) where the bare inner block is already
    well-conditioned. Enable for observations dominated by a few wide gaps.
    """

max_flag_fraction = 0.3 class-attribute instance-attribute

Flagged-fraction budget; observations flagged above this fall back to INNER_MASK.

inner_steps = 20 class-attribute instance-attribute

Fixed number of inner CG iterations for the flagged-block solve.

rtol = 0.0 class-attribute instance-attribute

Relative tolerance of the inner solver (0 forces exactly inner_steps iterations).

atol = 0.0 class-attribute instance-attribute

Absolute tolerance of the inner solver (0 forces exactly inner_steps iterations).

precondition = False class-attribute instance-attribute

Precondition the inner flagged-block CG with the covariance from the noise model.

Off by default: it helps only for few gaps wider than the correlation length, and slows the common case of many short gaps (turnarounds/glitches) where the bare inner block is already well-conditioned. Enable for observations dominated by a few wide gaps.

__init__(max_flag_fraction=0.3, inner_steps=20, rtol=0.0, atol=0.0, precondition=False)

GapsConfig dataclass

Configuration options related to the treatment of gaps.

Methods:

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class GapsConfig:
    """Configuration options related to the treatment of gaps."""

    treatment: GapTreatment = GapTreatment.FILL
    """How flagged samples enter the weighting (see [`GapTreatment`][])."""

    fill_options: GapFillingConfig = field(default_factory=GapFillingConfig)
    """Options to pass to the gap-filling operator (used when ``treatment`` is ``FILL``)."""

    nested: NestedConfig = field(default_factory=NestedConfig)
    """Inner-solver options (used when ``treatment`` is ``NESTED``)."""

treatment = GapTreatment.FILL class-attribute instance-attribute

How flagged samples enter the weighting (see GapTreatment).

fill_options = field(default_factory=GapFillingConfig) class-attribute instance-attribute

Options to pass to the gap-filling operator (used when treatment is FILL).

nested = field(default_factory=NestedConfig) class-attribute instance-attribute

Inner-solver options (used when treatment is NESTED).

__init__(treatment=GapTreatment.FILL, fill_options=GapFillingConfig(), nested=NestedConfig())

PointingConfig dataclass

Configuration options for pointing computation.

interpolation controls how the sky map is sampled:

  • 'nearest': nearest-neighbor (default, fastest).
  • 'bilinear': bilinear interpolation using the four nearest pixels.

Methods:

Attributes:

  • on_the_fly (bool) –

    Compute pointing on the fly instead of pre-computing pixel indices.

  • batch_size (int) –

    Detector batch size for on-the-fly pointing (set to 0 to use a full batch).

  • interpolation (Literal['nearest', 'bilinear']) –

    Pixel interpolation scheme used when sampling the sky map.

Source code in src/furax/mapmaking/config.py
@dataclass
class PointingConfig:
    """Configuration options for pointing computation.

    ``interpolation`` controls how the sky map is sampled:

    - ``'nearest'``: nearest-neighbor (default, fastest).
    - ``'bilinear'``: bilinear interpolation using the four nearest pixels.
    """

    on_the_fly: bool = True
    """Compute pointing on the fly instead of pre-computing pixel indices."""

    batch_size: int = 32
    """Detector batch size for on-the-fly pointing (set to 0 to use a full batch)."""

    interpolation: Literal['nearest', 'bilinear'] = 'nearest'
    """Pixel interpolation scheme used when sampling the sky map."""

on_the_fly = True class-attribute instance-attribute

Compute pointing on the fly instead of pre-computing pixel indices.

batch_size = 32 class-attribute instance-attribute

Detector batch size for on-the-fly pointing (set to 0 to use a full batch).

interpolation = 'nearest' class-attribute instance-attribute

Pixel interpolation scheme used when sampling the sky map.

__init__(on_the_fly=True, batch_size=32, interpolation='nearest')

SotodlibConfig dataclass

Configuration options specific to the sotodlib interface.

Methods:

Attributes:

  • site (Literal['so', 'so_sat1', 'so_sat2', 'so_sat3', 'so_lat']) –

    Observatory site identifier

  • weather (Literal['toco', 'typical']) –

    Atmospheric condition tag for so3g sightline model

  • demodulated (bool) –

    Use demodulated TODs (HWP-specific data from sotodlib preprocessing).

  • wobble_correction (bool) –

    Apply HWP wobble correction to the line of sight.

  • noise_source (Literal['preprocess', 'mapmaking']) –

    Which precomputed noise model to use when fit_noise_model is False.

Source code in src/furax/mapmaking/config.py
@dataclass
class SotodlibConfig:
    """Configuration options specific to the sotodlib interface."""

    # see https://github.com/simonsobs/so3g/blob/master/python/proj/coords.py#L45
    site: Literal['so', 'so_sat1', 'so_sat2', 'so_sat3', 'so_lat'] = 'so'
    """Observatory site identifier"""

    weather: Literal['toco', 'typical'] = 'toco'
    """Atmospheric condition tag for so3g sightline model"""

    demodulated: bool = False
    """Use demodulated TODs (HWP-specific data from sotodlib preprocessing)."""

    wobble_correction: bool = False
    """Apply HWP wobble correction to the line of sight."""

    noise_source: Literal['preprocess', 'mapmaking'] = 'preprocess'
    """Which precomputed noise model to use when fit_noise_model is False.

    'preprocess': use per-stoke noise fits (noiseT, noiseQ, noiseU) from preprocessing.
    'mapmaking': use the white noise estimate from noiseQ_mapmaking.
    """

site = 'so' class-attribute instance-attribute

Observatory site identifier

weather = 'toco' class-attribute instance-attribute

Atmospheric condition tag for so3g sightline model

demodulated = False class-attribute instance-attribute

Use demodulated TODs (HWP-specific data from sotodlib preprocessing).

wobble_correction = False class-attribute instance-attribute

Apply HWP wobble correction to the line of sight.

noise_source = 'preprocess' class-attribute instance-attribute

Which precomputed noise model to use when fit_noise_model is False.

'preprocess': use per-stoke noise fits (noiseT, noiseQ, noiseU) from preprocessing. 'mapmaking': use the white noise estimate from noiseQ_mapmaking.

__init__(site='so', weather='toco', demodulated=False, wobble_correction=False, noise_source='preprocess')

MapMakingConfig dataclass

Methods:

  • __init__
  • for_method

    Return a default MapMakingConfig pre-configured for the given method.

  • full_defaults

    Create a config with default values for all fields including optional ones.

  • load_yaml

    Load and instantiate a MapMakingConfig from a YAML file.

  • load_dict

    Load and instantiate a MapMakingConfig from a dictionary.

  • dump_yaml

    Dump the config to a YAML file.

Attributes:

Source code in src/furax/mapmaking/config.py
@dataclass
class MapMakingConfig:
    method: Methods = Methods.BINNED
    scanning_mask: bool = False
    sample_mask: bool = False
    hits_cut: float = 1e-2
    cond_cut: float = 1e-2
    double_precision: bool = True
    pointing: PointingConfig = field(default_factory=PointingConfig)
    weighting: WeightingConfig = field(default_factory=WeightingConfig)
    debug: bool = True
    solver: SolverConfig = field(default_factory=SolverConfig)
    gaps: GapsConfig = field(default_factory=GapsConfig)
    landscape: LandscapeConfig = field(
        default_factory=lambda: LandscapeConfig(healpix=HealpixConfig())
    )
    templates: TemplatesConfig | None = None
    atop_tau: int = 0
    sotodlib: SotodlibConfig | None = None

    @classmethod
    def for_method(cls, method: 'Methods | str') -> 'MapMakingConfig':
        """Return a default MapMakingConfig pre-configured for the given method.

        Args:
            method: A ``Methods`` enum value or its string name (e.g. ``'binned'``,
                ``'ml'``, ``'twostep'``, ``'atop'``), case-insensitive.
        """
        if isinstance(method, str):
            upper = method.upper()
            try:
                method = Methods[upper]
            except KeyError:
                # Fall back to matching by value (e.g. 'ML' → Methods.MAXL)
                matched = next((m for m in Methods if m.value.upper() == upper), None)
                if matched is None:
                    raise ValueError(f'Unknown method: {method!r}') from None
                method = matched

        if method == Methods.BINNED:
            return cls(
                method=Methods.BINNED,
                weighting=WeightingConfig(),
                templates=None,
            )
        elif method == Methods.MAXL:
            return cls(
                method=Methods.MAXL,
                weighting=WeightingConfig(
                    mode=WeightingMode.TOEPLITZ,
                    correlation_length=1_000,
                ),
                solver=SolverConfig(
                    rtol=1e-6,
                    atol=0,
                    max_steps=1_000,
                ),
                templates=None,
            )
        elif method == Methods.TWOSTEP:
            return cls(
                method=Methods.TWOSTEP,
                weighting=WeightingConfig(),
                solver=SolverConfig(
                    rtol=1e-6,
                    atol=0,
                    max_steps=1_000,
                ),
                templates=TemplatesConfig(
                    polynomial=PolynomialConfig(),
                ),
            )
        elif method == Methods.ATOP:
            return cls(
                method=Methods.ATOP,
                weighting=WeightingConfig(),
                solver=SolverConfig(
                    rtol=1e-6,
                    atol=0,
                    max_steps=100,
                ),
                atop_tau=37,
                templates=None,
            )
        else:
            raise ValueError(f'Unknown method: {method}')

    @classmethod
    def full_defaults(cls) -> 'MapMakingConfig':
        """Create a config with default values for all fields including optional ones."""
        return cls(templates=TemplatesConfig.full_defaults())

    @classmethod
    def load_yaml(cls, path: str | Path) -> 'MapMakingConfig':
        """Load and instantiate a ``MapMakingConfig`` from a YAML file."""
        data = yaml.safe_load(Path(path).read_text())
        return cls.load_dict(data)

    @classmethod
    def load_dict(cls, data: dict[str, Any]) -> 'MapMakingConfig':
        """Load and instantiate a ``MapMakingConfig`` from a dictionary."""
        return deserialize(MapMakingConfig, data)

    def dump_yaml(self, path: str | Path) -> None:
        """Dump the config to a YAML file.

        The '.yaml' suffix is automatically added if not already present.
        """
        filename = Path(path).with_suffix('.yaml')
        filename.parent.mkdir(parents=True, exist_ok=True)
        filename.write_text(self._to_yaml())

    def _to_yaml(self) -> str:
        """Serialize the config to a YAML string."""
        data = serialize(MapMakingConfig, self)
        return yaml.dump(data, indent=2)

    @property
    def binned(self) -> bool:
        return self.weighting.diagonal_matrix

    @property
    def demodulated(self) -> bool:
        return self.sotodlib.demodulated if self.sotodlib is not None else False

    @property
    def use_templates(self) -> bool:
        return (self.templates is not None) and (not self.templates.empty)

    @property
    def dtype(self) -> DTypeLike:
        return jnp.float64 if self.double_precision else jnp.float32  # type: ignore[no-any-return]

method = Methods.BINNED class-attribute instance-attribute

scanning_mask = False class-attribute instance-attribute

sample_mask = False class-attribute instance-attribute

hits_cut = 0.01 class-attribute instance-attribute

cond_cut = 0.01 class-attribute instance-attribute

double_precision = True class-attribute instance-attribute

pointing = field(default_factory=PointingConfig) class-attribute instance-attribute

weighting = field(default_factory=WeightingConfig) class-attribute instance-attribute

debug = True class-attribute instance-attribute

solver = field(default_factory=SolverConfig) class-attribute instance-attribute

gaps = field(default_factory=GapsConfig) class-attribute instance-attribute

landscape = field(default_factory=(lambda: LandscapeConfig(healpix=(HealpixConfig())))) class-attribute instance-attribute

templates = None class-attribute instance-attribute

atop_tau = 0 class-attribute instance-attribute

sotodlib = None class-attribute instance-attribute

binned property

demodulated property

use_templates property

dtype property

__init__(method=Methods.BINNED, scanning_mask=False, sample_mask=False, hits_cut=0.01, cond_cut=0.01, double_precision=True, pointing=PointingConfig(), weighting=WeightingConfig(), debug=True, solver=SolverConfig(), gaps=GapsConfig(), landscape=(lambda: LandscapeConfig(healpix=(HealpixConfig())))(), templates=None, atop_tau=0, sotodlib=None)

for_method(method) classmethod

Return a default MapMakingConfig pre-configured for the given method.

Parameters:

  • method (Methods | str) –

    A Methods enum value or its string name (e.g. 'binned', 'ml', 'twostep', 'atop'), case-insensitive.

Source code in src/furax/mapmaking/config.py
@classmethod
def for_method(cls, method: 'Methods | str') -> 'MapMakingConfig':
    """Return a default MapMakingConfig pre-configured for the given method.

    Args:
        method: A ``Methods`` enum value or its string name (e.g. ``'binned'``,
            ``'ml'``, ``'twostep'``, ``'atop'``), case-insensitive.
    """
    if isinstance(method, str):
        upper = method.upper()
        try:
            method = Methods[upper]
        except KeyError:
            # Fall back to matching by value (e.g. 'ML' → Methods.MAXL)
            matched = next((m for m in Methods if m.value.upper() == upper), None)
            if matched is None:
                raise ValueError(f'Unknown method: {method!r}') from None
            method = matched

    if method == Methods.BINNED:
        return cls(
            method=Methods.BINNED,
            weighting=WeightingConfig(),
            templates=None,
        )
    elif method == Methods.MAXL:
        return cls(
            method=Methods.MAXL,
            weighting=WeightingConfig(
                mode=WeightingMode.TOEPLITZ,
                correlation_length=1_000,
            ),
            solver=SolverConfig(
                rtol=1e-6,
                atol=0,
                max_steps=1_000,
            ),
            templates=None,
        )
    elif method == Methods.TWOSTEP:
        return cls(
            method=Methods.TWOSTEP,
            weighting=WeightingConfig(),
            solver=SolverConfig(
                rtol=1e-6,
                atol=0,
                max_steps=1_000,
            ),
            templates=TemplatesConfig(
                polynomial=PolynomialConfig(),
            ),
        )
    elif method == Methods.ATOP:
        return cls(
            method=Methods.ATOP,
            weighting=WeightingConfig(),
            solver=SolverConfig(
                rtol=1e-6,
                atol=0,
                max_steps=100,
            ),
            atop_tau=37,
            templates=None,
        )
    else:
        raise ValueError(f'Unknown method: {method}')

full_defaults() classmethod

Create a config with default values for all fields including optional ones.

Source code in src/furax/mapmaking/config.py
@classmethod
def full_defaults(cls) -> 'MapMakingConfig':
    """Create a config with default values for all fields including optional ones."""
    return cls(templates=TemplatesConfig.full_defaults())

load_yaml(path) classmethod

Load and instantiate a MapMakingConfig from a YAML file.

Source code in src/furax/mapmaking/config.py
@classmethod
def load_yaml(cls, path: str | Path) -> 'MapMakingConfig':
    """Load and instantiate a ``MapMakingConfig`` from a YAML file."""
    data = yaml.safe_load(Path(path).read_text())
    return cls.load_dict(data)

load_dict(data) classmethod

Load and instantiate a MapMakingConfig from a dictionary.

Source code in src/furax/mapmaking/config.py
@classmethod
def load_dict(cls, data: dict[str, Any]) -> 'MapMakingConfig':
    """Load and instantiate a ``MapMakingConfig`` from a dictionary."""
    return deserialize(MapMakingConfig, data)

dump_yaml(path)

Dump the config to a YAML file.

The '.yaml' suffix is automatically added if not already present.

Source code in src/furax/mapmaking/config.py
def dump_yaml(self, path: str | Path) -> None:
    """Dump the config to a YAML file.

    The '.yaml' suffix is automatically added if not already present.
    """
    filename = Path(path).with_suffix('.yaml')
    filename.parent.mkdir(parents=True, exist_ok=True)
    filename.write_text(self._to_yaml())

furax.mapmaking.gap_filling

Functions:

  • gap_fill

    Fill flagged time samples with a constrained noise realization, leaving good samples unchanged.

  • generate_noise_realization

    Generates a Gaussian noise realization with the given covariance.

gap_fill(key, x, ninv, mask, *, rate=1.0, max_cg_steps=50, rtol=0.0001, atol=0.0, preconditioner=None, metadata=None)

Fill flagged time samples with a constrained noise realization, leaving good samples unchanged.

Replaces each flagged sample with a draw from the noise model conditioned on the surrounding good data (a constrained realization), so the gap is filled consistently with the noise correlations rather than by interpolation. The fill is a free realization \(\xi\) plus a correction that pins the good samples back to their data values:

\[ x_{\mathrm{fill}} = \xi + d, \quad d_{\mathrm{good}} = x_{\mathrm{good}} - \xi_{\mathrm{good}}, \quad (M_b\, N^{-1} M_b)\, d_{\mathrm{bad}} = -M_b\, N^{-1} d_{\mathrm{good}}, \]

where \(M_b\) masks the flagged samples. Three things to note:

  • Good samples are returned bit-exact; only the gaps change.
  • The CG solve acts only on the flagged subspace.
  • \(\xi\) is drawn from \(N\), obtained as the reciprocal of the inverse-noise PSD.

For a few gaps wider than the correlation length the bare \(M_b N^{-1} M_b\) system is ill-conditioned; passing the flagged-block covariance \(M_b N M_b\) as preconditioner, i.e. mask.complement() @ cov @ mask.complement() with cov a cheap approximation of \(N\) (its banded/Fourier form), then helps a lot. Its product with the system operator differs from the identity by a boundary term of rank \(\approx 2\times\) (correlation bandwidth) \(\times\) (number of gaps), and CG converges in roughly that many steps.

This is only worthwhile when that rank is below the iteration count of the bare system. For the common case of many short gaps (turnarounds, glitches) the gaps are shorter than the correlation length, the bare flagged block is already well-conditioned, and preconditioning is a net loss -- leave preconditioner=None there. The preconditioner must be symmetric positive definite and live in the flagged subspace (zero on the good samples).

Parameters:

  • key (Key[Array, '']) –

    A PRNG key generated by the user.

  • x (PyTree[Float[Array, ' *shape']]) –

    The time-ordered vector to be filled.

  • ninv (AbstractLinearOperator) –

    The inverse-noise operator \(N^{-1}\).

  • mask (MaskOperator) –

    The good-sample mask \(M\); its zeros are the gaps.

  • rate (ArrayLike, default: 1.0 ) –

    The sampling rate of the data.

  • max_cg_steps (int, default: 50 ) –

    Maximum number of CG steps for the flagged-subspace solve.

  • rtol (float, default: 0.0001 ) –

    Relative convergence tolerance for the solver.

  • atol (float, default: 0.0 ) –

    Absolute convergence tolerance for the solver.

  • preconditioner (AbstractLinearOperator | None, default: None ) –

    Optional SPD preconditioner for the flagged-subspace CG (see above).

  • metadata (HashedObservationMetadata | None, default: None ) –

    Metadata folded into the random key for reproducibility.

Returns:

  • PyTree[Float[Array, ' *shape']]

    x with its flagged samples replaced by the constrained realization.

Examples:

Gap-filling a single-detector timestream

>>> key = jax.random.key(0)
>>> ndet, nsamples = 3, 10
>>> x = jax.random.normal(key, (ndet, nsamples))
>>> in_structure = jax.ShapeDtypeStruct(x.shape, x.dtype)
>>> good = jnp.broadcast_to(
...     jnp.array([1, 1, 1, 0, 0, 1, 1, 1, 1, 1], dtype=bool), x.shape
... )
>>> mask = MaskOperator.from_boolean_mask(good, in_structure=in_structure)
>>> ninv = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)
>>> cov = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)  # N ≈ cov
>>> preconditioner = (mask.complement() @ cov @ mask.complement()).reduce()
>>> filled = gap_fill(key, x, ninv, mask, preconditioner=preconditioner)
>>> assert jnp.all(filled[good] == x[good])
Source code in src/furax/mapmaking/gap_filling.py
def gap_fill(
    key: Key[Array, ''],
    x: PyTree[Float[Array, ' *shape']],
    ninv: AbstractLinearOperator,
    mask: MaskOperator,
    *,
    rate: ArrayLike = 1.0,
    max_cg_steps: int = 50,
    rtol: float = 1e-4,
    atol: float = 0.0,
    preconditioner: AbstractLinearOperator | None = None,
    metadata: HashedObservationMetadata | None = None,
) -> PyTree[Float[Array, ' *shape']]:
    r"""Fill flagged time samples with a constrained noise realization, leaving good samples unchanged.

    Replaces each flagged sample with a draw from the noise model conditioned on the surrounding
    good data (a *constrained realization*), so the gap is filled consistently with the noise
    correlations rather than by interpolation. The fill is a free realization $\xi$ plus a
    correction that pins the good samples back to their data values:

    $$
    x_{\mathrm{fill}} = \xi + d, \quad
    d_{\mathrm{good}} = x_{\mathrm{good}} - \xi_{\mathrm{good}}, \quad
    (M_b\, N^{-1} M_b)\, d_{\mathrm{bad}} = -M_b\, N^{-1} d_{\mathrm{good}},
    $$

    where $M_b$ masks the flagged samples. Three things to note:

    - Good samples are returned bit-exact; only the gaps change.
    - The CG solve acts only on the flagged subspace.
    - $\xi$ is drawn from $N$, obtained as the reciprocal of the inverse-noise PSD.

    For a few gaps wider than the correlation length the bare $M_b N^{-1} M_b$ system is
    ill-conditioned; passing the flagged-block covariance $M_b N M_b$ as ``preconditioner``,
    i.e. ``mask.complement() @ cov @ mask.complement()`` with ``cov`` a cheap approximation of
    $N$ (its banded/Fourier form), then helps a lot. Its product with the system operator
    differs from the identity by a boundary term of rank $\approx 2\times$ (correlation
    bandwidth) $\times$ (number of gaps), and CG converges in roughly that many steps.

    This is only worthwhile when that rank is *below* the iteration count of the bare system. For
    the common case of many short gaps (turnarounds, glitches) the gaps are shorter than the
    correlation length, the bare flagged block is already well-conditioned, and preconditioning is a
    net loss -- leave ``preconditioner=None`` there. The preconditioner must be symmetric positive
    definite and live in the flagged subspace (zero on the good samples).

    Args:
        key: A PRNG key generated by the user.
        x: The time-ordered vector to be filled.
        ninv: The inverse-noise operator $N^{-1}$.
        mask: The good-sample mask $M$; its zeros are the gaps.
        rate: The sampling rate of the data.
        max_cg_steps: Maximum number of CG steps for the flagged-subspace solve.
        rtol: Relative convergence tolerance for the solver.
        atol: Absolute convergence tolerance for the solver.
        preconditioner: Optional SPD preconditioner for the flagged-subspace CG (see above).
        metadata: Metadata folded into the random key for reproducibility.

    Returns:
        ``x`` with its flagged samples replaced by the constrained realization.

    Examples:
        Gap-filling a single-detector timestream

        >>> key = jax.random.key(0)
        >>> ndet, nsamples = 3, 10
        >>> x = jax.random.normal(key, (ndet, nsamples))
        >>> in_structure = jax.ShapeDtypeStruct(x.shape, x.dtype)
        >>> good = jnp.broadcast_to(
        ...     jnp.array([1, 1, 1, 0, 0, 1, 1, 1, 1, 1], dtype=bool), x.shape
        ... )
        >>> mask = MaskOperator.from_boolean_mask(good, in_structure=in_structure)
        >>> ninv = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)
        >>> cov = SymmetricBandToeplitzOperator(jnp.array([1.0]), in_structure=in_structure)  # N ≈ cov
        >>> preconditioner = (mask.complement() @ cov @ mask.complement()).reduce()
        >>> filled = gap_fill(key, x, ninv, mask, preconditioner=preconditioner)
        >>> assert jnp.all(filled[good] == x[good])
    """
    m = mask
    m_bad = m.complement()  # masks the flagged samples (gaps)
    # ξ ~ N, drawn from the reciprocal PSD of N⁻¹.
    xi = generate_noise_realization(key, ninv, rate, metadata=metadata, inverse=True)
    dg = m(tree.sub(x, xi))  # deviation on good samples, zero on the gaps
    # M_b N⁻¹ M_b is singular (zero on good) but SPD on the gaps; CG from zero stays on the gaps.
    a = m_bad @ ninv @ m_bad
    b = tree.mul(-1.0, m_bad(ninv(dg)))
    d_bad = cg(
        a,
        b,
        preconditioner=preconditioner,
        max_steps=max_cg_steps,
        rtol=rtol,
        atol=atol,
    ).solution
    # Keep good samples bit-exact; fill the gaps with ξ + d_bad
    return tree.add(m(x), m_bad(tree.add(xi, d_bad)))

generate_noise_realization(key, cov, fsamp, metadata=None, *, inverse=False)

Generates a Gaussian noise realization with the given covariance.

Parameters:

  • key (Key[Array, '']) –

    A PRNG key generated by the user.

  • cov (FourierOperator | SymmetricBandToeplitzOperator | BlockDiagonalOperator) –

    The noise covariance operator (N), or -- with inverse=True -- the inverse-noise operator (N⁻¹), in which case the realization is drawn from the reciprocal PSD. The operator's structure is the output structure of this function.

  • metadata (HashedObservationMetadata | None, default: None ) –

    Metadata that are folded into the random key.

  • fsamp (ArrayLike) –

    The sampling frequency of the data (recommended for correct normalization).

  • inverse (bool, default: False ) –

    If True, cov is N⁻¹ and the PSD is reciprocated before sampling.

Source code in src/furax/mapmaking/gap_filling.py
@partial(jax.jit, static_argnames='inverse')
def generate_noise_realization(
    key: Key[Array, ''],
    cov: FourierOperator | SymmetricBandToeplitzOperator | BlockDiagonalOperator,
    fsamp: ArrayLike,
    metadata: HashedObservationMetadata | None = None,
    *,
    inverse: bool = False,
) -> PyTree[Float[Array, ' *shape']]:
    """Generates a Gaussian noise realization with the given covariance.

    Args:
        key: A PRNG key generated by the user.
        cov: The noise covariance operator (`N`), or -- with ``inverse=True`` -- the inverse-noise
            operator (`N⁻¹`), in which case the realization is drawn from the reciprocal PSD. The
            operator's structure is the output structure of this function.
        metadata: Metadata that are folded into the random key.
        fsamp: The sampling frequency of the data (recommended for correct normalization).
        inverse: If ``True``, ``cov`` is `N⁻¹` and the PSD is reciprocated before sampling.
    """
    # Pytree TODs: a block-diagonal operator gives one realization per block (fresh key each),
    # returned as a pytree matching its block structure. A bare operator gives a single leaf.
    if isinstance(cov, BlockDiagonalOperator):
        leaves, treedef = jax.tree.flatten(cov.blocks, is_leaf=_is_operator)
        keys = jax.tree.unflatten(treedef, list(jax.random.split(key, len(leaves))))
        return jax.tree.map(
            lambda blk, k: _draw_block(k, blk, fsamp, metadata, inverse=inverse),
            cov.blocks,
            keys,
            is_leaf=_is_operator,
        )

    return _draw_block(key, cov, fsamp, metadata, inverse=inverse)

furax.mapmaking.mapmaker

Classes:

  • MultiObservationMapMaker

    Class for mapping multiple observations together.

  • MapMaker

    Class for generic mapmakers which consume GroundObservationData.

  • BinnedMapMaker

    Class for mapmaking with diagonal noise covariance.

  • MLMapmaker

    Class for mapmaking with maximum likelihood (ML) estimator.

  • TwoStepMapmaker

    Class for binned mapmaking with templates, using the two-step estimation method.

  • ATOPMapMaker

    Class for ATOP mapmaking with diagonal noise covariance.

  • IQUModulationOperator

    Add the input Stokes signals into a single HWP-modulated signal.

  • QUModulationOperator

    Add the input Stokes signals into a single HWP-modulated signal.

Functions:

Attributes:

  • T

T = TypeVar('T') module-attribute

MultiObservationMapMaker

Bases: Generic[T]

Class for mapping multiple observations together.

Methods:

Attributes:

Source code in src/furax/mapmaking/mapmaker.py
 85
 86
 87
 88
 89
 90
 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
class MultiObservationMapMaker(Generic[T]):
    """Class for mapping multiple observations together."""

    def __init__(
        self,
        observations: Sequence[AbstractLazyObservation[T]],
        config: MapMakingConfig | None = None,
        logger: Logger | None = None,
    ) -> None:
        self.observations = observations
        self.config = config or MapMakingConfig()  # use defaults if not provided
        self.logger = logger or furax_logger
        self._check_config()
        self.landscape = (
            _static_landscape(self.config.landscape, self.config.dtype)
            or self._scan_wcs_footprint()
        )
        # Build the reader once, up front: it only probes shapes (no mesh context needed)
        rhs_fields = {ReaderField.METADATA, ReaderField.SAMPLE_DATA}
        model_fields = ObservationModel.required_reader_fields(self.config)
        self.reader = self.get_reader(model_fields | rhs_fields)

    def _check_config(self) -> None:
        """Validate and adjust config for method-specific compatibility."""
        if self.config.method == Methods.ATOP:
            if not self.config.binned:
                raise ValueError('ATOP requires diagonal weighting (weighting.mode=DIAGONAL).')
            if 'I' in (stokes := self.config.landscape.stokes):
                if stokes != 'IQU':
                    raise ValueError(
                        f'ATOP does not support intensity map reconstruction and {stokes=!r}'
                        " cannot be reduced to a supported type. Use stokes='QU' instead."
                    )
                self.logger.info(
                    "Received stokes='IQU', but ATOP does not support intensity map reconstruction."
                    " Falling back to stokes='QU' instead."
                )
                self.config.landscape.stokes = 'QU'

    @cached_property
    def mesh(self) -> Mesh:
        return jax.make_mesh((jax.device_count(),), ('obs',))

    @property
    def sharding(self) -> NamedSharding:
        return NamedSharding(self.mesh, P('obs'))

    def distribute(self, x: PyTree) -> PyTree:
        """Shard a pytree of process-local arrays along the leading 'obs' axis."""
        return jax.tree.map(lambda a: jax.make_array_from_process_local_data(self.sharding, a), x)

    @property
    def n_observations(self) -> int:
        """Total number of observations across all processes."""
        return len(self.observations)

    @property
    def obs_distribution(self) -> tuple[int, int, int]:
        """``(start, n_owned, n_pad)`` for this process."""
        return get_obs_distribution_to_process(self.n_observations)

    def get_padded_read_indices(self) -> np.ndarray:
        start, n_owned, n_pad = self.obs_distribution
        indices = np.arange(start, start + n_owned)
        return np.pad(indices, (0, n_pad), mode='edge')

    def get_reader(self, required_fields: Collection[str]) -> ObservationReader[T]:
        """Build an ObservationReader for this process's local observations."""
        # Pass padded indices: process_allgather inside from_observations needs every
        # rank to send the same shape, so all ranks must report the same obs count.
        return ObservationReader.from_observations(
            self.observations,
            read_indices=tuple(self.get_padded_read_indices()),
            requested_fields=required_fields,
            demodulated=self.config.demodulated,
            stokes=self.config.landscape.stokes,
            dtype=self.config.dtype,
        )

    def run(self, out_dir: str | Path | None = None) -> MapMakingResults:
        """Runs the mapmaker and return results after saving them to the given directory."""
        results = self.make_maps()

        # Save outputs on process 0 only (all processes hold the same replicated result)
        if out_dir is not None and jax.process_index() == 0:
            out_dir = Path(out_dir).resolve()
            results.save(out_dir)
            self.logger.info(f'saved results to {out_dir}')
            self.config.dump_yaml(out_dir / 'mapmaking_config.yaml')
            self.logger.info('saved mapmaking configuration to file')

        # Barrier so other ranks don't race ahead while rank 0 is still writing.
        mhu.sync_global_devices('mapmaker.run.save_done')

        return results

    def make_maps(self) -> MapMakingResults:
        """Computes the mapmaker results (maps and other products)."""
        logger_info = lambda msg: self.logger.info(f'MultiObsMapMaker: {msg}')

        n_processes = jax.process_count()
        rank = jax.process_index()
        n_local_devices = jax.local_device_count()
        n_devices = jax.device_count()

        # Information about how observations are distributed among processes
        start, n_owned, n_pad = self.obs_distribution
        n_per_proc = n_owned + n_pad
        n_per_dev = n_per_proc // n_local_devices
        n_slots_global = n_per_proc * n_processes

        # Every slot (real or padding) is padded to the same reader.out_structure
        per_slot_bytes = furax.tree.nbytes(self.reader.out_structure)
        global_bytes = per_slot_bytes * n_slots_global

        # The true, total data size (before observations are padded to a common structure)
        real_bytes = self.reader.total_nbytes

        slot_overhead = (n_slots_global - self.n_observations) / self.n_observations
        byte_overhead = (global_bytes - real_bytes) / real_bytes
        logger_info(
            f'layout procs={n_processes} dev_per_proc={n_local_devices} dev_total={n_devices}'
        )
        logger_info(
            f'dataset obs={self.n_observations} slots={n_slots_global} slot_overhead=+{slot_overhead:.1%} '
            f'slots_per_proc={n_per_proc} slots_per_dev={n_per_dev} slot_size={_format_bytes(per_slot_bytes)}'
        )
        logger_info(
            f'dataset real={_format_bytes(real_bytes)} global={_format_bytes(global_bytes)} '
            f'byte_overhead=+{byte_overhead:.1%}'
        )

        rank_pad = n_pad / n_per_proc
        logger_info(
            f'rank={rank} obs={start}:{start + n_owned} real={n_owned} pad={n_pad} '
            f'pad_pct={rank_pad:.1%} real_size={_format_bytes(per_slot_bytes * n_owned)} '
            f'pad_size={_format_bytes(per_slot_bytes * n_pad)}'
        )

        with jax.set_mesh(self.mesh):
            # Single read pass (sharded over observations): build the model and accumulate the
            # hit map + RHS together, so each observation is read/preprocessed exactly once. The
            # cross-process reduction of hits/rhs happens via psum inside the kernel.
            model, hits, rhs = self.build_model_and_accumulate()
            jax.block_until_ready((hits, rhs))
            logger_info('Accumulated hit map and RHS vector')

            failed_observations = self._collect_failed_observations()
            if failed_observations:
                logger_info(f'{len(failed_observations)} observation(s) failed and were excluded')

            # System operator (full/diagonal)
            A = self.get_system_operator(model)
            diag_A = A if self.config.binned else self.get_system_operator(model, diag=True)
            BJ = BJPreconditioner.create(diag_A)
            icov = BJ.blocks.block_until_ready()
            logger_info('Computed white noise inverse covariance')

            valid_pixels = self.pixel_selection(hits, icov)
            # Select valid pixels on the (trailing) sky axes, leaving the leading Stokes axis intact.
            selector = IndexOperator((..., *jnp.where(valid_pixels)), in_structure=A.out_structure)
            n_selected = jnp.sum(valid_pixels)
            n_observed = jnp.sum(hits > 0)
            n_total = valid_pixels.size
            logger_info(f'Selected {n_selected} pixels ({n_observed} seen, {n_total} total)')

            hits = hits.at[~valid_pixels].set(0)  # excluded pixels have zero hits
            icov = jnp.moveaxis(icov, [-2, -1], [0, 1])  # (*pixels, ns, ns) → (ns, ns, *pixels)

            # Solve the mapmaking system
            solver = lineax.CG(**asdict(self.config.solver))
            spd = OperatorTag.POSITIVE_SEMIDEFINITE
            lx_system = as_lineax_operator(selector @ A @ selector.T, spd)
            M = (selector @ BJ.I @ selector.T).reduce()  # preconditioner
            lx_precond = as_lineax_operator(M, spd)
            rhs_reduced = selector(rhs)
            y0 = M(rhs_reduced)

            solution = lineax.linear_solve(
                lx_system,
                rhs_reduced,
                solver=solver,
                options={'preconditioner': lx_precond, 'y0': y0},
                throw=False,
            )
            estimate = selector.T(solution.value)
            num_steps = solution.stats['num_steps']
            logger_info(f'Finished mapmaking (iteration steps: {num_steps})')

        return MapMakingResults(
            map=estimate,
            icov=icov,
            hit_map=hits,
            solver_stats=solution.stats,
            landscape=self.landscape,
            failed_observations=failed_observations,
        )

    def _collect_failed_observations(self) -> list[str]:
        """Names of observations that failed to load, gathered across all processes.

        Each process records the local indices it could not read (``reader.failed_indices``); a
        boolean mask over all observations is all-gathered and OR-reduced so every process reports
        the same global set.
        """
        local = np.zeros(self.n_observations, dtype=bool)
        local[sorted(self.reader.failed_indices)] = True
        gathered = np.asarray(mhu.process_allgather(local)).reshape(-1, self.n_observations)
        failed = np.flatnonzero(gathered.any(axis=0))
        return [self.observations[int(i)].name for i in failed]

    def build_model_and_accumulate(
        self,
    ) -> tuple[ObservationModel, Int64[Array, '...'], StokesType]:
        """Build the model and accumulate the hit map and RHS in a single, sharded read pass.

        Sharded over observations: each observation is read (and, for preproc-backed observations,
        preprocessed) exactly once, and contributes to the model, the hit map and the RHS from that
        single read. This avoids reading every observation twice (once to build the model, once to
        accumulate the RHS). The hit map and RHS are reduced across all processes with ``psum``
        inside the kernel.

        Observations that contribute nothing -- padding (added so every device carries the same
        workload) and failed loads (the preprocessing pipeline raised) -- are gated out by zeroing
        their masker inside the kernel, so they drop out of the hit map, the RHS, and the CG system
        operator alike. Failed loads are reported afterwards from ``self.reader.failed_indices``
        (see ``make_maps``).

        Must run under ``jax.set_mesh(self.mesh)``.

        Returns ``(model, hits, rhs)`` with the model sharded along the observation axis and the
        hit map / RHS replicated (already reduced across processes).
        """
        config = self.config
        landscape = self.landscape
        reader = self.reader
        reader.reset_failures()  # fresh pass: drop failures recorded by any previous read
        fill_gaps = config.gaps.treatment == GapTreatment.FILL and not config.binned

        indices = self.distribute(self.get_padded_read_indices())
        is_real = self.distribute(self._real_observation_mask())
        axis = jax.sharding.get_abstract_mesh().axis_names[0]

        @jax.shard_map(out_specs=(P('obs'), P(), P()), check_vma=False)
        def kernel(indices, is_real):  # type: ignore[no-untyped-def]
            def step(carry, args):  # type: ignore[no-untyped-def]
                hits_acc, rhs_acc = carry
                i, real = args

                # Skip the load for padding slots: only the real branch hits the io_callback,
                # so a padded observation is never read or preprocessed just to be masked away.
                # (lax.cond evaluates a single branch at runtime)
                data, padding, valid = jax.lax.cond(
                    real,
                    lambda: reader.read(i),
                    lambda: reader.read_filler(),
                )
                obs = ObservationModel.create(data, padding, config, landscape)

                # Padding/failed observations contribute nothing
                obs.M = obs.M.restrict(real & valid)

                # Hit map contribution.
                assert isinstance(obs.H, CompositionOperator)  # mypy
                pointing = obs.H.operands[-1]
                assert isinstance(pointing, PointingOperator)  # mypy
                pointing_i = pointing.as_stokes_i(interpolate=False)
                ones = furax.tree.ones_like(obs.M.in_structure)
                masked_tod = obs.M(ones)
                # The sample mask is per-detector (identical across Stokes legs); take one leg so
                # StokesI wraps a (ndet, nsamp) array rather than a whole demodulated Stokes backing.
                masked = masked_tod.data[0] if isinstance(masked_tod, Stokes) else masked_tod
                hits_i = jnp.int64(pointing_i.T(StokesI(masked)).i)

                # RHS contribution (optionally gap-filled).
                def func_gapfill(tod):  # type: ignore[no-untyped-def]
                    # Only reached under GapTreatment.FILL, where W is the plain inner-mask weight.
                    assert isinstance(obs.W, WeightOperator)
                    # Optional M_b N M_b preconditioner (covariance from the noise model).
                    preconditioner = None
                    if config.gaps.fill_options.precondition:
                        cov = obs.noise_operator(config.weighting.correlation_length, inverse=False)
                        m_bad = obs.M.complement()
                        preconditioner = (m_bad @ cov @ m_bad).reduce()
                    return gap_fill(
                        jax.random.key(config.gaps.fill_options.seed),
                        tod,
                        obs.W.weight,
                        obs.M,
                        rate=obs.sample_rate,
                        max_cg_steps=config.gaps.fill_options.max_steps,
                        rtol=config.gaps.fill_options.rtol,
                        preconditioner=preconditioner,
                        metadata=data[ReaderField.METADATA],
                    )

                # Use Python `if` for static `fill_gaps`, so gap-filling branch is not traced
                tod = data[ReaderField.SAMPLE_DATA]
                if fill_gaps:
                    tod = jax.lax.cond(
                        real & valid,
                        func_gapfill,
                        lambda _: _,  # return raw data as-is
                        tod,
                    )
                    # Gaps filled: skip the data-side mask so the fill survives N⁻¹.
                    rhs_i = obs.rhs_operator_prefilled(tod)
                else:
                    rhs_i = obs.rhs_operator(tod)
                return (hits_acc + hits_i, furax.tree.add(rhs_acc, rhs_i)), obs

            init_hits = jax.lax.pcast(jnp.zeros(landscape.shape, jnp.int64), axis, to='varying')
            init_rhs = jax.lax.pcast(landscape.zeros(), axis, to='varying')
            (hits, rhs), model = jax.lax.scan(step, (init_hits, init_rhs), (indices, is_real))
            return model, jax.lax.psum(hits, axis), jax.lax.psum(rhs, axis)

        model, hits, rhs = kernel(indices, is_real)
        return model, hits, rhs

    def _real_observation_mask(self) -> np.ndarray:
        """Boolean flag per padded slot: True for real observations, False for padding."""
        _, n_owned, n_pad = self.obs_distribution
        return np.concatenate([np.ones(n_owned, dtype=bool), np.zeros(n_pad, dtype=bool)])

    def get_system_operator(
        self, model: ObservationModel, *, diag: bool = False
    ) -> AbstractLinearOperator:
        H = ScanBlockColumnOperator.create(model.H)
        # filter_vmap: array leaves mapped, static fields held
        weight = eqx.filter_vmap(ObservationModel.diag_W)(model) if diag else model.W
        W = ScanBlockDiagonalOperator.create(weight)
        # specify leading axis dimension because F can be trivial
        _, n_own, n_pad = self.obs_distribution
        F = ScanBlockDiagonalOperator.create(model.F, n_lead=n_own + n_pad)
        return (H.T @ W @ F @ H).reduce()

    def pixel_selection(
        self, hits: Integer[Array, ' pixels'], weights: Float[Array, 'pixels stokes stokes']
    ) -> Bool[Array, ' pixels']:
        """Compute pixel selection according to hit and condition number cuts."""
        # Cut pixels with low number of samples
        hits_quantile = jnp.quantile(hits[hits > 0], q=0.95)
        valid = hits > self.config.hits_cut * hits_quantile

        if self.config.cond_cut > 0:
            eigs = furax.linalg.eigvalsh(weights)
            valid = jnp.logical_and(
                valid,
                eigs[..., 0] > self.config.cond_cut * eigs[..., -1],
            )

        return valid

    def _scan_wcs_footprint(self) -> WCSLandscape:
        """Scan observations to determine the combined WCS footprint and build a WCSLandscape.

        Performs a preliminary pass over all observations, loading the pointing data needed
        to compute each observation's sky coverage, then combines them into a unified footprint.

        Restricted to single-process runs over file-backed observations: the scan is an
        unsharded loop calling ``get_data`` per observation. For file-backed observations that
        cheaply loads only the requested pointing fields, but for sources that cannot subset
        their fields (e.g. preproc-backed observations) it would run the full load/preprocess
        pipeline on every observation, on every process. Pre-compute the footprint and pass an
        explicit WCS landscape instead in those cases.
        """
        if jax.process_count() > 1:
            msg = (
                'Automatic WCS footprint scanning is only supported on a single process. '
                'Pre-compute the footprint and pass an explicit WCS landscape instead.'
            )
            raise RuntimeError(msg)
        if any(not isinstance(obs, FileBackedLazyObservation) for obs in self.observations):
            msg = (
                'Automatic WCS footprint scanning requires file-backed observations '
                '(get_data must cheaply subset pointing fields). Pre-compute the footprint'
                'and pass an explicit WCS landscape instead.'
            )
            raise RuntimeError(msg)
        lc = self.config.landscape
        if lc.wcs is None:
            raise ValueError('WCS landscape config is required for auto footprint scanning.')
        wcs_config = lc.wcs
        res = wcs_config.resolution * pixell.utils.arcmin
        proj = wcs_config.projection.name.lower()
        pointing_fields = [ReaderField.BORESIGHT_QUATERNIONS, ReaderField.DETECTOR_QUATERNIONS]

        n = len(self.observations)
        corners_rad = np.empty((2, 2, n))  # [bottom-left, top-right] corners per observation
        for i, lazy_obs in enumerate(self.observations):
            obs = lazy_obs.get_data(pointing_fields)
            shape, wcs = obs.get_wcs_shape_and_kernel(
                resolution_arcmin=wcs_config.resolution, projection=wcs_config.projection
            )
            # RA _decreases_ left-to-right (increases toward the East)
            # so each corner pair is technically [[dec_lo, ra_hi], [dec_hi, ra_lo]]
            corners_rad[..., i] = pixell.enmap.corners(shape, wcs, corner=True)

        # find the corners of the smallest box covering all the individual patches
        dec_lo = corners_rad[0, 0].min()
        dec_hi = corners_rad[1, 0].max()
        # minimum_enclosing_arc expects [lo, hi] intervals with shape (2, n)
        ra_lo, ra_hi = minimum_enclosing_arc(corners_rad[::-1, 1].T)
        union_box = np.array([[dec_lo, ra_hi], [dec_hi, ra_lo]])

        # create the final shape and WCS objects for this covering box
        shape, wcs = pixell.enmap.geometry(pos=union_box, res=res, proj=proj)
        return WCSLandscape.from_wcs(shape, wcs, lc.stokes, self.config.dtype)

observations = observations instance-attribute

config = config or MapMakingConfig() instance-attribute

logger = logger or furax_logger instance-attribute

landscape = _static_landscape(self.config.landscape, self.config.dtype) or self._scan_wcs_footprint() instance-attribute

reader = self.get_reader(model_fields | rhs_fields) instance-attribute

mesh cached property

sharding property

n_observations property

Total number of observations across all processes.

obs_distribution property

(start, n_owned, n_pad) for this process.

__init__(observations, config=None, logger=None)

Source code in src/furax/mapmaking/mapmaker.py
def __init__(
    self,
    observations: Sequence[AbstractLazyObservation[T]],
    config: MapMakingConfig | None = None,
    logger: Logger | None = None,
) -> None:
    self.observations = observations
    self.config = config or MapMakingConfig()  # use defaults if not provided
    self.logger = logger or furax_logger
    self._check_config()
    self.landscape = (
        _static_landscape(self.config.landscape, self.config.dtype)
        or self._scan_wcs_footprint()
    )
    # Build the reader once, up front: it only probes shapes (no mesh context needed)
    rhs_fields = {ReaderField.METADATA, ReaderField.SAMPLE_DATA}
    model_fields = ObservationModel.required_reader_fields(self.config)
    self.reader = self.get_reader(model_fields | rhs_fields)

distribute(x)

Shard a pytree of process-local arrays along the leading 'obs' axis.

Source code in src/furax/mapmaking/mapmaker.py
def distribute(self, x: PyTree) -> PyTree:
    """Shard a pytree of process-local arrays along the leading 'obs' axis."""
    return jax.tree.map(lambda a: jax.make_array_from_process_local_data(self.sharding, a), x)

get_padded_read_indices()

Source code in src/furax/mapmaking/mapmaker.py
def get_padded_read_indices(self) -> np.ndarray:
    start, n_owned, n_pad = self.obs_distribution
    indices = np.arange(start, start + n_owned)
    return np.pad(indices, (0, n_pad), mode='edge')

get_reader(required_fields)

Build an ObservationReader for this process's local observations.

Source code in src/furax/mapmaking/mapmaker.py
def get_reader(self, required_fields: Collection[str]) -> ObservationReader[T]:
    """Build an ObservationReader for this process's local observations."""
    # Pass padded indices: process_allgather inside from_observations needs every
    # rank to send the same shape, so all ranks must report the same obs count.
    return ObservationReader.from_observations(
        self.observations,
        read_indices=tuple(self.get_padded_read_indices()),
        requested_fields=required_fields,
        demodulated=self.config.demodulated,
        stokes=self.config.landscape.stokes,
        dtype=self.config.dtype,
    )

run(out_dir=None)

Runs the mapmaker and return results after saving them to the given directory.

Source code in src/furax/mapmaking/mapmaker.py
def run(self, out_dir: str | Path | None = None) -> MapMakingResults:
    """Runs the mapmaker and return results after saving them to the given directory."""
    results = self.make_maps()

    # Save outputs on process 0 only (all processes hold the same replicated result)
    if out_dir is not None and jax.process_index() == 0:
        out_dir = Path(out_dir).resolve()
        results.save(out_dir)
        self.logger.info(f'saved results to {out_dir}')
        self.config.dump_yaml(out_dir / 'mapmaking_config.yaml')
        self.logger.info('saved mapmaking configuration to file')

    # Barrier so other ranks don't race ahead while rank 0 is still writing.
    mhu.sync_global_devices('mapmaker.run.save_done')

    return results

make_maps()

Computes the mapmaker results (maps and other products).

Source code in src/furax/mapmaking/mapmaker.py
def make_maps(self) -> MapMakingResults:
    """Computes the mapmaker results (maps and other products)."""
    logger_info = lambda msg: self.logger.info(f'MultiObsMapMaker: {msg}')

    n_processes = jax.process_count()
    rank = jax.process_index()
    n_local_devices = jax.local_device_count()
    n_devices = jax.device_count()

    # Information about how observations are distributed among processes
    start, n_owned, n_pad = self.obs_distribution
    n_per_proc = n_owned + n_pad
    n_per_dev = n_per_proc // n_local_devices
    n_slots_global = n_per_proc * n_processes

    # Every slot (real or padding) is padded to the same reader.out_structure
    per_slot_bytes = furax.tree.nbytes(self.reader.out_structure)
    global_bytes = per_slot_bytes * n_slots_global

    # The true, total data size (before observations are padded to a common structure)
    real_bytes = self.reader.total_nbytes

    slot_overhead = (n_slots_global - self.n_observations) / self.n_observations
    byte_overhead = (global_bytes - real_bytes) / real_bytes
    logger_info(
        f'layout procs={n_processes} dev_per_proc={n_local_devices} dev_total={n_devices}'
    )
    logger_info(
        f'dataset obs={self.n_observations} slots={n_slots_global} slot_overhead=+{slot_overhead:.1%} '
        f'slots_per_proc={n_per_proc} slots_per_dev={n_per_dev} slot_size={_format_bytes(per_slot_bytes)}'
    )
    logger_info(
        f'dataset real={_format_bytes(real_bytes)} global={_format_bytes(global_bytes)} '
        f'byte_overhead=+{byte_overhead:.1%}'
    )

    rank_pad = n_pad / n_per_proc
    logger_info(
        f'rank={rank} obs={start}:{start + n_owned} real={n_owned} pad={n_pad} '
        f'pad_pct={rank_pad:.1%} real_size={_format_bytes(per_slot_bytes * n_owned)} '
        f'pad_size={_format_bytes(per_slot_bytes * n_pad)}'
    )

    with jax.set_mesh(self.mesh):
        # Single read pass (sharded over observations): build the model and accumulate the
        # hit map + RHS together, so each observation is read/preprocessed exactly once. The
        # cross-process reduction of hits/rhs happens via psum inside the kernel.
        model, hits, rhs = self.build_model_and_accumulate()
        jax.block_until_ready((hits, rhs))
        logger_info('Accumulated hit map and RHS vector')

        failed_observations = self._collect_failed_observations()
        if failed_observations:
            logger_info(f'{len(failed_observations)} observation(s) failed and were excluded')

        # System operator (full/diagonal)
        A = self.get_system_operator(model)
        diag_A = A if self.config.binned else self.get_system_operator(model, diag=True)
        BJ = BJPreconditioner.create(diag_A)
        icov = BJ.blocks.block_until_ready()
        logger_info('Computed white noise inverse covariance')

        valid_pixels = self.pixel_selection(hits, icov)
        # Select valid pixels on the (trailing) sky axes, leaving the leading Stokes axis intact.
        selector = IndexOperator((..., *jnp.where(valid_pixels)), in_structure=A.out_structure)
        n_selected = jnp.sum(valid_pixels)
        n_observed = jnp.sum(hits > 0)
        n_total = valid_pixels.size
        logger_info(f'Selected {n_selected} pixels ({n_observed} seen, {n_total} total)')

        hits = hits.at[~valid_pixels].set(0)  # excluded pixels have zero hits
        icov = jnp.moveaxis(icov, [-2, -1], [0, 1])  # (*pixels, ns, ns) → (ns, ns, *pixels)

        # Solve the mapmaking system
        solver = lineax.CG(**asdict(self.config.solver))
        spd = OperatorTag.POSITIVE_SEMIDEFINITE
        lx_system = as_lineax_operator(selector @ A @ selector.T, spd)
        M = (selector @ BJ.I @ selector.T).reduce()  # preconditioner
        lx_precond = as_lineax_operator(M, spd)
        rhs_reduced = selector(rhs)
        y0 = M(rhs_reduced)

        solution = lineax.linear_solve(
            lx_system,
            rhs_reduced,
            solver=solver,
            options={'preconditioner': lx_precond, 'y0': y0},
            throw=False,
        )
        estimate = selector.T(solution.value)
        num_steps = solution.stats['num_steps']
        logger_info(f'Finished mapmaking (iteration steps: {num_steps})')

    return MapMakingResults(
        map=estimate,
        icov=icov,
        hit_map=hits,
        solver_stats=solution.stats,
        landscape=self.landscape,
        failed_observations=failed_observations,
    )

build_model_and_accumulate()

Build the model and accumulate the hit map and RHS in a single, sharded read pass.

Sharded over observations: each observation is read (and, for preproc-backed observations, preprocessed) exactly once, and contributes to the model, the hit map and the RHS from that single read. This avoids reading every observation twice (once to build the model, once to accumulate the RHS). The hit map and RHS are reduced across all processes with psum inside the kernel.

Observations that contribute nothing -- padding (added so every device carries the same workload) and failed loads (the preprocessing pipeline raised) -- are gated out by zeroing their masker inside the kernel, so they drop out of the hit map, the RHS, and the CG system operator alike. Failed loads are reported afterwards from self.reader.failed_indices (see make_maps).

Must run under jax.set_mesh(self.mesh).

Returns (model, hits, rhs) with the model sharded along the observation axis and the hit map / RHS replicated (already reduced across processes).

Source code in src/furax/mapmaking/mapmaker.py
def build_model_and_accumulate(
    self,
) -> tuple[ObservationModel, Int64[Array, '...'], StokesType]:
    """Build the model and accumulate the hit map and RHS in a single, sharded read pass.

    Sharded over observations: each observation is read (and, for preproc-backed observations,
    preprocessed) exactly once, and contributes to the model, the hit map and the RHS from that
    single read. This avoids reading every observation twice (once to build the model, once to
    accumulate the RHS). The hit map and RHS are reduced across all processes with ``psum``
    inside the kernel.

    Observations that contribute nothing -- padding (added so every device carries the same
    workload) and failed loads (the preprocessing pipeline raised) -- are gated out by zeroing
    their masker inside the kernel, so they drop out of the hit map, the RHS, and the CG system
    operator alike. Failed loads are reported afterwards from ``self.reader.failed_indices``
    (see ``make_maps``).

    Must run under ``jax.set_mesh(self.mesh)``.

    Returns ``(model, hits, rhs)`` with the model sharded along the observation axis and the
    hit map / RHS replicated (already reduced across processes).
    """
    config = self.config
    landscape = self.landscape
    reader = self.reader
    reader.reset_failures()  # fresh pass: drop failures recorded by any previous read
    fill_gaps = config.gaps.treatment == GapTreatment.FILL and not config.binned

    indices = self.distribute(self.get_padded_read_indices())
    is_real = self.distribute(self._real_observation_mask())
    axis = jax.sharding.get_abstract_mesh().axis_names[0]

    @jax.shard_map(out_specs=(P('obs'), P(), P()), check_vma=False)
    def kernel(indices, is_real):  # type: ignore[no-untyped-def]
        def step(carry, args):  # type: ignore[no-untyped-def]
            hits_acc, rhs_acc = carry
            i, real = args

            # Skip the load for padding slots: only the real branch hits the io_callback,
            # so a padded observation is never read or preprocessed just to be masked away.
            # (lax.cond evaluates a single branch at runtime)
            data, padding, valid = jax.lax.cond(
                real,
                lambda: reader.read(i),
                lambda: reader.read_filler(),
            )
            obs = ObservationModel.create(data, padding, config, landscape)

            # Padding/failed observations contribute nothing
            obs.M = obs.M.restrict(real & valid)

            # Hit map contribution.
            assert isinstance(obs.H, CompositionOperator)  # mypy
            pointing = obs.H.operands[-1]
            assert isinstance(pointing, PointingOperator)  # mypy
            pointing_i = pointing.as_stokes_i(interpolate=False)
            ones = furax.tree.ones_like(obs.M.in_structure)
            masked_tod = obs.M(ones)
            # The sample mask is per-detector (identical across Stokes legs); take one leg so
            # StokesI wraps a (ndet, nsamp) array rather than a whole demodulated Stokes backing.
            masked = masked_tod.data[0] if isinstance(masked_tod, Stokes) else masked_tod
            hits_i = jnp.int64(pointing_i.T(StokesI(masked)).i)

            # RHS contribution (optionally gap-filled).
            def func_gapfill(tod):  # type: ignore[no-untyped-def]
                # Only reached under GapTreatment.FILL, where W is the plain inner-mask weight.
                assert isinstance(obs.W, WeightOperator)
                # Optional M_b N M_b preconditioner (covariance from the noise model).
                preconditioner = None
                if config.gaps.fill_options.precondition:
                    cov = obs.noise_operator(config.weighting.correlation_length, inverse=False)
                    m_bad = obs.M.complement()
                    preconditioner = (m_bad @ cov @ m_bad).reduce()
                return gap_fill(
                    jax.random.key(config.gaps.fill_options.seed),
                    tod,
                    obs.W.weight,
                    obs.M,
                    rate=obs.sample_rate,
                    max_cg_steps=config.gaps.fill_options.max_steps,
                    rtol=config.gaps.fill_options.rtol,
                    preconditioner=preconditioner,
                    metadata=data[ReaderField.METADATA],
                )

            # Use Python `if` for static `fill_gaps`, so gap-filling branch is not traced
            tod = data[ReaderField.SAMPLE_DATA]
            if fill_gaps:
                tod = jax.lax.cond(
                    real & valid,
                    func_gapfill,
                    lambda _: _,  # return raw data as-is
                    tod,
                )
                # Gaps filled: skip the data-side mask so the fill survives N⁻¹.
                rhs_i = obs.rhs_operator_prefilled(tod)
            else:
                rhs_i = obs.rhs_operator(tod)
            return (hits_acc + hits_i, furax.tree.add(rhs_acc, rhs_i)), obs

        init_hits = jax.lax.pcast(jnp.zeros(landscape.shape, jnp.int64), axis, to='varying')
        init_rhs = jax.lax.pcast(landscape.zeros(), axis, to='varying')
        (hits, rhs), model = jax.lax.scan(step, (init_hits, init_rhs), (indices, is_real))
        return model, jax.lax.psum(hits, axis), jax.lax.psum(rhs, axis)

    model, hits, rhs = kernel(indices, is_real)
    return model, hits, rhs

get_system_operator(model, *, diag=False)

Source code in src/furax/mapmaking/mapmaker.py
def get_system_operator(
    self, model: ObservationModel, *, diag: bool = False
) -> AbstractLinearOperator:
    H = ScanBlockColumnOperator.create(model.H)
    # filter_vmap: array leaves mapped, static fields held
    weight = eqx.filter_vmap(ObservationModel.diag_W)(model) if diag else model.W
    W = ScanBlockDiagonalOperator.create(weight)
    # specify leading axis dimension because F can be trivial
    _, n_own, n_pad = self.obs_distribution
    F = ScanBlockDiagonalOperator.create(model.F, n_lead=n_own + n_pad)
    return (H.T @ W @ F @ H).reduce()

pixel_selection(hits, weights)

Compute pixel selection according to hit and condition number cuts.

Source code in src/furax/mapmaking/mapmaker.py
def pixel_selection(
    self, hits: Integer[Array, ' pixels'], weights: Float[Array, 'pixels stokes stokes']
) -> Bool[Array, ' pixels']:
    """Compute pixel selection according to hit and condition number cuts."""
    # Cut pixels with low number of samples
    hits_quantile = jnp.quantile(hits[hits > 0], q=0.95)
    valid = hits > self.config.hits_cut * hits_quantile

    if self.config.cond_cut > 0:
        eigs = furax.linalg.eigvalsh(weights)
        valid = jnp.logical_and(
            valid,
            eigs[..., 0] > self.config.cond_cut * eigs[..., -1],
        )

    return valid

MapMaker dataclass

Class for generic mapmakers which consume GroundObservationData.

Methods:

Attributes:

Source code in src/furax/mapmaking/mapmaker.py
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
@dataclass
class MapMaker:
    """Class for generic mapmakers which consume GroundObservationData."""

    config: MapMakingConfig
    logger: Logger = furax_logger

    def __post_init__(self) -> None:
        return

    @abstractmethod
    def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]: ...

    def run(
        self, observation: AbstractGroundObservation[Any], out_dir: str | Path | None
    ) -> dict[str, Any]:
        results = self.make_map(observation)

        # Save output
        if out_dir is not None:
            out_dir = Path(out_dir)
            out_dir.mkdir(parents=True, exist_ok=True)
            self._save(results, out_dir)
            self.config.dump_yaml(out_dir / 'mapmaking_config.yaml')
            self.logger.info('Mapmaking config saved to file')

        return results

    def _save(self, results: dict[str, Any], out_dir: Path) -> None:
        for key, m in results.items():
            if isinstance(m, jax.Array) or isinstance(m, np.ndarray):
                np.save(out_dir / key, np.array(m))
            elif isinstance(m, StokesIQU):
                np.save(out_dir / key, np.stack([m.i, m.q, m.u], axis=0))
            elif isinstance(m, pixell.enmap.ndmap):
                pixell.enmap.write_map((out_dir / f'{key}.hdf').as_posix(), m, allow_modify=True)
            elif isinstance(m, WCS):
                header = m.to_header()
                hdu = fits.PrimaryHDU(header=header)
                hdu.writeto(out_dir / f'{key}.fits', overwrite=True)
            elif isinstance(m, StokesLandscape):
                with open(out_dir / f'{key}.pkl', 'wb') as f:
                    pickle.dump(m, f)
            elif isinstance(m, dict):
                self._save(m, out_dir)
                continue
            else:
                continue
            self.logger.info(f'Mapmaking result [{key}] saved to file')

    @classmethod
    def from_config(cls, config: MapMakingConfig, logger: Logger | None = None) -> 'MapMaker':
        """Return the appropriate mapmaker based on the config's mapmaking method."""
        maker = {
            Methods.BINNED: BinnedMapMaker,
            Methods.MAXL: MLMapmaker,
            Methods.TWOSTEP: TwoStepMapmaker,
            Methods.ATOP: ATOPMapMaker,
        }[config.method]

        if logger is None:
            return maker(config)  # type: ignore[abstract]
        else:
            return maker(config, logger=logger)  # type: ignore[abstract]

    @classmethod
    def from_yaml(cls, path: str | Path, logger: Logger | None = None) -> 'MapMaker':
        return cls.from_config(MapMakingConfig.load_yaml(path), logger=logger)

    def get_landscape(self, observation: AbstractGroundObservation[Any]) -> StokesLandscape:
        """Landscape used for mapmaking with given observation."""
        lc = self.config.landscape
        if (landscape := _static_landscape(lc, self.config.dtype)) is not None:
            return landscape
        assert lc.wcs is not None  # mypy: _static_landscape returns None only for auto WCS
        wcs_shape, wcs_kernel = observation.get_wcs_shape_and_kernel(
            resolution_arcmin=lc.wcs.resolution, projection=lc.wcs.projection
        )
        return WCSLandscape.from_wcs(wcs_shape, wcs_kernel, lc.stokes, self.config.dtype)

    def get_pointing(
        self, observation: AbstractGroundObservation[Any], landscape: StokesLandscape
    ) -> AbstractLinearOperator:
        """Operator containing pointing information for given observation."""
        det_off_ang = observation.get_detector_offset_angles().astype(landscape.dtype)

        if self.config.pointing.on_the_fly:
            pointing = PointingOperator.create(
                landscape,
                jnp.asarray(observation.get_boresight_quaternions()),
                jnp.asarray(observation.get_detector_quaternions()),
                batch_size=self.config.pointing.batch_size,
                interpolate=self.config.pointing.interpolation == 'bilinear',
            )
            return pointing

        else:
            pixel_inds, spin_ang = observation.get_pointing_and_spin_angles(landscape)
            point_ang = spin_ang + det_off_ang[:, None]

            # Index the (trailing) sky axes, leaving the leading Stokes axis of the map intact.
            if isinstance(landscape, WCSLandscape | AstropyWCSLandscape):
                assert pixel_inds.shape[-1] == 2, 'Wrong WCS landscape format'
                indexer = IndexOperator(
                    (..., pixel_inds[..., 0], pixel_inds[..., 1]), in_structure=landscape.structure
                )
            elif isinstance(landscape, HealpixLandscape):
                if pixel_inds.shape[-1] == 1:
                    pixel_inds = pixel_inds[..., 0]
                indexer = IndexOperator((..., pixel_inds), in_structure=landscape.structure)

            # Rotation due to coordinate transform
            tod_shape = pixel_inds.shape[:2]
            rotator = QURotationOperator.create(
                tod_shape, dtype=landscape.dtype, stokes=landscape.stokes, angles=point_ang
            )

            return (rotator @ indexer).reduce()

    def get_acquisition(
        self,
        observation: AbstractGroundObservation[Any],
        landscape: StokesLandscape,
    ) -> AbstractLinearOperator:
        """Acquisition operator mapping sky maps to time-ordered data."""
        pointing = self.get_pointing(observation, landscape)

        if self.config.demodulated:
            return pointing
        else:
            meta = {
                'shape': (observation.n_detectors, observation.n_samples),
                'stokes': landscape.stokes,
                'dtype': self.config.dtype,
            }
            polarizer = LinearPolarizerOperator.create(
                **meta,  # type: ignore[arg-type]
                angles=jnp.asarray(
                    observation.get_detector_offset_angles().astype(self.config.dtype)[:, None]
                ),
            )
            hwp = HWPOperator.create(
                **meta,  # type: ignore[arg-type]
                angles=jnp.asarray(observation.get_hwp_angles().astype(self.config.dtype)),
            )

            return (polarizer @ hwp @ pointing).reduce()

    def get_scanning_masker(
        self, observation: AbstractGroundObservation[Any]
    ) -> AbstractLinearOperator:
        """Select only the scanning intervals of the given TOD.

        The TOD has shape (ndets, nsamps).
        """
        in_structure = ShapeDtypeStruct(
            shape=(observation.n_detectors, observation.n_samples), dtype=self.config.dtype
        )
        if not self.config.scanning_mask:
            return IdentityOperator(in_structure=in_structure)

        mask = observation.get_scanning_mask()
        out_structure = ShapeDtypeStruct(
            shape=(observation.n_detectors, np.sum(mask)), dtype=self.config.dtype
        )
        masker = IndexOperator(
            (slice(None), jnp.array(mask)),
            in_structure=in_structure,
            out_structure=out_structure,
        )
        return masker

    def get_scanning_mask_projector(
        self, observation: AbstractGroundObservation[Any]
    ) -> AbstractLinearOperator:
        """Zero the values outside the scanning intervals of the given TOD.

        The TOD has shape (ndets, nsamps).
        """
        structure = ShapeDtypeStruct(
            shape=(observation.n_detectors, observation.n_samples), dtype=self.config.dtype
        )
        if not self.config.scanning_mask:
            return IdentityOperator(in_structure=structure)

        # mask is broadcasted along detector axis
        mask = observation.get_scanning_mask()
        return MaskOperator.from_boolean_mask(mask, in_structure=structure)

    def get_sample_mask_projector(
        self, observation: AbstractGroundObservation[Any]
    ) -> AbstractLinearOperator:
        """Zero the given TOD at masked (flagged) samples.

        The TOD has shape (ndets, nsamps).
        """
        structure = ShapeDtypeStruct(
            shape=(observation.n_detectors, observation.n_samples), dtype=self.config.dtype
        )
        if not self.config.sample_mask:
            return IdentityOperator(in_structure=structure)

        # Note the mask value is 1 at valid (unmasked) samples
        mask = observation.get_sample_mask()
        return MaskOperator.from_boolean_mask(mask, in_structure=structure)

    def get_mask_projector(
        self, observation: AbstractGroundObservation[Any]
    ) -> AbstractLinearOperator:
        """Mask operator which incorporates both the scanning and sample mask projectors."""
        return (
            self.get_scanning_mask_projector(observation)
            @ self.get_sample_mask_projector(observation)
        ).reduce()

    def get_or_fit_noise_model(self, observation: AbstractGroundObservation[Any]) -> NoiseModel:
        """Return a noise model for the observation.

        The model type (diagonal, toeplitz, ...) is specified by the mapmaker.
        Attempts to load the noise model from the data if available,
        but otherwise fits a model to the data.
        """
        config = self.config

        if config.weighting.mode == WeightingMode.IDENTITY:
            return WhiteNoiseModel(sigma=jnp.ones(observation.n_detectors, dtype=config.dtype))

        Model = WhiteNoiseModel if config.binned else AtmosphericNoiseModel

        if config.weighting.source == NoiseSource.PRECOMPUTED:
            # Load the noise model from data if available
            noise_model = observation.get_noise_model()
            if noise_model:
                self.logger.info('Loading noise model from data')
                if isinstance(noise_model, Model):
                    return noise_model
                if config.binned and isinstance(noise_model, AtmosphericNoiseModel):
                    return noise_model.to_white_noise_model()
            self.logger.info('No noise model found for loading')

        # Otherwise, fit the noise model from data
        self.logger.info('Fitting noise model from data')
        f, Pxx = jax.scipy.signal.welch(
            jnp.asarray(observation.get_tods()).astype(config.dtype),
            fs=observation.sample_rate,
            nperseg=config.weighting.fitting.nperseg,
        )
        hwp_frequency = jnp.asarray(observation.get_hwp_frequency())
        return Model.fit_psd_model(
            f,
            Pxx,
            sample_rate=jnp.array(observation.sample_rate),
            hwp_frequency=hwp_frequency,
            config=config.weighting.fitting,
        )

    def get_pixel_selector(
        self, blocks: Float[Array, '... nstokes nstokes'], landscape: StokesLandscape
    ) -> IndexOperator:
        """Select indices of map pixels satisfying the hit and condition-number cuts.

        Pixels must meet the minimum fractional hits (hits_cut) and condition number
        (cond_cut) criteria.
        """
        config = self.config

        # eigs = jnp.linalg.eigvalsh(blocks)
        eigs = np.linalg.eigvalsh(blocks)
        hits_quantile = np.quantile(eigs[(eigs[..., -1] > 0),], q=0.95)
        valid = jnp.logical_and(
            eigs[..., -1] > config.hits_cut * hits_quantile,
            eigs[..., 0] > config.cond_cut * eigs[..., -1],
        )
        return IndexOperator((..., *jnp.where(valid)), in_structure=landscape.structure)

    def get_template_operator(
        self, observation: AbstractGroundObservation[Any]
    ) -> BlockRowOperator:
        """Create a template operator from the provided name and configuration."""
        config = self.config
        assert config.templates is not None
        blocks: dict[str, AbstractLinearOperator] = {}

        if poly := config.templates.polynomial:
            blocks['polynomial'] = PerDetectorTemplate.polynomial(
                max_poly_order=poly.legendre.max_order,
                intervals=jnp.asarray(observation.get_scanning_intervals()),
                times=jnp.asarray(observation.get_elapsed_times()),
                n_dets=observation.n_detectors,
                dtype=config.dtype,
            )
        if sss := config.templates.scan_synchronous:
            blocks['scan_synchronous'] = PerDetectorTemplate.scan_synchronous(
                legendre=sss.legendre,
                azimuth=jnp.asarray(observation.get_azimuth()),
                n_dets=observation.n_detectors,
                dtype=config.dtype,
            )
        if baz := config.templates.binaz_synchronous:
            blocks['binaz_synchronous'] = PerDetectorTemplate.binaz_synchronous(
                bins=baz.bins,
                azimuth=jnp.asarray(observation.get_azimuth()),
                n_dets=observation.n_detectors,
                dtype=config.dtype,
            )
        if hwpss := config.templates.hwp_synchronous:
            blocks['hwp_synchronous'] = PerDetectorTemplate.hwp_synchronous(
                n_harmonics=hwpss.n_harmonics,
                hwp_angles=jnp.asarray(observation.get_hwp_angles()),
                n_dets=observation.n_detectors,
                dtype=config.dtype,
            )
        if azhwpss := config.templates.azhwp_synchronous:
            azimuth = jnp.asarray(observation.get_azimuth())
            hwp_angles = jnp.asarray(observation.get_hwp_angles())
            if azhwpss.split_scans:
                blocks['azhwp_synchronous_left'] = PerDetectorTemplate.azhwp_synchronous(
                    legendre=azhwpss.legendre,
                    n_harmonics=azhwpss.n_harmonics,
                    azimuth=azimuth,
                    hwp_angles=hwp_angles,
                    n_dets=observation.n_detectors,
                    dtype=config.dtype,
                    scan_mask=jnp.asarray(observation.get_left_scan_mask()),
                )
                blocks['azhwp_synchronous_right'] = PerDetectorTemplate.azhwp_synchronous(
                    legendre=azhwpss.legendre,
                    n_harmonics=azhwpss.n_harmonics,
                    azimuth=azimuth,
                    hwp_angles=hwp_angles,
                    n_dets=observation.n_detectors,
                    dtype=config.dtype,
                    scan_mask=jnp.asarray(observation.get_right_scan_mask()),
                )
            else:
                blocks['azhwp_synchronous'] = PerDetectorTemplate.azhwp_synchronous(
                    legendre=azhwpss.legendre,
                    n_harmonics=azhwpss.n_harmonics,
                    azimuth=azimuth,
                    hwp_angles=hwp_angles,
                    n_dets=observation.n_detectors,
                    dtype=config.dtype,
                )
        if binazhwpss := config.templates.binazhwp_synchronous:
            blocks['binazhwp_synchronous'] = PerDetectorTemplate.binazhwp_synchronous(
                bins=binazhwpss.bins,
                n_harmonics=binazhwpss.n_harmonics,
                azimuth=jnp.asarray(observation.get_azimuth()),
                hwp_angles=jnp.asarray(observation.get_hwp_angles()),
                n_dets=observation.n_detectors,
                dtype=config.dtype,
            )
        if shwpss := config.templates.spline_hwpss:
            times = jnp.asarray(observation.get_elapsed_times())
            blocks['spline_hwpss'] = PerDetectorTemplate.bspline_hwpss(
                times=times,
                hwp_angles=jnp.asarray(observation.get_hwp_angles()),
                n_dets=observation.n_detectors,
                n_knots=shwpss.resolve_n_knots(times.size),
                harmonics=shwpss.harmonics,
                dtype=config.dtype,
            )
        if ground := config.templates.ground:
            azimuth = jnp.asarray(observation.get_azimuth())
            elevation = jnp.asarray(observation.get_elevation())
            detector_quaternions = jnp.asarray(observation.get_detector_quaternions())
            self._ground_landscape = templates.GroundTemplateOperator.get_landscape(
                azimuth_resolution=ground.azimuth_resolution,
                elevation_resolution=ground.elevation_resolution,
                boresight_azimuth=azimuth,
                boresight_elevation=elevation,
                detector_quaternions=detector_quaternions,
                stokes='IQU',
                dtype=config.dtype,
            )
            ground_op = templates.GroundTemplateOperator.create(
                azimuth_resolution=ground.azimuth_resolution,
                elevation_resolution=ground.elevation_resolution,
                boresight_azimuth=azimuth,
                boresight_elevation=elevation,
                boresight_rotation=jnp.zeros_like(azimuth),
                detector_quaternions=detector_quaternions,
                hwp_angles=jnp.asarray(observation.get_hwp_angles()),
                stokes='IQU',
                dtype=config.dtype,
                landscape=self._ground_landscape,
                batch_size=config.pointing.batch_size,
            )
            ones_tod = jnp.ones(
                (observation.n_detectors, observation.n_samples), dtype=config.dtype
            )
            self._ground_coverage = ground_op.T(ones_tod)
            nonzero_hits = jnp.argwhere(self._ground_coverage.i > 0)
            indexer = IndexOperator(
                (..., nonzero_hits[:, 0], nonzero_hits[:, 1]),
                in_structure=furax.tree.as_structure(self._ground_coverage),
            )
            flattener = furax.asoperator(
                lambda s: jnp.concatenate([s.i, s.q, s.u]),
                in_structure=indexer.out_structure,
            )
            self._ground_selector = flattener @ indexer

            blocks['ground'] = ground_op @ self._ground_selector.T

        return BlockRowOperator(blocks=blocks)

config instance-attribute

logger = furax_logger class-attribute instance-attribute

__init__(config, logger=furax_logger)

__post_init__()

Source code in src/furax/mapmaking/mapmaker.py
def __post_init__(self) -> None:
    return

make_map(observation) abstractmethod

Source code in src/furax/mapmaking/mapmaker.py
@abstractmethod
def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]: ...

run(observation, out_dir)

Source code in src/furax/mapmaking/mapmaker.py
def run(
    self, observation: AbstractGroundObservation[Any], out_dir: str | Path | None
) -> dict[str, Any]:
    results = self.make_map(observation)

    # Save output
    if out_dir is not None:
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
        self._save(results, out_dir)
        self.config.dump_yaml(out_dir / 'mapmaking_config.yaml')
        self.logger.info('Mapmaking config saved to file')

    return results

from_config(config, logger=None) classmethod

Return the appropriate mapmaker based on the config's mapmaking method.

Source code in src/furax/mapmaking/mapmaker.py
@classmethod
def from_config(cls, config: MapMakingConfig, logger: Logger | None = None) -> 'MapMaker':
    """Return the appropriate mapmaker based on the config's mapmaking method."""
    maker = {
        Methods.BINNED: BinnedMapMaker,
        Methods.MAXL: MLMapmaker,
        Methods.TWOSTEP: TwoStepMapmaker,
        Methods.ATOP: ATOPMapMaker,
    }[config.method]

    if logger is None:
        return maker(config)  # type: ignore[abstract]
    else:
        return maker(config, logger=logger)  # type: ignore[abstract]

from_yaml(path, logger=None) classmethod

Source code in src/furax/mapmaking/mapmaker.py
@classmethod
def from_yaml(cls, path: str | Path, logger: Logger | None = None) -> 'MapMaker':
    return cls.from_config(MapMakingConfig.load_yaml(path), logger=logger)

get_landscape(observation)

Landscape used for mapmaking with given observation.

Source code in src/furax/mapmaking/mapmaker.py
def get_landscape(self, observation: AbstractGroundObservation[Any]) -> StokesLandscape:
    """Landscape used for mapmaking with given observation."""
    lc = self.config.landscape
    if (landscape := _static_landscape(lc, self.config.dtype)) is not None:
        return landscape
    assert lc.wcs is not None  # mypy: _static_landscape returns None only for auto WCS
    wcs_shape, wcs_kernel = observation.get_wcs_shape_and_kernel(
        resolution_arcmin=lc.wcs.resolution, projection=lc.wcs.projection
    )
    return WCSLandscape.from_wcs(wcs_shape, wcs_kernel, lc.stokes, self.config.dtype)

get_pointing(observation, landscape)

Operator containing pointing information for given observation.

Source code in src/furax/mapmaking/mapmaker.py
def get_pointing(
    self, observation: AbstractGroundObservation[Any], landscape: StokesLandscape
) -> AbstractLinearOperator:
    """Operator containing pointing information for given observation."""
    det_off_ang = observation.get_detector_offset_angles().astype(landscape.dtype)

    if self.config.pointing.on_the_fly:
        pointing = PointingOperator.create(
            landscape,
            jnp.asarray(observation.get_boresight_quaternions()),
            jnp.asarray(observation.get_detector_quaternions()),
            batch_size=self.config.pointing.batch_size,
            interpolate=self.config.pointing.interpolation == 'bilinear',
        )
        return pointing

    else:
        pixel_inds, spin_ang = observation.get_pointing_and_spin_angles(landscape)
        point_ang = spin_ang + det_off_ang[:, None]

        # Index the (trailing) sky axes, leaving the leading Stokes axis of the map intact.
        if isinstance(landscape, WCSLandscape | AstropyWCSLandscape):
            assert pixel_inds.shape[-1] == 2, 'Wrong WCS landscape format'
            indexer = IndexOperator(
                (..., pixel_inds[..., 0], pixel_inds[..., 1]), in_structure=landscape.structure
            )
        elif isinstance(landscape, HealpixLandscape):
            if pixel_inds.shape[-1] == 1:
                pixel_inds = pixel_inds[..., 0]
            indexer = IndexOperator((..., pixel_inds), in_structure=landscape.structure)

        # Rotation due to coordinate transform
        tod_shape = pixel_inds.shape[:2]
        rotator = QURotationOperator.create(
            tod_shape, dtype=landscape.dtype, stokes=landscape.stokes, angles=point_ang
        )

        return (rotator @ indexer).reduce()

get_acquisition(observation, landscape)

Acquisition operator mapping sky maps to time-ordered data.

Source code in src/furax/mapmaking/mapmaker.py
def get_acquisition(
    self,
    observation: AbstractGroundObservation[Any],
    landscape: StokesLandscape,
) -> AbstractLinearOperator:
    """Acquisition operator mapping sky maps to time-ordered data."""
    pointing = self.get_pointing(observation, landscape)

    if self.config.demodulated:
        return pointing
    else:
        meta = {
            'shape': (observation.n_detectors, observation.n_samples),
            'stokes': landscape.stokes,
            'dtype': self.config.dtype,
        }
        polarizer = LinearPolarizerOperator.create(
            **meta,  # type: ignore[arg-type]
            angles=jnp.asarray(
                observation.get_detector_offset_angles().astype(self.config.dtype)[:, None]
            ),
        )
        hwp = HWPOperator.create(
            **meta,  # type: ignore[arg-type]
            angles=jnp.asarray(observation.get_hwp_angles().astype(self.config.dtype)),
        )

        return (polarizer @ hwp @ pointing).reduce()

get_scanning_masker(observation)

Select only the scanning intervals of the given TOD.

The TOD has shape (ndets, nsamps).

Source code in src/furax/mapmaking/mapmaker.py
def get_scanning_masker(
    self, observation: AbstractGroundObservation[Any]
) -> AbstractLinearOperator:
    """Select only the scanning intervals of the given TOD.

    The TOD has shape (ndets, nsamps).
    """
    in_structure = ShapeDtypeStruct(
        shape=(observation.n_detectors, observation.n_samples), dtype=self.config.dtype
    )
    if not self.config.scanning_mask:
        return IdentityOperator(in_structure=in_structure)

    mask = observation.get_scanning_mask()
    out_structure = ShapeDtypeStruct(
        shape=(observation.n_detectors, np.sum(mask)), dtype=self.config.dtype
    )
    masker = IndexOperator(
        (slice(None), jnp.array(mask)),
        in_structure=in_structure,
        out_structure=out_structure,
    )
    return masker

get_scanning_mask_projector(observation)

Zero the values outside the scanning intervals of the given TOD.

The TOD has shape (ndets, nsamps).

Source code in src/furax/mapmaking/mapmaker.py
def get_scanning_mask_projector(
    self, observation: AbstractGroundObservation[Any]
) -> AbstractLinearOperator:
    """Zero the values outside the scanning intervals of the given TOD.

    The TOD has shape (ndets, nsamps).
    """
    structure = ShapeDtypeStruct(
        shape=(observation.n_detectors, observation.n_samples), dtype=self.config.dtype
    )
    if not self.config.scanning_mask:
        return IdentityOperator(in_structure=structure)

    # mask is broadcasted along detector axis
    mask = observation.get_scanning_mask()
    return MaskOperator.from_boolean_mask(mask, in_structure=structure)

get_sample_mask_projector(observation)

Zero the given TOD at masked (flagged) samples.

The TOD has shape (ndets, nsamps).

Source code in src/furax/mapmaking/mapmaker.py
def get_sample_mask_projector(
    self, observation: AbstractGroundObservation[Any]
) -> AbstractLinearOperator:
    """Zero the given TOD at masked (flagged) samples.

    The TOD has shape (ndets, nsamps).
    """
    structure = ShapeDtypeStruct(
        shape=(observation.n_detectors, observation.n_samples), dtype=self.config.dtype
    )
    if not self.config.sample_mask:
        return IdentityOperator(in_structure=structure)

    # Note the mask value is 1 at valid (unmasked) samples
    mask = observation.get_sample_mask()
    return MaskOperator.from_boolean_mask(mask, in_structure=structure)

get_mask_projector(observation)

Mask operator which incorporates both the scanning and sample mask projectors.

Source code in src/furax/mapmaking/mapmaker.py
def get_mask_projector(
    self, observation: AbstractGroundObservation[Any]
) -> AbstractLinearOperator:
    """Mask operator which incorporates both the scanning and sample mask projectors."""
    return (
        self.get_scanning_mask_projector(observation)
        @ self.get_sample_mask_projector(observation)
    ).reduce()

get_or_fit_noise_model(observation)

Return a noise model for the observation.

The model type (diagonal, toeplitz, ...) is specified by the mapmaker. Attempts to load the noise model from the data if available, but otherwise fits a model to the data.

Source code in src/furax/mapmaking/mapmaker.py
def get_or_fit_noise_model(self, observation: AbstractGroundObservation[Any]) -> NoiseModel:
    """Return a noise model for the observation.

    The model type (diagonal, toeplitz, ...) is specified by the mapmaker.
    Attempts to load the noise model from the data if available,
    but otherwise fits a model to the data.
    """
    config = self.config

    if config.weighting.mode == WeightingMode.IDENTITY:
        return WhiteNoiseModel(sigma=jnp.ones(observation.n_detectors, dtype=config.dtype))

    Model = WhiteNoiseModel if config.binned else AtmosphericNoiseModel

    if config.weighting.source == NoiseSource.PRECOMPUTED:
        # Load the noise model from data if available
        noise_model = observation.get_noise_model()
        if noise_model:
            self.logger.info('Loading noise model from data')
            if isinstance(noise_model, Model):
                return noise_model
            if config.binned and isinstance(noise_model, AtmosphericNoiseModel):
                return noise_model.to_white_noise_model()
        self.logger.info('No noise model found for loading')

    # Otherwise, fit the noise model from data
    self.logger.info('Fitting noise model from data')
    f, Pxx = jax.scipy.signal.welch(
        jnp.asarray(observation.get_tods()).astype(config.dtype),
        fs=observation.sample_rate,
        nperseg=config.weighting.fitting.nperseg,
    )
    hwp_frequency = jnp.asarray(observation.get_hwp_frequency())
    return Model.fit_psd_model(
        f,
        Pxx,
        sample_rate=jnp.array(observation.sample_rate),
        hwp_frequency=hwp_frequency,
        config=config.weighting.fitting,
    )

get_pixel_selector(blocks, landscape)

Select indices of map pixels satisfying the hit and condition-number cuts.

Pixels must meet the minimum fractional hits (hits_cut) and condition number (cond_cut) criteria.

Source code in src/furax/mapmaking/mapmaker.py
def get_pixel_selector(
    self, blocks: Float[Array, '... nstokes nstokes'], landscape: StokesLandscape
) -> IndexOperator:
    """Select indices of map pixels satisfying the hit and condition-number cuts.

    Pixels must meet the minimum fractional hits (hits_cut) and condition number
    (cond_cut) criteria.
    """
    config = self.config

    # eigs = jnp.linalg.eigvalsh(blocks)
    eigs = np.linalg.eigvalsh(blocks)
    hits_quantile = np.quantile(eigs[(eigs[..., -1] > 0),], q=0.95)
    valid = jnp.logical_and(
        eigs[..., -1] > config.hits_cut * hits_quantile,
        eigs[..., 0] > config.cond_cut * eigs[..., -1],
    )
    return IndexOperator((..., *jnp.where(valid)), in_structure=landscape.structure)

get_template_operator(observation)

Create a template operator from the provided name and configuration.

Source code in src/furax/mapmaking/mapmaker.py
def get_template_operator(
    self, observation: AbstractGroundObservation[Any]
) -> BlockRowOperator:
    """Create a template operator from the provided name and configuration."""
    config = self.config
    assert config.templates is not None
    blocks: dict[str, AbstractLinearOperator] = {}

    if poly := config.templates.polynomial:
        blocks['polynomial'] = PerDetectorTemplate.polynomial(
            max_poly_order=poly.legendre.max_order,
            intervals=jnp.asarray(observation.get_scanning_intervals()),
            times=jnp.asarray(observation.get_elapsed_times()),
            n_dets=observation.n_detectors,
            dtype=config.dtype,
        )
    if sss := config.templates.scan_synchronous:
        blocks['scan_synchronous'] = PerDetectorTemplate.scan_synchronous(
            legendre=sss.legendre,
            azimuth=jnp.asarray(observation.get_azimuth()),
            n_dets=observation.n_detectors,
            dtype=config.dtype,
        )
    if baz := config.templates.binaz_synchronous:
        blocks['binaz_synchronous'] = PerDetectorTemplate.binaz_synchronous(
            bins=baz.bins,
            azimuth=jnp.asarray(observation.get_azimuth()),
            n_dets=observation.n_detectors,
            dtype=config.dtype,
        )
    if hwpss := config.templates.hwp_synchronous:
        blocks['hwp_synchronous'] = PerDetectorTemplate.hwp_synchronous(
            n_harmonics=hwpss.n_harmonics,
            hwp_angles=jnp.asarray(observation.get_hwp_angles()),
            n_dets=observation.n_detectors,
            dtype=config.dtype,
        )
    if azhwpss := config.templates.azhwp_synchronous:
        azimuth = jnp.asarray(observation.get_azimuth())
        hwp_angles = jnp.asarray(observation.get_hwp_angles())
        if azhwpss.split_scans:
            blocks['azhwp_synchronous_left'] = PerDetectorTemplate.azhwp_synchronous(
                legendre=azhwpss.legendre,
                n_harmonics=azhwpss.n_harmonics,
                azimuth=azimuth,
                hwp_angles=hwp_angles,
                n_dets=observation.n_detectors,
                dtype=config.dtype,
                scan_mask=jnp.asarray(observation.get_left_scan_mask()),
            )
            blocks['azhwp_synchronous_right'] = PerDetectorTemplate.azhwp_synchronous(
                legendre=azhwpss.legendre,
                n_harmonics=azhwpss.n_harmonics,
                azimuth=azimuth,
                hwp_angles=hwp_angles,
                n_dets=observation.n_detectors,
                dtype=config.dtype,
                scan_mask=jnp.asarray(observation.get_right_scan_mask()),
            )
        else:
            blocks['azhwp_synchronous'] = PerDetectorTemplate.azhwp_synchronous(
                legendre=azhwpss.legendre,
                n_harmonics=azhwpss.n_harmonics,
                azimuth=azimuth,
                hwp_angles=hwp_angles,
                n_dets=observation.n_detectors,
                dtype=config.dtype,
            )
    if binazhwpss := config.templates.binazhwp_synchronous:
        blocks['binazhwp_synchronous'] = PerDetectorTemplate.binazhwp_synchronous(
            bins=binazhwpss.bins,
            n_harmonics=binazhwpss.n_harmonics,
            azimuth=jnp.asarray(observation.get_azimuth()),
            hwp_angles=jnp.asarray(observation.get_hwp_angles()),
            n_dets=observation.n_detectors,
            dtype=config.dtype,
        )
    if shwpss := config.templates.spline_hwpss:
        times = jnp.asarray(observation.get_elapsed_times())
        blocks['spline_hwpss'] = PerDetectorTemplate.bspline_hwpss(
            times=times,
            hwp_angles=jnp.asarray(observation.get_hwp_angles()),
            n_dets=observation.n_detectors,
            n_knots=shwpss.resolve_n_knots(times.size),
            harmonics=shwpss.harmonics,
            dtype=config.dtype,
        )
    if ground := config.templates.ground:
        azimuth = jnp.asarray(observation.get_azimuth())
        elevation = jnp.asarray(observation.get_elevation())
        detector_quaternions = jnp.asarray(observation.get_detector_quaternions())
        self._ground_landscape = templates.GroundTemplateOperator.get_landscape(
            azimuth_resolution=ground.azimuth_resolution,
            elevation_resolution=ground.elevation_resolution,
            boresight_azimuth=azimuth,
            boresight_elevation=elevation,
            detector_quaternions=detector_quaternions,
            stokes='IQU',
            dtype=config.dtype,
        )
        ground_op = templates.GroundTemplateOperator.create(
            azimuth_resolution=ground.azimuth_resolution,
            elevation_resolution=ground.elevation_resolution,
            boresight_azimuth=azimuth,
            boresight_elevation=elevation,
            boresight_rotation=jnp.zeros_like(azimuth),
            detector_quaternions=detector_quaternions,
            hwp_angles=jnp.asarray(observation.get_hwp_angles()),
            stokes='IQU',
            dtype=config.dtype,
            landscape=self._ground_landscape,
            batch_size=config.pointing.batch_size,
        )
        ones_tod = jnp.ones(
            (observation.n_detectors, observation.n_samples), dtype=config.dtype
        )
        self._ground_coverage = ground_op.T(ones_tod)
        nonzero_hits = jnp.argwhere(self._ground_coverage.i > 0)
        indexer = IndexOperator(
            (..., nonzero_hits[:, 0], nonzero_hits[:, 1]),
            in_structure=furax.tree.as_structure(self._ground_coverage),
        )
        flattener = furax.asoperator(
            lambda s: jnp.concatenate([s.i, s.q, s.u]),
            in_structure=indexer.out_structure,
        )
        self._ground_selector = flattener @ indexer

        blocks['ground'] = ground_op @ self._ground_selector.T

    return BlockRowOperator(blocks=blocks)

BinnedMapMaker dataclass

Bases: MapMaker

Class for mapmaking with diagonal noise covariance.

Methods:

Source code in src/furax/mapmaking/mapmaker.py
class BinnedMapMaker(MapMaker):
    """Class for mapmaking with diagonal noise covariance."""

    def __post_init__(self) -> None:
        super().__post_init__()

        # Validation on config
        if not self.config.binned:
            raise ValueError('Binned Mapmaker is incompatible with binned=False')

    def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
        config = self.config
        logger_info = lambda msg: self.logger.info(f'Binned Mapmaker: {msg}')

        # Data and landscape
        data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
        data_struct = ShapeDtypeStruct(data.shape, data.dtype)
        landscape = self.get_landscape(observation)

        # Acquisition (I, Q, U Maps -> TOD)
        acquisition = self.get_acquisition(observation, landscape=landscape)
        logger_info('Created acquisition operator')

        # Optional mask for scanning
        masker = self.get_scanning_masker(observation)
        acquisition = masker @ acquisition
        data_struct = masker.out_structure  # Now with a subset of samples
        logger_info('Created scanning mask operator')

        # Noise
        noise_model = self.get_or_fit_noise_model(observation)
        inv_noise = noise_model.inverse_operator(data_struct)
        logger_info('Created inverse noise covariance operator')

        # System matrix
        system = BJPreconditioner.create((acquisition.T @ inv_noise @ acquisition).reduce())
        logger_info('Created system operator')

        # Mapmaking operator
        binner = acquisition.T @ inv_noise @ masker
        mapmaking_operator = system.inverse() @ binner

        @jax.jit
        def process(d):  # type: ignore[no-untyped-def]
            return mapmaking_operator.reduce()(d)

        logger_info('Set up mapmaking operator')

        # Run mapmaking
        res = process(data)
        res.i.block_until_ready()
        logger_info('Finished mapmaking')

        if config.debug:
            res = process(data)
            res.i.block_until_ready()
            logger_info('Test - second time - Finished mapmaking')

        final_map = np.array([res.i, res.q, res.u])
        weights = np.array(system.blocks)

        output = {'map': final_map, 'weights': weights}
        if isinstance(landscape, WCSLandscape):
            output['wcs'] = landscape.to_wcs()
        elif isinstance(landscape, AstropyWCSLandscape):
            output['wcs'] = landscape.wcs
        if (
            config.weighting.source == NoiseSource.FIT
            and config.weighting.mode != WeightingMode.IDENTITY
        ):
            output['noise_fit'] = noise_model.to_array()  # type: ignore[assignment]
        if config.debug:
            proj_map = (masker.T @ acquisition)(res)
            output['proj_map'] = proj_map

        return output

__post_init__()

Source code in src/furax/mapmaking/mapmaker.py
def __post_init__(self) -> None:
    super().__post_init__()

    # Validation on config
    if not self.config.binned:
        raise ValueError('Binned Mapmaker is incompatible with binned=False')

make_map(observation)

Source code in src/furax/mapmaking/mapmaker.py
def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
    config = self.config
    logger_info = lambda msg: self.logger.info(f'Binned Mapmaker: {msg}')

    # Data and landscape
    data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
    data_struct = ShapeDtypeStruct(data.shape, data.dtype)
    landscape = self.get_landscape(observation)

    # Acquisition (I, Q, U Maps -> TOD)
    acquisition = self.get_acquisition(observation, landscape=landscape)
    logger_info('Created acquisition operator')

    # Optional mask for scanning
    masker = self.get_scanning_masker(observation)
    acquisition = masker @ acquisition
    data_struct = masker.out_structure  # Now with a subset of samples
    logger_info('Created scanning mask operator')

    # Noise
    noise_model = self.get_or_fit_noise_model(observation)
    inv_noise = noise_model.inverse_operator(data_struct)
    logger_info('Created inverse noise covariance operator')

    # System matrix
    system = BJPreconditioner.create((acquisition.T @ inv_noise @ acquisition).reduce())
    logger_info('Created system operator')

    # Mapmaking operator
    binner = acquisition.T @ inv_noise @ masker
    mapmaking_operator = system.inverse() @ binner

    @jax.jit
    def process(d):  # type: ignore[no-untyped-def]
        return mapmaking_operator.reduce()(d)

    logger_info('Set up mapmaking operator')

    # Run mapmaking
    res = process(data)
    res.i.block_until_ready()
    logger_info('Finished mapmaking')

    if config.debug:
        res = process(data)
        res.i.block_until_ready()
        logger_info('Test - second time - Finished mapmaking')

    final_map = np.array([res.i, res.q, res.u])
    weights = np.array(system.blocks)

    output = {'map': final_map, 'weights': weights}
    if isinstance(landscape, WCSLandscape):
        output['wcs'] = landscape.to_wcs()
    elif isinstance(landscape, AstropyWCSLandscape):
        output['wcs'] = landscape.wcs
    if (
        config.weighting.source == NoiseSource.FIT
        and config.weighting.mode != WeightingMode.IDENTITY
    ):
        output['noise_fit'] = noise_model.to_array()  # type: ignore[assignment]
    if config.debug:
        proj_map = (masker.T @ acquisition)(res)
        output['proj_map'] = proj_map

    return output

MLMapmaker dataclass

Bases: MapMaker

Class for mapmaking with maximum likelihood (ML) estimator.

Methods:

Source code in src/furax/mapmaking/mapmaker.py
class MLMapmaker(MapMaker):
    """Class for mapmaking with maximum likelihood (ML) estimator."""

    def __post_init__(self) -> None:
        super().__post_init__()

        # Validation on config
        if self.config.binned:
            raise ValueError('ML Mapmaker is incompatible with binned=True')
        if self.config.demodulated:
            raise ValueError('ML Mapmaker is incompatible with demodulated=True')

    def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
        config = self.config
        logger_info = lambda msg: self.logger.info(f'ML Mapmaker: {msg}')

        # Data and landscape
        data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
        data_struct = ShapeDtypeStruct(data.shape, data.dtype)
        landscape = self.get_landscape(observation)

        # Acquisition (I, Q, U Maps -> TOD)
        acquisition = self.get_acquisition(observation, landscape=landscape)
        logger_info('Created acquisition operator')

        # Optional mask for scanning
        masker = self.get_mask_projector(observation)
        valid_sample_fraction = (
            1.0
            if isinstance(masker, IdentityOperator)
            else float(jnp.mean(masker(jnp.ones(data.shape, data.dtype))))
        )
        logger_info('Created mask operator')
        logger_info(f'Valid sample fraction: {valid_sample_fraction:.4f}')

        # Noise
        noise_model = self.get_or_fit_noise_model(observation)
        inv_noise = noise_model.inverse_operator(
            data_struct,
            sample_rate=observation.sample_rate,
            correlation_length=config.weighting.correlation_length,
        )
        noise = noise_model.operator(
            data_struct,
            sample_rate=observation.sample_rate,
            correlation_length=config.weighting.correlation_length,
        )
        logger_info('Created noise and inverse noise covariance operators')

        # Approximate system matrix with diagonal noise covariance and full map pixels
        if config.weighting.mode == WeightingMode.IDENTITY:
            diag_inv_noise = inv_noise
        elif isinstance(inv_noise, SymmetricBandToeplitzOperator):
            diag_inv_noise = DiagonalOperator(
                inv_noise.band_values[..., [0]], in_structure=data_struct
            )
        else:
            raise NotImplementedError
        diag_system = BJPreconditioner.create(acquisition.T @ diag_inv_noise @ masker @ acquisition)
        logger_info('Created approximate system matrix')

        # Map pixel selection
        blocks = diag_system.blocks
        selector = self.get_pixel_selector(blocks, landscape)
        logger_info(
            f'Selected {prod(selector.out_structure.shape)}\
                            /{prod(landscape.shape)} pixels'
        )

        # Adjust the sample mask according to the new pixel selection
        positive_sample_hits = (
            (masker @ acquisition @ selector.T)(
                StokesIQU.from_iquv(
                    i=jnp.ones(selector.out_structure.shape, dtype=data.dtype),
                    q=jnp.zeros(selector.out_structure.shape, dtype=data.dtype),
                    u=jnp.zeros(selector.out_structure.shape, dtype=data.dtype),
                    v=None,  # type: ignore[arg-type]
                )
            )
            > 0
        ).astype(data.dtype)
        masker = DiagonalOperator(positive_sample_hits, in_structure=data_struct)
        logger_info(f'Updated valid sample fraction: {jnp.mean(masker._diagonal):.4f}')

        # Preconditioner
        # We use the approximate diagonal system matrix before the mask update
        preconditioner = selector @ diag_system.inverse() @ selector.T

        # Templates (optional)
        if config.use_templates:
            template_op = self.get_template_operator(observation)
            logger_info('Built template operators')
            REGVAL = config.templates.regularization  # type: ignore[union-attr]
            tmpl_inv_sys = {}
            regs = {}
            # Pass the operator as an explicit argument so JAX traces its arrays as inputs
            # rather than hashing it as the jit fun (operators carry unhashable leaves).
            apply = jax.jit(lambda op, v: op(v))
            for tmpl, tmpl_op in template_op.blocks.items():
                tmpl_sys = (tmpl_op.T @ diag_inv_noise @ masker @ tmpl_op).reduce()
                # Approximation to the diagonal of the matrix
                norm_sys = jnp.abs(apply(tmpl_sys, furax.tree.ones_like(tmpl_op.in_structure)))
                # Regualrisation value is REGVAL times the smallest non-zero eigenvalue
                regs[tmpl] = REGVAL * jnp.min(norm_sys[norm_sys > 0])
                tmpl_inv_sys[tmpl] = DiagonalOperator(
                    (norm_sys + regs[tmpl]),
                    in_structure=tmpl_op.in_structure,
                ).inverse()
            template_preconditioner = BlockDiagonalOperator(tmpl_inv_sys)
            logger_info('Built template preconditioner')
            template_reg_op = BlockDiagonalOperator(
                [
                    DiagonalOperator(jnp.array([0.0]), in_structure=selector.out_structure),
                    {
                        tmpl: regs[tmpl]
                        * IdentityOperator(in_structure=template_op.blocks[tmpl].in_structure)
                        for tmpl in template_op.blocks.keys()
                    },
                ]
            )
            logger_info('Built template regularizer')
            logger_info(f'Template operator input structure: {template_op.in_structure}')

        # Mapmaking operator
        p: AbstractLinearOperator
        h: AbstractLinearOperator
        if config.use_templates:
            p = BlockDiagonalOperator([preconditioner, template_preconditioner])
            h = BlockRowOperator([acquisition @ selector.T, template_op])
            reg = template_reg_op
        else:
            p = preconditioner
            h = acquisition @ selector.T

        if config.gaps.treatment != GapTreatment.NESTED:
            M = masker @ inv_noise @ masker
        else:
            nested_solver = lineax.CG(
                rtol=config.gaps.nested.rtol,
                atol=config.gaps.nested.atol,
                max_steps=config.gaps.nested.inner_steps,
            )
            M = (
                masker
                @ (masker @ noise @ masker).I(
                    solver=nested_solver,
                    preconditioner=masker @ inv_noise @ masker,
                )
                @ masker
            )
            logger_info('Set up nested PCG for the noise inverse')

        solver = lineax.CG(**asdict(config.solver))
        options = {'solver': solver, 'preconditioner': p}
        if config.use_templates:
            mapmaking_operator = (h.T @ M @ h + reg).I(**options) @ h.T @ M
        else:
            mapmaking_operator = (h.T @ M @ h).I(**options) @ h.T @ M

        @jax.jit
        def process(d):  # type: ignore[no-untyped-def]
            return mapmaking_operator.reduce()(d)

        logger_info('Completed setting up the solver')

        # Run mapmaking
        if config.use_templates:
            rec_map, tmpl_ampl = process(data)
        else:
            rec_map = process(data)
        result_map = selector.T(rec_map)
        result_map.i.block_until_ready()
        logger_info('Finished mapmaking computation')

        # Get weights after pixel selection
        weights = jnp.zeros_like(blocks)
        weights = weights.at[selector.indices + (slice(None), slice(None))].add(
            blocks[selector.indices + (slice(None), slice(None))]
        )

        # Format output and compute auxiliary data
        final_map = np.array([result_map.i, result_map.q, result_map.u])

        output = {'map': final_map, 'weights': weights, 'weights_uncut': blocks}
        if isinstance(landscape, WCSLandscape):
            output['wcs'] = landscape.to_wcs()
        elif isinstance(landscape, AstropyWCSLandscape):
            output['wcs'] = landscape.wcs
        if (
            config.weighting.source == NoiseSource.FIT
            and config.weighting.mode != WeightingMode.IDENTITY
        ):
            output['noise_fit'] = noise_model.to_array()
        if config.use_templates:
            for key in tmpl_ampl.keys():
                output[f'template_{key}'] = tmpl_ampl[key]
                output[f'template_reg_{key}'] = np.array(regs[key])
            if 'ground' in tmpl_ampl.keys():
                output['ground_landscape'] = self._ground_landscape
                output['ground_coverage'] = self._ground_coverage
                output['ground_map'] = self._ground_selector.T(tmpl_ampl['ground'])
        if config.debug:
            proj_map = (masker @ acquisition)(result_map)
            if config.use_templates:
                projs = {
                    'proj_map': proj_map,
                    **{
                        f'proj_{tmpl}': (masker @ template_op.blocks[tmpl])(tmpl_ampl[tmpl])
                        for tmpl in tmpl_ampl
                    },
                }
            else:
                projs = {'proj_map': proj_map}
            output['projs'] = projs

        return output

__post_init__()

Source code in src/furax/mapmaking/mapmaker.py
def __post_init__(self) -> None:
    super().__post_init__()

    # Validation on config
    if self.config.binned:
        raise ValueError('ML Mapmaker is incompatible with binned=True')
    if self.config.demodulated:
        raise ValueError('ML Mapmaker is incompatible with demodulated=True')

make_map(observation)

Source code in src/furax/mapmaking/mapmaker.py
def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
    config = self.config
    logger_info = lambda msg: self.logger.info(f'ML Mapmaker: {msg}')

    # Data and landscape
    data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
    data_struct = ShapeDtypeStruct(data.shape, data.dtype)
    landscape = self.get_landscape(observation)

    # Acquisition (I, Q, U Maps -> TOD)
    acquisition = self.get_acquisition(observation, landscape=landscape)
    logger_info('Created acquisition operator')

    # Optional mask for scanning
    masker = self.get_mask_projector(observation)
    valid_sample_fraction = (
        1.0
        if isinstance(masker, IdentityOperator)
        else float(jnp.mean(masker(jnp.ones(data.shape, data.dtype))))
    )
    logger_info('Created mask operator')
    logger_info(f'Valid sample fraction: {valid_sample_fraction:.4f}')

    # Noise
    noise_model = self.get_or_fit_noise_model(observation)
    inv_noise = noise_model.inverse_operator(
        data_struct,
        sample_rate=observation.sample_rate,
        correlation_length=config.weighting.correlation_length,
    )
    noise = noise_model.operator(
        data_struct,
        sample_rate=observation.sample_rate,
        correlation_length=config.weighting.correlation_length,
    )
    logger_info('Created noise and inverse noise covariance operators')

    # Approximate system matrix with diagonal noise covariance and full map pixels
    if config.weighting.mode == WeightingMode.IDENTITY:
        diag_inv_noise = inv_noise
    elif isinstance(inv_noise, SymmetricBandToeplitzOperator):
        diag_inv_noise = DiagonalOperator(
            inv_noise.band_values[..., [0]], in_structure=data_struct
        )
    else:
        raise NotImplementedError
    diag_system = BJPreconditioner.create(acquisition.T @ diag_inv_noise @ masker @ acquisition)
    logger_info('Created approximate system matrix')

    # Map pixel selection
    blocks = diag_system.blocks
    selector = self.get_pixel_selector(blocks, landscape)
    logger_info(
        f'Selected {prod(selector.out_structure.shape)}\
                        /{prod(landscape.shape)} pixels'
    )

    # Adjust the sample mask according to the new pixel selection
    positive_sample_hits = (
        (masker @ acquisition @ selector.T)(
            StokesIQU.from_iquv(
                i=jnp.ones(selector.out_structure.shape, dtype=data.dtype),
                q=jnp.zeros(selector.out_structure.shape, dtype=data.dtype),
                u=jnp.zeros(selector.out_structure.shape, dtype=data.dtype),
                v=None,  # type: ignore[arg-type]
            )
        )
        > 0
    ).astype(data.dtype)
    masker = DiagonalOperator(positive_sample_hits, in_structure=data_struct)
    logger_info(f'Updated valid sample fraction: {jnp.mean(masker._diagonal):.4f}')

    # Preconditioner
    # We use the approximate diagonal system matrix before the mask update
    preconditioner = selector @ diag_system.inverse() @ selector.T

    # Templates (optional)
    if config.use_templates:
        template_op = self.get_template_operator(observation)
        logger_info('Built template operators')
        REGVAL = config.templates.regularization  # type: ignore[union-attr]
        tmpl_inv_sys = {}
        regs = {}
        # Pass the operator as an explicit argument so JAX traces its arrays as inputs
        # rather than hashing it as the jit fun (operators carry unhashable leaves).
        apply = jax.jit(lambda op, v: op(v))
        for tmpl, tmpl_op in template_op.blocks.items():
            tmpl_sys = (tmpl_op.T @ diag_inv_noise @ masker @ tmpl_op).reduce()
            # Approximation to the diagonal of the matrix
            norm_sys = jnp.abs(apply(tmpl_sys, furax.tree.ones_like(tmpl_op.in_structure)))
            # Regualrisation value is REGVAL times the smallest non-zero eigenvalue
            regs[tmpl] = REGVAL * jnp.min(norm_sys[norm_sys > 0])
            tmpl_inv_sys[tmpl] = DiagonalOperator(
                (norm_sys + regs[tmpl]),
                in_structure=tmpl_op.in_structure,
            ).inverse()
        template_preconditioner = BlockDiagonalOperator(tmpl_inv_sys)
        logger_info('Built template preconditioner')
        template_reg_op = BlockDiagonalOperator(
            [
                DiagonalOperator(jnp.array([0.0]), in_structure=selector.out_structure),
                {
                    tmpl: regs[tmpl]
                    * IdentityOperator(in_structure=template_op.blocks[tmpl].in_structure)
                    for tmpl in template_op.blocks.keys()
                },
            ]
        )
        logger_info('Built template regularizer')
        logger_info(f'Template operator input structure: {template_op.in_structure}')

    # Mapmaking operator
    p: AbstractLinearOperator
    h: AbstractLinearOperator
    if config.use_templates:
        p = BlockDiagonalOperator([preconditioner, template_preconditioner])
        h = BlockRowOperator([acquisition @ selector.T, template_op])
        reg = template_reg_op
    else:
        p = preconditioner
        h = acquisition @ selector.T

    if config.gaps.treatment != GapTreatment.NESTED:
        M = masker @ inv_noise @ masker
    else:
        nested_solver = lineax.CG(
            rtol=config.gaps.nested.rtol,
            atol=config.gaps.nested.atol,
            max_steps=config.gaps.nested.inner_steps,
        )
        M = (
            masker
            @ (masker @ noise @ masker).I(
                solver=nested_solver,
                preconditioner=masker @ inv_noise @ masker,
            )
            @ masker
        )
        logger_info('Set up nested PCG for the noise inverse')

    solver = lineax.CG(**asdict(config.solver))
    options = {'solver': solver, 'preconditioner': p}
    if config.use_templates:
        mapmaking_operator = (h.T @ M @ h + reg).I(**options) @ h.T @ M
    else:
        mapmaking_operator = (h.T @ M @ h).I(**options) @ h.T @ M

    @jax.jit
    def process(d):  # type: ignore[no-untyped-def]
        return mapmaking_operator.reduce()(d)

    logger_info('Completed setting up the solver')

    # Run mapmaking
    if config.use_templates:
        rec_map, tmpl_ampl = process(data)
    else:
        rec_map = process(data)
    result_map = selector.T(rec_map)
    result_map.i.block_until_ready()
    logger_info('Finished mapmaking computation')

    # Get weights after pixel selection
    weights = jnp.zeros_like(blocks)
    weights = weights.at[selector.indices + (slice(None), slice(None))].add(
        blocks[selector.indices + (slice(None), slice(None))]
    )

    # Format output and compute auxiliary data
    final_map = np.array([result_map.i, result_map.q, result_map.u])

    output = {'map': final_map, 'weights': weights, 'weights_uncut': blocks}
    if isinstance(landscape, WCSLandscape):
        output['wcs'] = landscape.to_wcs()
    elif isinstance(landscape, AstropyWCSLandscape):
        output['wcs'] = landscape.wcs
    if (
        config.weighting.source == NoiseSource.FIT
        and config.weighting.mode != WeightingMode.IDENTITY
    ):
        output['noise_fit'] = noise_model.to_array()
    if config.use_templates:
        for key in tmpl_ampl.keys():
            output[f'template_{key}'] = tmpl_ampl[key]
            output[f'template_reg_{key}'] = np.array(regs[key])
        if 'ground' in tmpl_ampl.keys():
            output['ground_landscape'] = self._ground_landscape
            output['ground_coverage'] = self._ground_coverage
            output['ground_map'] = self._ground_selector.T(tmpl_ampl['ground'])
    if config.debug:
        proj_map = (masker @ acquisition)(result_map)
        if config.use_templates:
            projs = {
                'proj_map': proj_map,
                **{
                    f'proj_{tmpl}': (masker @ template_op.blocks[tmpl])(tmpl_ampl[tmpl])
                    for tmpl in tmpl_ampl
                },
            }
        else:
            projs = {'proj_map': proj_map}
        output['projs'] = projs

    return output

TwoStepMapmaker dataclass

Bases: MapMaker

Class for binned mapmaking with templates, using the two-step estimation method.

Methods:

Source code in src/furax/mapmaking/mapmaker.py
class TwoStepMapmaker(MapMaker):
    """Class for binned mapmaking with templates, using the two-step estimation method."""

    def __post_init__(self) -> None:
        super().__post_init__()

        # Validation on config
        if not self.config.binned:
            raise ValueError('Two-Step Mapmaker is incompatible with binned=False')
        if self.config.demodulated:
            raise ValueError('Two-Step Mapmaker is incompatible with demodulated=True')
        if not self.config.use_templates:
            raise ValueError('Two-Step Mapmaker is incompatible with no templates')

    def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
        config = self.config
        logger_info = lambda msg: self.logger.info(f'Two-Step Mapmaker: {msg}')

        # Data and landscape
        data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
        data_struct = ShapeDtypeStruct(data.shape, data.dtype)
        landscape = self.get_landscape(observation)

        # Acquisition (I, Q, U Maps -> TOD)
        acquisition = self.get_acquisition(observation, landscape=landscape)
        logger_info('Created acquisition operator')

        # Optional mask for scanning
        masker = self.get_scanning_mask_projector(observation)
        logger_info('Created scanning mask operator')

        # Noise
        noise_model = self.get_or_fit_noise_model(observation)
        inv_noise = noise_model.inverse_operator(data_struct)
        logger_info('Created inverse noise covariance operator')

        # System matrix
        system = BJPreconditioner.create(acquisition.T @ masker @ inv_noise @ masker @ acquisition)
        logger_info('Created system matrix')

        # Map pixel selection
        blocks = system.blocks
        selector = self.get_pixel_selector(blocks, landscape)
        logger_info(
            f'Selected {prod(selector.out_structure.shape)}\
                            /{prod(landscape.shape)} pixels'
        )

        # Templates
        template_op = self.get_template_operator(observation)
        logger_info('Built template operators')

        # Define operators
        system_inv = selector @ system.inverse() @ selector.T
        A = acquisition @ selector.T
        M = inv_noise
        mp = masker
        FA = M - M @ mp @ A @ system_inv @ A.T @ mp @ M

        solver = lineax.CG(**asdict(config.solver))
        with Config(solver=solver):
            template_estimator = (
                (template_op.T @ mp @ FA @ mp @ template_op).I @ template_op.T @ mp @ FA @ mp
            )
        map_estimator = system_inv @ A.T @ mp @ M @ mp

        @jax.jit
        def process(d):  # type: ignore[no-untyped-def]
            x = template_estimator(d)  # Template amplitude estimates
            s = map_estimator(d - template_op(x))  # Map estimates
            return s, x

        logger_info('Completed setting up the solver')

        # Run mapmaking
        rec_map, tmpl_ampl = process(data)
        result_map = selector.T(rec_map)
        result_map.i.block_until_ready()
        logger_info('Finished mapmaking computation')

        # Format output and compute auxiliary data
        final_map = np.array([result_map.i, result_map.q, result_map.u])

        output = {'map': final_map, 'weights': blocks}
        for key in tmpl_ampl.keys():
            output[f'template_{key}'] = tmpl_ampl[key]
        if isinstance(landscape, WCSLandscape):
            output['wcs'] = landscape.to_wcs()
        elif isinstance(landscape, AstropyWCSLandscape):
            output['wcs'] = landscape.wcs
        if (
            config.weighting.source == NoiseSource.FIT
            and config.weighting.mode != WeightingMode.IDENTITY
        ):
            output['noise_fit'] = noise_model.to_array()
        if config.debug:
            proj_map = (mp @ acquisition)(result_map)
            projs = {
                'proj_map': proj_map,
                **{
                    f'proj_{tmpl}': (mp @ template_op.blocks[tmpl])(tmpl_ampl[tmpl])
                    for tmpl in tmpl_ampl
                },
            }
            output['projs'] = projs

        return output

__post_init__()

Source code in src/furax/mapmaking/mapmaker.py
def __post_init__(self) -> None:
    super().__post_init__()

    # Validation on config
    if not self.config.binned:
        raise ValueError('Two-Step Mapmaker is incompatible with binned=False')
    if self.config.demodulated:
        raise ValueError('Two-Step Mapmaker is incompatible with demodulated=True')
    if not self.config.use_templates:
        raise ValueError('Two-Step Mapmaker is incompatible with no templates')

make_map(observation)

Source code in src/furax/mapmaking/mapmaker.py
def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
    config = self.config
    logger_info = lambda msg: self.logger.info(f'Two-Step Mapmaker: {msg}')

    # Data and landscape
    data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
    data_struct = ShapeDtypeStruct(data.shape, data.dtype)
    landscape = self.get_landscape(observation)

    # Acquisition (I, Q, U Maps -> TOD)
    acquisition = self.get_acquisition(observation, landscape=landscape)
    logger_info('Created acquisition operator')

    # Optional mask for scanning
    masker = self.get_scanning_mask_projector(observation)
    logger_info('Created scanning mask operator')

    # Noise
    noise_model = self.get_or_fit_noise_model(observation)
    inv_noise = noise_model.inverse_operator(data_struct)
    logger_info('Created inverse noise covariance operator')

    # System matrix
    system = BJPreconditioner.create(acquisition.T @ masker @ inv_noise @ masker @ acquisition)
    logger_info('Created system matrix')

    # Map pixel selection
    blocks = system.blocks
    selector = self.get_pixel_selector(blocks, landscape)
    logger_info(
        f'Selected {prod(selector.out_structure.shape)}\
                        /{prod(landscape.shape)} pixels'
    )

    # Templates
    template_op = self.get_template_operator(observation)
    logger_info('Built template operators')

    # Define operators
    system_inv = selector @ system.inverse() @ selector.T
    A = acquisition @ selector.T
    M = inv_noise
    mp = masker
    FA = M - M @ mp @ A @ system_inv @ A.T @ mp @ M

    solver = lineax.CG(**asdict(config.solver))
    with Config(solver=solver):
        template_estimator = (
            (template_op.T @ mp @ FA @ mp @ template_op).I @ template_op.T @ mp @ FA @ mp
        )
    map_estimator = system_inv @ A.T @ mp @ M @ mp

    @jax.jit
    def process(d):  # type: ignore[no-untyped-def]
        x = template_estimator(d)  # Template amplitude estimates
        s = map_estimator(d - template_op(x))  # Map estimates
        return s, x

    logger_info('Completed setting up the solver')

    # Run mapmaking
    rec_map, tmpl_ampl = process(data)
    result_map = selector.T(rec_map)
    result_map.i.block_until_ready()
    logger_info('Finished mapmaking computation')

    # Format output and compute auxiliary data
    final_map = np.array([result_map.i, result_map.q, result_map.u])

    output = {'map': final_map, 'weights': blocks}
    for key in tmpl_ampl.keys():
        output[f'template_{key}'] = tmpl_ampl[key]
    if isinstance(landscape, WCSLandscape):
        output['wcs'] = landscape.to_wcs()
    elif isinstance(landscape, AstropyWCSLandscape):
        output['wcs'] = landscape.wcs
    if (
        config.weighting.source == NoiseSource.FIT
        and config.weighting.mode != WeightingMode.IDENTITY
    ):
        output['noise_fit'] = noise_model.to_array()
    if config.debug:
        proj_map = (mp @ acquisition)(result_map)
        projs = {
            'proj_map': proj_map,
            **{
                f'proj_{tmpl}': (mp @ template_op.blocks[tmpl])(tmpl_ampl[tmpl])
                for tmpl in tmpl_ampl
            },
        }
        output['projs'] = projs

    return output

ATOPMapMaker dataclass

Bases: MapMaker

Class for ATOP mapmaking with diagonal noise covariance.

Methods:

Source code in src/furax/mapmaking/mapmaker.py
class ATOPMapMaker(MapMaker):
    """Class for ATOP mapmaking with diagonal noise covariance."""

    def __post_init__(self) -> None:
        super().__post_init__()

        # Validation on config
        if not self.config.binned:
            raise ValueError('ATOP Mapmaker is currently incompatible with binned=False')
        if self.config.atop_tau < 2:
            raise ValueError('ATOP tau should be at least 2')
        if self.config.landscape.stokes != 'QU':
            raise ValueError('ATOP only compatible with stokes=QU')

    def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
        config = self.config
        logger_info = lambda msg: self.logger.info(f'ATOP Mapmaker: {msg}')

        # Data and landscape
        data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
        data_struct = ShapeDtypeStruct(data.shape, data.dtype)
        landscape = self.get_landscape(observation)

        # Acquisition (I, Q, U Maps -> TOD)
        acquisition = self.get_acquisition(observation, landscape=landscape)
        logger_info('Created acquisition operator')

        # ATOP projector
        atop_projector = templates.ATOPProjectionOperator(
            self.config.atop_tau, in_structure=data_struct
        )

        # Optional mask for scanning
        masker = self.get_mask_projector(observation)
        valid_sample_fraction = (
            1.0
            if isinstance(masker, IdentityOperator)
            else float(jnp.mean(masker(jnp.ones(data.shape, data.dtype))))
        )
        logger_info('Created mask operator')
        logger_info(f'Valid sample fraction: {valid_sample_fraction:.4f}')

        # Additionally, mask all tau-intervals with any masked samples
        tau_mask = jnp.abs(atop_projector(masker(jnp.ones_like(data)))) < 0.5 / config.atop_tau
        masker @= MaskOperator.from_boolean_mask(tau_mask, in_structure=data_struct)
        valid_sample_fraction = float(jnp.mean(masker(jnp.ones(data.shape, data.dtype))))
        logger_info(f'Updated valid sample fraction: {valid_sample_fraction:.4f}')

        # Noise
        noise_model = self.get_or_fit_noise_model(observation)
        inv_noise = noise_model.inverse_operator(data_struct)
        logger_info('Created inverse noise covariance operator')

        # Approximate system matrix with diagonal noise covariance and full map pixels
        diag_system = BJPreconditioner.create(
            (acquisition.T @ inv_noise @ masker @ acquisition).reduce()
        )
        logger_info('Created approximate system matrix')

        # Map pixel selection
        blocks = diag_system.blocks
        selector = self.get_pixel_selector(blocks, landscape)
        logger_info(
            f'Selected {prod(selector.out_structure.shape)}\
                            /{prod(landscape.shape)} pixels'
        )

        # Preconditioner
        preconditioner = selector @ diag_system.inverse() @ selector.T

        # Mapmaking operators
        h = acquisition @ selector.T
        mp = masker
        ap = inv_noise @ atop_projector
        lhs = h.T @ mp @ ap @ mp @ h
        rhs_op = jax.jit(lambda d: (h.T @ mp @ ap @ mp).reduce()(d))

        solver = lineax.CG(**asdict(self.config.solver))
        spd = OperatorTag.POSITIVE_SEMIDEFINITE
        lx_system = as_lineax_operator(lhs, spd)
        lx_precond = as_lineax_operator(preconditioner.reduce(), spd)
        logger_info('Completed setting up the solver')

        # Run mapmaking
        rhs = rhs_op(data)
        y0 = preconditioner(rhs)
        solution = lineax.linear_solve(
            lx_system,
            rhs,
            solver=solver,
            options={'preconditioner': lx_precond, 'y0': y0},
            throw=False,
        )
        result_map = selector.T(solution.value)
        result_map.q.block_until_ready()
        num_steps = solution.stats['num_steps']
        logger_info(f'Finished mapmaking computation. Number of PCG steps: {num_steps}')

        # Format output and compute auxiliary data
        final_map = np.array([result_map.q, result_map.u])

        output = {'map': final_map, 'weights': blocks}
        if isinstance(landscape, AstropyWCSLandscape):
            output['wcs'] = landscape.wcs
        if (
            config.weighting.source == NoiseSource.FIT
            and config.weighting.mode != WeightingMode.IDENTITY
        ):
            output['noise_fit'] = noise_model.to_array()
        if config.debug:
            proj_map = (mp @ acquisition)(result_map)
            output['proj_map'] = proj_map

        return output

__post_init__()

Source code in src/furax/mapmaking/mapmaker.py
def __post_init__(self) -> None:
    super().__post_init__()

    # Validation on config
    if not self.config.binned:
        raise ValueError('ATOP Mapmaker is currently incompatible with binned=False')
    if self.config.atop_tau < 2:
        raise ValueError('ATOP tau should be at least 2')
    if self.config.landscape.stokes != 'QU':
        raise ValueError('ATOP only compatible with stokes=QU')

make_map(observation)

Source code in src/furax/mapmaking/mapmaker.py
def make_map(self, observation: AbstractGroundObservation[Any]) -> dict[str, Any]:
    config = self.config
    logger_info = lambda msg: self.logger.info(f'ATOP Mapmaker: {msg}')

    # Data and landscape
    data = jnp.asarray(observation.get_tods(), dtype=config.dtype)
    data_struct = ShapeDtypeStruct(data.shape, data.dtype)
    landscape = self.get_landscape(observation)

    # Acquisition (I, Q, U Maps -> TOD)
    acquisition = self.get_acquisition(observation, landscape=landscape)
    logger_info('Created acquisition operator')

    # ATOP projector
    atop_projector = templates.ATOPProjectionOperator(
        self.config.atop_tau, in_structure=data_struct
    )

    # Optional mask for scanning
    masker = self.get_mask_projector(observation)
    valid_sample_fraction = (
        1.0
        if isinstance(masker, IdentityOperator)
        else float(jnp.mean(masker(jnp.ones(data.shape, data.dtype))))
    )
    logger_info('Created mask operator')
    logger_info(f'Valid sample fraction: {valid_sample_fraction:.4f}')

    # Additionally, mask all tau-intervals with any masked samples
    tau_mask = jnp.abs(atop_projector(masker(jnp.ones_like(data)))) < 0.5 / config.atop_tau
    masker @= MaskOperator.from_boolean_mask(tau_mask, in_structure=data_struct)
    valid_sample_fraction = float(jnp.mean(masker(jnp.ones(data.shape, data.dtype))))
    logger_info(f'Updated valid sample fraction: {valid_sample_fraction:.4f}')

    # Noise
    noise_model = self.get_or_fit_noise_model(observation)
    inv_noise = noise_model.inverse_operator(data_struct)
    logger_info('Created inverse noise covariance operator')

    # Approximate system matrix with diagonal noise covariance and full map pixels
    diag_system = BJPreconditioner.create(
        (acquisition.T @ inv_noise @ masker @ acquisition).reduce()
    )
    logger_info('Created approximate system matrix')

    # Map pixel selection
    blocks = diag_system.blocks
    selector = self.get_pixel_selector(blocks, landscape)
    logger_info(
        f'Selected {prod(selector.out_structure.shape)}\
                        /{prod(landscape.shape)} pixels'
    )

    # Preconditioner
    preconditioner = selector @ diag_system.inverse() @ selector.T

    # Mapmaking operators
    h = acquisition @ selector.T
    mp = masker
    ap = inv_noise @ atop_projector
    lhs = h.T @ mp @ ap @ mp @ h
    rhs_op = jax.jit(lambda d: (h.T @ mp @ ap @ mp).reduce()(d))

    solver = lineax.CG(**asdict(self.config.solver))
    spd = OperatorTag.POSITIVE_SEMIDEFINITE
    lx_system = as_lineax_operator(lhs, spd)
    lx_precond = as_lineax_operator(preconditioner.reduce(), spd)
    logger_info('Completed setting up the solver')

    # Run mapmaking
    rhs = rhs_op(data)
    y0 = preconditioner(rhs)
    solution = lineax.linear_solve(
        lx_system,
        rhs,
        solver=solver,
        options={'preconditioner': lx_precond, 'y0': y0},
        throw=False,
    )
    result_map = selector.T(solution.value)
    result_map.q.block_until_ready()
    num_steps = solution.stats['num_steps']
    logger_info(f'Finished mapmaking computation. Number of PCG steps: {num_steps}')

    # Format output and compute auxiliary data
    final_map = np.array([result_map.q, result_map.u])

    output = {'map': final_map, 'weights': blocks}
    if isinstance(landscape, AstropyWCSLandscape):
        output['wcs'] = landscape.wcs
    if (
        config.weighting.source == NoiseSource.FIT
        and config.weighting.mode != WeightingMode.IDENTITY
    ):
        output['noise_fit'] = noise_model.to_array()
    if config.debug:
        proj_map = (mp @ acquisition)(result_map)
        output['proj_map'] = proj_map

    return output

IQUModulationOperator

Bases: AbstractLinearOperator

Add the input Stokes signals into a single HWP-modulated signal.

Similar to LinearPolarizerOperator @ QURotationOperator(hwp_angle), except that only half of the QU rotation needs to be computed.

Methods:

Attributes:

Source code in src/furax/mapmaking/mapmaker.py
class IQUModulationOperator(AbstractLinearOperator):
    """Add the input Stokes signals into a single HWP-modulated signal.

    Similar to ``LinearPolarizerOperator @ QURotationOperator(hwp_angle)``, except that
    only half of the QU rotation needs to be computed.
    """

    cos_hwp_angle: Float[Array, ' samps']
    sin_hwp_angle: Float[Array, ' samps']

    def __init__(
        self,
        shape: tuple[int, ...],
        hwp_angle: Float[Array, '...'],
        dtype: DTypeLike = jnp.float32,
    ) -> None:
        in_structure = Stokes.class_for('IQU').structure_for(shape, dtype)
        object.__setattr__(self, 'cos_hwp_angle', jnp.cos(4 * hwp_angle.astype(dtype)))
        object.__setattr__(self, 'sin_hwp_angle', jnp.sin(4 * hwp_angle.astype(dtype)))
        object.__setattr__(self, 'in_structure', in_structure)

    def mv(self, x: StokesType) -> Float[Array, '...']:
        return x.i + self.cos_hwp_angle[None, :] * x.q + self.sin_hwp_angle[None, :] * x.u

cos_hwp_angle instance-attribute

sin_hwp_angle instance-attribute

__init__(shape, hwp_angle, dtype=jnp.float32)

Source code in src/furax/mapmaking/mapmaker.py
def __init__(
    self,
    shape: tuple[int, ...],
    hwp_angle: Float[Array, '...'],
    dtype: DTypeLike = jnp.float32,
) -> None:
    in_structure = Stokes.class_for('IQU').structure_for(shape, dtype)
    object.__setattr__(self, 'cos_hwp_angle', jnp.cos(4 * hwp_angle.astype(dtype)))
    object.__setattr__(self, 'sin_hwp_angle', jnp.sin(4 * hwp_angle.astype(dtype)))
    object.__setattr__(self, 'in_structure', in_structure)

mv(x)

Source code in src/furax/mapmaking/mapmaker.py
def mv(self, x: StokesType) -> Float[Array, '...']:
    return x.i + self.cos_hwp_angle[None, :] * x.q + self.sin_hwp_angle[None, :] * x.u

QUModulationOperator

Bases: AbstractLinearOperator

Add the input Stokes signals into a single HWP-modulated signal.

Similar to LinearPolarizerOperator @ QURotationOperator(hwp_angle), except that only half of the QU rotation needs to be computed.

Methods:

Attributes:

Source code in src/furax/mapmaking/mapmaker.py
class QUModulationOperator(AbstractLinearOperator):
    """Add the input Stokes signals into a single HWP-modulated signal.

    Similar to ``LinearPolarizerOperator @ QURotationOperator(hwp_angle)``, except that
    only half of the QU rotation needs to be computed.
    """

    cos_hwp_angle: Float[Array, ' samps']
    sin_hwp_angle: Float[Array, ' samps']

    def __init__(
        self,
        shape: tuple[int, ...],
        hwp_angle: Float[Array, '...'],
        dtype: DTypeLike = jnp.float32,
    ) -> None:
        in_structure = Stokes.class_for('QU').structure_for(shape, dtype)
        object.__setattr__(self, 'cos_hwp_angle', jnp.cos(4 * hwp_angle.astype(dtype)))
        object.__setattr__(self, 'sin_hwp_angle', jnp.sin(4 * hwp_angle.astype(dtype)))
        object.__setattr__(self, 'in_structure', in_structure)

    def mv(self, x: StokesType) -> Float[Array, '...']:
        return self.cos_hwp_angle[None, :] * x.q + self.sin_hwp_angle[None, :] * x.u

cos_hwp_angle instance-attribute

sin_hwp_angle instance-attribute

__init__(shape, hwp_angle, dtype=jnp.float32)

Source code in src/furax/mapmaking/mapmaker.py
def __init__(
    self,
    shape: tuple[int, ...],
    hwp_angle: Float[Array, '...'],
    dtype: DTypeLike = jnp.float32,
) -> None:
    in_structure = Stokes.class_for('QU').structure_for(shape, dtype)
    object.__setattr__(self, 'cos_hwp_angle', jnp.cos(4 * hwp_angle.astype(dtype)))
    object.__setattr__(self, 'sin_hwp_angle', jnp.sin(4 * hwp_angle.astype(dtype)))
    object.__setattr__(self, 'in_structure', in_structure)

mv(x)

Source code in src/furax/mapmaking/mapmaker.py
def mv(self, x: StokesType) -> Float[Array, '...']:
    return self.cos_hwp_angle[None, :] * x.q + self.sin_hwp_angle[None, :] * x.u

get_obs_distribution_to_process(n_obs, rank=None, n_proc=None, n_local=None)

Compute this process's slice for distributed mapmaking.

Distributes n_obs observations across processes as evenly as possible (first n_obs % n_proc processes get one extra), then pads each process's share to the next multiple of n_local so every device has a uniform workload. All processes end up with the same number of total slots (n_owned + n_pad), which is required for multi-process sharding.

Parameters:

  • n_obs (int) –

    Total number of observations across all processes.

  • rank (int | None, default: None ) –

    Process index. Defaults to jax.process_index().

  • n_proc (int | None, default: None ) –

    Process count. Defaults to jax.process_count().

  • n_local (int | None, default: None ) –

    Local device count. Defaults to jax.local_device_count().

Returns:

  • int

    A tuple (start, n_owned, n_pad) where start is the index of the

  • int

    first real observation owned by this process, n_owned is the number

  • int

    of real observations, and n_pad is the number of padding slots so that

  • tuple[int, int, int]

    n_owned + n_pad is a multiple of n_local.

Raises:

Source code in src/furax/mapmaking/mapmaker.py
def get_obs_distribution_to_process(
    n_obs: int,
    rank: int | None = None,
    n_proc: int | None = None,
    n_local: int | None = None,
) -> tuple[int, int, int]:
    """Compute this process's slice for distributed mapmaking.

    Distributes ``n_obs`` observations across processes as evenly as possible
    (first ``n_obs % n_proc`` processes get one extra), then pads each process's
    share to the next multiple of ``n_local`` so every device has a uniform
    workload.  All processes end up with the same number of total slots
    (``n_owned + n_pad``), which is required for multi-process sharding.

    Args:
        n_obs: Total number of observations across all processes.
        rank: Process index. Defaults to ``jax.process_index()``.
        n_proc: Process count. Defaults to ``jax.process_count()``.
        n_local: Local device count. Defaults to ``jax.local_device_count()``.

    Returns:
        A tuple ``(start, n_owned, n_pad)`` where ``start`` is the index of the
        first real observation owned by this process, ``n_owned`` is the number
        of real observations, and ``n_pad`` is the number of padding slots so that
        ``n_owned + n_pad`` is a multiple of ``n_local``.

    Raises:
        ValueError: If ``n_obs < n_proc``.
    """
    if rank is None:
        rank = jax.process_index()
    if n_proc is None:
        n_proc = jax.process_count()
    if n_local is None:
        n_local = jax.local_device_count()

    if n_obs < n_proc:
        raise ValueError(
            f'Not enough observations ({n_obs}) for {n_proc} processes. '
            f'Provide more observations or run with fewer processes.'
        )

    base = n_obs // n_proc
    remainder = n_obs % n_proc
    max_owned = base + (1 if remainder > 0 else 0)
    n_per_proc = max_owned + (-max_owned) % n_local  # ceil to next multiple of n_local

    n_owned = base + (1 if rank < remainder else 0)
    start = rank * base + min(rank, remainder)
    n_pad = n_per_proc - n_owned

    return start, n_owned, n_pad

furax.mapmaking.noise

Classes:

  • NoiseModel

    Dataclass for noise models used for ground observation data.

  • WhiteNoiseModel

    Dataclass for the white noise model used for ground observation data.

  • AtmosphericNoiseModel

    Dataclass for the 1/f noise model used for ground observation data.

Functions:

NoiseModel dataclass

Bases: ABC

Dataclass for noise models used for ground observation data.

Methods:

Attributes:

Source code in src/furax/mapmaking/noise.py
@jax.tree_util.register_dataclass
@dataclass
class NoiseModel(ABC):
    """Dataclass for noise models used for ground observation data."""

    @property
    @abstractmethod
    def n_detectors(self) -> int: ...

    @abstractmethod
    def psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']: ...

    @abstractmethod
    def log_psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']: ...

    @abstractmethod
    def to_array(self) -> Float[Array, 'dets n']: ...

    @abstractmethod
    def operator(
        self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
    ) -> AbstractLinearOperator: ...

    @abstractmethod
    def inverse_operator(
        self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
    ) -> AbstractLinearOperator: ...

    @abstractmethod
    def to_white_noise_model(self) -> 'WhiteNoiseModel': ...

    def to_operator_fourier(
        self,
        in_structure: PyTree[jax.ShapeDtypeStruct],
        *,
        sample_rate: float,
        inverse: bool = True,
    ) -> FourierOperator:
        """Fourier operator representation of the noise model."""
        func = (lambda f: 1.0 / self.psd(f)) if inverse else self.psd
        # do not use apodization -- sufficient padding is done by the FourierOperator
        return FourierOperator(
            func, in_structure=in_structure, sample_rate=sample_rate, apodize=False
        )

    def l2_loss(
        self, f: Float[Array, ' a'], Pxx: Float[Array, 'dets a'], mask: Float[Array, ' a']
    ) -> Float[Array, '']:
        """l2 loss in log-log spacea with given frequency mask."""
        pred = self.log_psd(f)
        loss = jnp.trapezoid(((pred - jnp.log10(Pxx)) * mask[None, :]) ** 2, jnp.log10(f))
        return jnp.mean(loss)

n_detectors abstractmethod property

__init__()

psd(f) abstractmethod

Source code in src/furax/mapmaking/noise.py
@abstractmethod
def psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']: ...

log_psd(f) abstractmethod

Source code in src/furax/mapmaking/noise.py
@abstractmethod
def log_psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']: ...

to_array() abstractmethod

Source code in src/furax/mapmaking/noise.py
@abstractmethod
def to_array(self) -> Float[Array, 'dets n']: ...

operator(in_structure, **kwargs) abstractmethod

Source code in src/furax/mapmaking/noise.py
@abstractmethod
def operator(
    self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
) -> AbstractLinearOperator: ...

inverse_operator(in_structure, **kwargs) abstractmethod

Source code in src/furax/mapmaking/noise.py
@abstractmethod
def inverse_operator(
    self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
) -> AbstractLinearOperator: ...

to_white_noise_model() abstractmethod

Source code in src/furax/mapmaking/noise.py
@abstractmethod
def to_white_noise_model(self) -> 'WhiteNoiseModel': ...

to_operator_fourier(in_structure, *, sample_rate, inverse=True)

Fourier operator representation of the noise model.

Source code in src/furax/mapmaking/noise.py
def to_operator_fourier(
    self,
    in_structure: PyTree[jax.ShapeDtypeStruct],
    *,
    sample_rate: float,
    inverse: bool = True,
) -> FourierOperator:
    """Fourier operator representation of the noise model."""
    func = (lambda f: 1.0 / self.psd(f)) if inverse else self.psd
    # do not use apodization -- sufficient padding is done by the FourierOperator
    return FourierOperator(
        func, in_structure=in_structure, sample_rate=sample_rate, apodize=False
    )

l2_loss(f, Pxx, mask)

l2 loss in log-log spacea with given frequency mask.

Source code in src/furax/mapmaking/noise.py
def l2_loss(
    self, f: Float[Array, ' a'], Pxx: Float[Array, 'dets a'], mask: Float[Array, ' a']
) -> Float[Array, '']:
    """l2 loss in log-log spacea with given frequency mask."""
    pred = self.log_psd(f)
    loss = jnp.trapezoid(((pred - jnp.log10(Pxx)) * mask[None, :]) ** 2, jnp.log10(f))
    return jnp.mean(loss)

WhiteNoiseModel dataclass

Bases: NoiseModel

Dataclass for the white noise model used for ground observation data.

Methods:

Attributes:

Source code in src/furax/mapmaking/noise.py
@jax.tree_util.register_dataclass
@dataclass
class WhiteNoiseModel(NoiseModel):
    """Dataclass for the white noise model used for ground observation data."""

    sigma: Float[Array, ' dets']

    @property
    def n_detectors(self) -> int:
        return self.sigma.shape[-1]

    @jax.jit
    def psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
        return jnp.broadcast_to(self.sigma[..., None] ** 2, (*self.sigma.shape, f.shape[-1]))

    @jax.jit
    def log_psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
        return jnp.broadcast_to(
            2 * jnp.log10(self.sigma)[..., None], (*self.sigma.shape, f.shape[-1])
        )

    def to_array(self) -> Float[Array, 'dets n']:
        return self.sigma[:, None]

    def operator(
        self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
    ) -> AbstractLinearOperator:
        # ``sigma`` covers all axes but the trailing sample axis (``(ndets,)`` modulated,
        # ``(n_stokes, ndets)`` for a demodulated Stokes TOD); broadcast it over the samples.
        return DiagonalOperator(self.sigma[..., None] ** 2, in_structure=in_structure)

    def inverse_operator(
        self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
    ) -> AbstractLinearOperator:
        inv_var = jnp.where(self.sigma > 0, 1.0 / (self.sigma**2), 0.0)
        return DiagonalOperator(inv_var[..., None], in_structure=in_structure)

    def to_white_noise_model(self) -> 'WhiteNoiseModel':
        return self

    @classmethod
    def fit_psd_model(
        cls,
        f: Float[Array, ' freq'],
        Pxx: Float[Array, 'dets freq'],
        sample_rate: Array,
        hwp_frequency: Array,
        config: NoiseFitConfig | None = None,
    ) -> 'WhiteNoiseModel':
        """Fit a white noise model to data."""
        sigma = fit_white_noise_model(
            f,
            Pxx,
            sample_rate=sample_rate,
            hwp_frequency=hwp_frequency,
            config=config or NoiseFitConfig(),
        )['fit']
        return cls(sigma)

sigma instance-attribute

n_detectors property

__init__(sigma)

psd(f)

Source code in src/furax/mapmaking/noise.py
@jax.jit
def psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
    return jnp.broadcast_to(self.sigma[..., None] ** 2, (*self.sigma.shape, f.shape[-1]))

log_psd(f)

Source code in src/furax/mapmaking/noise.py
@jax.jit
def log_psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
    return jnp.broadcast_to(
        2 * jnp.log10(self.sigma)[..., None], (*self.sigma.shape, f.shape[-1])
    )

to_array()

Source code in src/furax/mapmaking/noise.py
def to_array(self) -> Float[Array, 'dets n']:
    return self.sigma[:, None]

operator(in_structure, **kwargs)

Source code in src/furax/mapmaking/noise.py
def operator(
    self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
) -> AbstractLinearOperator:
    # ``sigma`` covers all axes but the trailing sample axis (``(ndets,)`` modulated,
    # ``(n_stokes, ndets)`` for a demodulated Stokes TOD); broadcast it over the samples.
    return DiagonalOperator(self.sigma[..., None] ** 2, in_structure=in_structure)

inverse_operator(in_structure, **kwargs)

Source code in src/furax/mapmaking/noise.py
def inverse_operator(
    self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
) -> AbstractLinearOperator:
    inv_var = jnp.where(self.sigma > 0, 1.0 / (self.sigma**2), 0.0)
    return DiagonalOperator(inv_var[..., None], in_structure=in_structure)

to_white_noise_model()

Source code in src/furax/mapmaking/noise.py
def to_white_noise_model(self) -> 'WhiteNoiseModel':
    return self

fit_psd_model(f, Pxx, sample_rate, hwp_frequency, config=None) classmethod

Fit a white noise model to data.

Source code in src/furax/mapmaking/noise.py
@classmethod
def fit_psd_model(
    cls,
    f: Float[Array, ' freq'],
    Pxx: Float[Array, 'dets freq'],
    sample_rate: Array,
    hwp_frequency: Array,
    config: NoiseFitConfig | None = None,
) -> 'WhiteNoiseModel':
    """Fit a white noise model to data."""
    sigma = fit_white_noise_model(
        f,
        Pxx,
        sample_rate=sample_rate,
        hwp_frequency=hwp_frequency,
        config=config or NoiseFitConfig(),
    )['fit']
    return cls(sigma)

AtmosphericNoiseModel dataclass

Bases: NoiseModel

Dataclass for the 1/f noise model used for ground observation data.

Methods:

Attributes:

  • sigma (Float[Array, ' dets']) –
  • alpha (Float[Array, ' dets']) –
  • fk (Float[Array, ' dets']) –
  • f0 (Float[Array, ' dets']) –
  • n_detectors (int) –
Source code in src/furax/mapmaking/noise.py
@jax.tree_util.register_dataclass
@dataclass
class AtmosphericNoiseModel(NoiseModel):
    """Dataclass for the 1/f noise model used for ground observation data."""

    sigma: Float[Array, ' dets']
    alpha: Float[Array, ' dets']
    fk: Float[Array, ' dets']
    f0: Float[Array, ' dets']

    @property
    def n_detectors(self) -> int:
        return self.sigma.shape[-1]

    @jax.jit
    def psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
        ratio = (f + self.f0[..., None]) / self.fk[..., None]
        return jnp.where(
            self.sigma[..., None] > 0,
            self.sigma[..., None] ** 2 * (1 + ratio ** self.alpha[..., None]),
            0,
        )

    @jax.jit
    def log_psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
        ratio = (f + self.f0[..., None]) / self.fk[..., None]
        return 2 * jnp.log10(self.sigma)[..., None] + jnp.log10(1 + ratio ** self.alpha[..., None])

    def to_array(self) -> Float[Array, 'dets n']:
        return jnp.stack([self.sigma, self.alpha, self.fk, self.f0], axis=-1)

    def operator(
        self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
    ) -> AbstractLinearOperator:
        """Build a Toeplitz operator from the autocorrelation function.

        The autocorrelation is evaluated via inverse FFT of the noise PSD, which is
        apodized and cut at the given length.
        """
        sample_rate: float = cast(float, kwargs.get('sample_rate'))
        correlation_length: int = cast(int, kwargs.get('correlation_length'))
        window = apodization_window(correlation_length)

        fft_size = SymmetricBandToeplitzOperator._get_next_power_of_two(2 * correlation_length - 1)
        freq = jnp.fft.rfftfreq(fft_size, 1 / sample_rate)
        eval_psd = self.psd(freq)
        invntt = jnp.fft.irfft(1.0 / eval_psd, n=fft_size)[..., :correlation_length]
        inv_band = invntt * window
        # pad only the last dimension
        pad_width = [(0, 0)] * (inv_band.ndim - 1) + [(0, fft_size - (2 * inv_band.shape[-1] - 1))]
        padded_band = jnp.pad(inv_band, pad_width)
        symmetrised_band = jnp.concatenate([padded_band, inv_band[..., -1:0:-1]], axis=-1)
        eff_inv_psd = jnp.fft.rfft(symmetrised_band, n=fft_size).real
        new_band = jnp.fft.irfft(1.0 / eff_inv_psd, n=fft_size)[..., :correlation_length] * window
        return SymmetricBandToeplitzOperator(new_band, in_structure=in_structure)

    def inverse_operator(
        self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
    ) -> AbstractLinearOperator:
        """Build a Toeplitz operator from the inverse autocorrelation function.

        The inverse autocorrelation is evaluated via inverse FFT of the noise PSD, which
        is apodized and cut at the given length.
        """
        sample_rate: float = cast(float, kwargs.get('sample_rate'))
        correlation_length: int = cast(int, kwargs.get('correlation_length'))

        fft_size = SymmetricBandToeplitzOperator._get_next_power_of_two(2 * correlation_length - 1)
        freq = jnp.fft.rfftfreq(fft_size, 1 / sample_rate)
        eval_psd = self.psd(freq)
        inv_psd = jnp.where(eval_psd > 0, 1 / eval_psd, 0.0)
        invntt = jnp.fft.irfft(inv_psd, n=fft_size)[..., :correlation_length]
        window = apodization_window(correlation_length)

        return SymmetricBandToeplitzOperator(invntt * window, in_structure=in_structure)

    def to_white_noise_model(self) -> WhiteNoiseModel:
        return WhiteNoiseModel(sigma=self.sigma)

    @classmethod
    def fit_psd_model(
        cls,
        f: Float[Array, ' freq'],
        Pxx: Float[Array, 'dets freq'],
        sample_rate: Array,
        hwp_frequency: Array,
        config: NoiseFitConfig | None = None,
    ) -> 'AtmosphericNoiseModel':
        """Fit a atmospheric (1/f) noise model to data."""
        result = fit_atmospheric_psd_model(
            f,
            Pxx,
            sample_rate=sample_rate,
            hwp_frequency=hwp_frequency,
            config=config or NoiseFitConfig(),
        )
        return cls(*result['fit'].T)

sigma instance-attribute

alpha instance-attribute

fk instance-attribute

f0 instance-attribute

n_detectors property

__init__(sigma, alpha, fk, f0)

psd(f)

Source code in src/furax/mapmaking/noise.py
@jax.jit
def psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
    ratio = (f + self.f0[..., None]) / self.fk[..., None]
    return jnp.where(
        self.sigma[..., None] > 0,
        self.sigma[..., None] ** 2 * (1 + ratio ** self.alpha[..., None]),
        0,
    )

log_psd(f)

Source code in src/furax/mapmaking/noise.py
@jax.jit
def log_psd(self, f: Float[Array, ' a']) -> Float[Array, '... a']:
    ratio = (f + self.f0[..., None]) / self.fk[..., None]
    return 2 * jnp.log10(self.sigma)[..., None] + jnp.log10(1 + ratio ** self.alpha[..., None])

to_array()

Source code in src/furax/mapmaking/noise.py
def to_array(self) -> Float[Array, 'dets n']:
    return jnp.stack([self.sigma, self.alpha, self.fk, self.f0], axis=-1)

operator(in_structure, **kwargs)

Build a Toeplitz operator from the autocorrelation function.

The autocorrelation is evaluated via inverse FFT of the noise PSD, which is apodized and cut at the given length.

Source code in src/furax/mapmaking/noise.py
def operator(
    self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
) -> AbstractLinearOperator:
    """Build a Toeplitz operator from the autocorrelation function.

    The autocorrelation is evaluated via inverse FFT of the noise PSD, which is
    apodized and cut at the given length.
    """
    sample_rate: float = cast(float, kwargs.get('sample_rate'))
    correlation_length: int = cast(int, kwargs.get('correlation_length'))
    window = apodization_window(correlation_length)

    fft_size = SymmetricBandToeplitzOperator._get_next_power_of_two(2 * correlation_length - 1)
    freq = jnp.fft.rfftfreq(fft_size, 1 / sample_rate)
    eval_psd = self.psd(freq)
    invntt = jnp.fft.irfft(1.0 / eval_psd, n=fft_size)[..., :correlation_length]
    inv_band = invntt * window
    # pad only the last dimension
    pad_width = [(0, 0)] * (inv_band.ndim - 1) + [(0, fft_size - (2 * inv_band.shape[-1] - 1))]
    padded_band = jnp.pad(inv_band, pad_width)
    symmetrised_band = jnp.concatenate([padded_band, inv_band[..., -1:0:-1]], axis=-1)
    eff_inv_psd = jnp.fft.rfft(symmetrised_band, n=fft_size).real
    new_band = jnp.fft.irfft(1.0 / eff_inv_psd, n=fft_size)[..., :correlation_length] * window
    return SymmetricBandToeplitzOperator(new_band, in_structure=in_structure)

inverse_operator(in_structure, **kwargs)

Build a Toeplitz operator from the inverse autocorrelation function.

The inverse autocorrelation is evaluated via inverse FFT of the noise PSD, which is apodized and cut at the given length.

Source code in src/furax/mapmaking/noise.py
def inverse_operator(
    self, in_structure: PyTree[jax.ShapeDtypeStruct], **kwargs: Any
) -> AbstractLinearOperator:
    """Build a Toeplitz operator from the inverse autocorrelation function.

    The inverse autocorrelation is evaluated via inverse FFT of the noise PSD, which
    is apodized and cut at the given length.
    """
    sample_rate: float = cast(float, kwargs.get('sample_rate'))
    correlation_length: int = cast(int, kwargs.get('correlation_length'))

    fft_size = SymmetricBandToeplitzOperator._get_next_power_of_two(2 * correlation_length - 1)
    freq = jnp.fft.rfftfreq(fft_size, 1 / sample_rate)
    eval_psd = self.psd(freq)
    inv_psd = jnp.where(eval_psd > 0, 1 / eval_psd, 0.0)
    invntt = jnp.fft.irfft(inv_psd, n=fft_size)[..., :correlation_length]
    window = apodization_window(correlation_length)

    return SymmetricBandToeplitzOperator(invntt * window, in_structure=in_structure)

to_white_noise_model()

Source code in src/furax/mapmaking/noise.py
def to_white_noise_model(self) -> WhiteNoiseModel:
    return WhiteNoiseModel(sigma=self.sigma)

fit_psd_model(f, Pxx, sample_rate, hwp_frequency, config=None) classmethod

Fit a atmospheric (1/f) noise model to data.

Source code in src/furax/mapmaking/noise.py
@classmethod
def fit_psd_model(
    cls,
    f: Float[Array, ' freq'],
    Pxx: Float[Array, 'dets freq'],
    sample_rate: Array,
    hwp_frequency: Array,
    config: NoiseFitConfig | None = None,
) -> 'AtmosphericNoiseModel':
    """Fit a atmospheric (1/f) noise model to data."""
    result = fit_atmospheric_psd_model(
        f,
        Pxx,
        sample_rate=sample_rate,
        hwp_frequency=hwp_frequency,
        config=config or NoiseFitConfig(),
    )
    return cls(*result['fit'].T)

apodization_window(size, kind='chebwin')

Source code in src/furax/mapmaking/noise.py
def apodization_window(size: int, kind: str = 'chebwin') -> Float[Array, ' {size}']:
    window_type: tuple[Any, ...]
    if kind == 'gaussian':
        q_apo = 3  # apodization factor: cut happens at q_apo * sigma in the Gaussian window
        window_type = ('general_gaussian', 1, 1 / q_apo * size)
    elif kind == 'chebwin':
        at = 150  # attenuation level (dB)
        window_type = ('chebwin', at)
    else:
        raise RuntimeError(f'Apodization window {kind!r} is not supported.')

    window = jnp.array(get_window(window_type, 2 * size))
    window = jnp.fft.ifftshift(window)[:size]
    return window

fit_white_noise_model(f, Pxx, sample_rate, hwp_frequency, config=None)

Fit a white noise model to the periodogram in log space.

This function fits a model of the form

PSD(f) = sigma^2

where
  • sigma: white noise level

Parameters:

  • f (Float[Array, ' freqs']) –

    Frequency array (Hz). Shape: (n_freq,)

  • Pxx (Float[Array, 'dets freqs']) –

    Power spectral density values. Shape: (n_detectors, n_freq)

  • sample_rate (Array) –

    Sampling rate of the timestream (Hz).

  • hwp_frequency (Array) –

    HWP rotation frequency (Hz)

  • config (NoiseFitConfig | None, default: None ) –

    NoiseFitConfig instance

Returns:

  • dict[str, Any]

    A dictionary containing following keys: fit: Array of fitted parameters [sigma]. Shape: (n_detectors, 1)

Source code in src/furax/mapmaking/noise.py
def fit_white_noise_model(
    f: Float[Array, ' freqs'],
    Pxx: Float[Array, 'dets freqs'],
    sample_rate: Array,
    hwp_frequency: Array,
    config: NoiseFitConfig | None = None,
) -> dict[str, Any]:
    """Fit a white noise model to the periodogram in log space.

    This function fits a model of the form:
        PSD(f) = sigma^2

    where:
        - sigma: white noise level

    Args:
        f: Frequency array (Hz). Shape: (n_freq,)
        Pxx: Power spectral density values. Shape: (n_detectors, n_freq)
        sample_rate: Sampling rate of the timestream (Hz).
        hwp_frequency: HWP rotation frequency (Hz)
        config: NoiseFitConfig instance

    Returns:
        A dictionary containing following keys:
            fit: Array of fitted parameters [sigma].
                Shape: (n_detectors, 1)

    """
    config = config or NoiseFitConfig()
    mask = _create_frequency_mask_from_config(
        f, sample_rate=sample_rate, hwp_frequency=hwp_frequency, config=config
    )
    nyquist = sample_rate / 2

    results = jax.vmap(
        lambda f, Pxx: {
            'fit': _approximate_white_noise(
                f,
                Pxx,
                mask=mask,
                high_f_threshold=nyquist * config.high_freq_nyquist,
            )
        },
        in_axes=(None, 0),
        out_axes={'fit': 0},
    )(f, Pxx)

    return results

fit_atmospheric_psd_model(f, Pxx, sample_rate, hwp_frequency, config=None)

Fit a 1/f PSD model to the periodogram in log space.

This function fits a model of the form

PSD(f) = sigma^2 * (1 + ((f + f0) / f_knee)^alpha)

where
  • sigma: white noise level
  • alpha: power law index (typically negative)
  • f_knee: knee frequency
  • f0: minimum frequency offset

Parameters:

  • f (Float[Array, ' freqs']) –

    Frequency array (Hz). Shape: (n_freq,)

  • Pxx (Float[Array, 'dets freqs']) –

    Power spectral density values. Shape: (n_detectors, n_freq)

  • sample_rate (Array) –

    Sampling rate (Hz)

  • hwp_frequency (Array) –

    HWP rotation frequency (Hz)

  • config (NoiseFitConfig | None, default: None ) –

    NoiseFitConfig instance

Returns:

  • dict[str, Any]

    A dictionary containing following keys: fit: Array of fitted parameters [sigma, alpha, f_knee, f_min]. Shape: (n_detectors, 4) loss: Array containing loss function values (-2logL) evaluated at the fitted parameters. Shape: (n_detectors,) num_iter: Array containing number of iterations spent to obtain the fit. Shape: (n_detectors,) fisher: Array containing fisher matrix values for the fitted parameters. Shape: (n_detectors, 4, 4)

Source code in src/furax/mapmaking/noise.py
def fit_atmospheric_psd_model(
    f: Float[Array, ' freqs'],
    Pxx: Float[Array, 'dets freqs'],
    sample_rate: Array,
    hwp_frequency: Array,
    config: NoiseFitConfig | None = None,
) -> dict[str, Any]:
    """Fit a 1/f PSD model to the periodogram in log space.

    This function fits a model of the form:
        PSD(f) = sigma^2 * (1 + ((f + f0) / f_knee)^alpha)

    where:
        - sigma: white noise level
        - alpha: power law index (typically negative)
        - f_knee: knee frequency
        - f0: minimum frequency offset

    Args:
        f: Frequency array (Hz). Shape: (n_freq,)
        Pxx: Power spectral density values. Shape: (n_detectors, n_freq)
        sample_rate: Sampling rate (Hz)
        hwp_frequency: HWP rotation frequency (Hz)
        config: NoiseFitConfig instance

    Returns:
        A dictionary containing following keys:
            fit: Array of fitted parameters [sigma, alpha, f_knee, f_min].
                Shape: (n_detectors, 4)
            loss: Array containing loss function values (-2logL) evaluated at the fitted parameters.
                Shape: (n_detectors,)
            num_iter: Array containing number of iterations spent to obtain the fit.
                Shape: (n_detectors,)
            fisher: Array containing fisher matrix values for the fitted parameters.
                Shape: (n_detectors, 4, 4)

    """
    config = config or NoiseFitConfig()
    mask = _create_frequency_mask_from_config(
        f, sample_rate=sample_rate, hwp_frequency=hwp_frequency, config=config
    )
    nyquist = sample_rate / 2

    results = jax.vmap(
        lambda f, Pxx: _fit_psd_model_masked(
            f,
            Pxx,
            mask=mask,
            low_f_threshold=nyquist * config.low_freq_nyquist,
            high_f_threshold=nyquist * config.high_freq_nyquist,
            max_iter=config.max_iter,
            tol=config.tol,
        ),
        in_axes=(None, 0),
        out_axes={'fit': 0, 'loss': 0, 'num_iter': 0, 'inv_fisher': 0, 'num_freq': None},
    )(f, Pxx)

    def fit_details(results: dict[str, Any]) -> None:
        logger.info(
            f'---- PSD model fit details ----\n'
            f'Number of frequency bins: {results["num_freq"]}\n'
            f'Mean loss per bin: {results["loss"] / results["num_freq"]}\n'
            f'Number of iterations: {results["num_iter"]}\n'
            f'-------------------------------'
        )

    jax.debug.callback(fit_details, results)

    return results

furax.mapmaking.pixell_utils

Functions:

ndmap_from_wcs_landscape(map, landscape)

Convert a given Stokes pytree to pixell's ndmap.

Source code in src/furax/mapmaking/pixell_utils.py
def ndmap_from_wcs_landscape(
    map: StokesType, landscape: WCSLandscape | AstropyWCSLandscape
) -> pixell.enmap.ndmap:
    """Convert a given Stokes pytree to pixell's ndmap."""
    wcs = landscape.wcs if isinstance(landscape, AstropyWCSLandscape) else landscape.to_wcs()
    leaves = jax.tree.leaves(map)
    data = leaves[0] if len(leaves) == 1 else leaves
    return pixell.enmap.ndmap(data, wcs)

plot_ndmap(data, title=None, vmax=None, vmin=None, cmap='RdBu_r', scale=1.0, fig=None, ax=None)

Visualisation function for ndmap that replaces pixell.enplot.eshow for now.

Inputs

title: title of the produced plot vmax: value above this are mapped to the colormap's upper end vmin: value below this are mapped to the colormap's lower end cmap: colormap name in matplotlib scale: value range scale factor of the colormap, overridden if vmin or vmax is provided fig, ax: matplotlib figure and axes

Returns: fig, ax

Source code in src/furax/mapmaking/pixell_utils.py
def plot_ndmap(
    data: pixell.enmap.ndmap,
    title: str | None = None,
    vmax: float | None = None,
    vmin: float | None = None,
    cmap: str = 'RdBu_r',
    scale: float = 1.0,
    fig: matplotlib.figure.Figure | None = None,
    ax: matplotlib.axes._axes.Axes | None = None,
) -> tuple[matplotlib.figure.Figure, matplotlib.axes._axes.Axes]:
    """Visualisation function for ndmap that replaces pixell.enplot.eshow for now.

    Inputs:
        title: title of the produced plot
        vmax: value above this are mapped to the colormap's upper end
        vmin: value below this are mapped to the colormap's lower end
        cmap: colormap name in matplotlib
        scale: value range scale factor of the colormap,
               overridden if vmin or vmax is provided
        fig, ax: matplotlib figure and axes
    Returns:
        fig, ax
    """
    if fig is None or ax is None:
        fig, ax = plt.subplots()

    if vmax is None and vmin is None:
        vmax = scale * np.max(np.abs(data))
        vmin = -vmax

    wcs = data.wcs.wcs
    # Note that the data has axes [Dec, RA], unlike the wcs object's [RA, Dec]
    ra = wcs.crval[0] + wcs.cdelt[0] * (np.arange(data.shape[1] + 1) - wcs.crpix[0] - 0.5)
    dec = wcs.crval[1] + wcs.cdelt[1] * (np.arange(data.shape[0] + 1) - wcs.crpix[1] - 0.5)
    # Convert negative RA to positive values and unwrap if necessary
    ra = np.unwrap(ra % 360.0, period=360.0)

    # Plot and label
    im = ax.pcolormesh(ra, dec, data, cmap=cmap, vmax=vmax, vmin=vmin)
    ax.set_xlabel('RA [deg]')
    ax.set_ylabel('Dec [deg]')
    ax.invert_xaxis()  # As per convention
    ax.grid(alpha=0.5)
    fig.colorbar(im)
    if title is not None:
        ax.set_title(title)
    ax.set_aspect('equal')

    return fig, ax

plot_cartview(input_maps, titles=None, lonra=(-180, 180), latra=(-90, 90), xsize=800, cmap='RdBu', vmaxs=None, vmins=None, vmax_quantile=0.999, nside=None, fig=None, axs=None)

Visualisation function for CAR projection of healpix maps.

Unlike healpy.cartview, this function returns matplotlib Axes object, and one can have more control of the plot elements such as grids and axes labels.

Parameters:

  • input_maps (NDArray[Any]) –

    HEALPix map(s) to plot. Can be 1D (single map) or 2D (multiple maps)

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

    Title(s) for the plot(s)

  • lonra (tuple[float, float], default: (-180, 180) ) –

    Longitude range [min, max] in degrees

  • latra (tuple[float, float], default: (-90, 90) ) –

    Latitude range [min, max] in degrees

  • xsize (int, default: 800 ) –

    Number of pixels in longitude direction

  • cmap (str, default: 'RdBu' ) –

    Colormap name

  • vmaxs (list[float] | list[None] | None, default: None ) –

    Maximum values for color scale (one per map)

  • vmins (list[float] | list[None] | None, default: None ) –

    Minimum values for color scale (one per map)

  • vmax_quantile (float, default: 0.999 ) –

    Quantile to use for automatic vmax determination

  • nside (int | None, default: None ) –

    HEALPix nside parameter. If None, inferred from input_maps

  • fig (Figure | None, default: None ) –

    matplotlib figure

  • axs (Axes | None, default: None ) –

    matplotlib axes

Returns:

  • tuple ( tuple[Figure, NDArray[Axes]] ) –

    (figure, axes) matplotlib objects

Source code in src/furax/mapmaking/pixell_utils.py
def plot_cartview(
    input_maps: NDArray[Any],
    titles: list[str] | str | None = None,
    lonra: tuple[float, float] = (-180, 180),
    latra: tuple[float, float] = (-90, 90),
    xsize: int = 800,
    cmap: str = 'RdBu',
    vmaxs: list[float] | list[None] | None = None,
    vmins: list[float] | list[None] | None = None,
    vmax_quantile: float = 0.999,
    nside: int | None = None,
    fig: matplotlib.figure.Figure | None = None,
    axs: matplotlib.axes._axes.Axes | None = None,
) -> tuple[matplotlib.figure.Figure, NDArray[matplotlib.axes.Axes]]:
    """Visualisation function for CAR projection of healpix maps.

    Unlike healpy.cartview, this function returns matplotlib Axes object, and one can have
    more control of the plot elements such as grids and axes labels.

    Args:
        input_maps: HEALPix map(s) to plot. Can be 1D (single map) or 2D (multiple maps)
        titles: Title(s) for the plot(s)
        lonra: Longitude range [min, max] in degrees
        latra: Latitude range [min, max] in degrees
        xsize: Number of pixels in longitude direction
        cmap: Colormap name
        vmaxs: Maximum values for color scale (one per map)
        vmins: Minimum values for color scale (one per map)
        vmax_quantile: Quantile to use for automatic vmax determination
        nside: HEALPix nside parameter. If None, inferred from input_maps
        fig: matplotlib figure
        axs: matplotlib axes

    Returns:
        tuple: (figure, axes) matplotlib objects
    """
    if not isinstance(input_maps, list) and input_maps.ndim == 1:
        input_maps = input_maps[None, :]
    if titles is not None and not isinstance(titles, list):
        titles = [titles]
    if vmaxs is None:
        vmaxs = [None] * len(input_maps)
    if vmins is None:
        vmins = [None] * len(input_maps)

    # Infer nside if not provided
    if nside is None:
        npix = input_maps[0].shape[-1]  # Last dimension should be the number of pixels
        nside = hp.npix2nside(npix)

    ysize = int(np.round((latra[1] - latra[0]) * xsize / (lonra[1] - lonra[0])))
    lon_grid = np.linspace(lonra[0], lonra[1], xsize)
    lat_grid = np.linspace(latra[0], latra[1], ysize)

    pix_inds = hp.pixelfunc.ang2pix(nside, lon_grid[None, :], lat_grid[:, None], lonlat=True)

    n_maps = len(input_maps)
    if fig is None or axs is None:
        fig, axs = plt.subplots(n_maps, 1, figsize=(10, 10 * (ysize / xsize) * n_maps))
    axs = np.atleast_1d(axs)

    for map_no in range(n_maps):
        ax = axs[map_no]

        proj_map = input_maps[map_no][pix_inds]

        vmax = vmaxs[map_no]
        vmin = vmins[map_no]
        if not vmax:
            vmax = np.quantile(np.abs(proj_map[np.abs(proj_map) > 0]), vmax_quantile)
        if not vmin and vmax is not None:
            vmin = -vmax

        im = ax.pcolor(
            lon_grid, lat_grid, proj_map, cmap=cmap, vmax=vmax, vmin=vmin, shading='nearest'
        )
        ax.xaxis.set_inverted(True)  # Follow astronomical convention for inverted RA
        ax.set_xlabel('lon [deg]')
        ax.set_ylabel('lat [deg]')
        ax.grid(alpha=0.5)
        ax.set_aspect('equal')
        if titles is not None:
            ax.set_title(titles[map_no])

        the_divider = make_axes_locatable(ax)
        color_axis = the_divider.append_axes('right', size='5%', pad=0.1)
        _ = fig.colorbar(im, cax=color_axis, format='%.0e')
    fig.tight_layout()

    return fig, axs

get_healpix_lonlat_ranges(healpix_map, nside=None, padding_deg=5.0, hit_threshold=1e-18)

Find appropriate longitude and latitude ranges for a HEALPix map.

This function identifies the sky region covered by non-zero pixels in a HEALPix map and returns appropriate longitude and latitude ranges for visualization.

Parameters:

  • healpix_map (NDArray[Any]) –

    HEALPix map array

  • nside (int | None, default: None ) –

    HEALPix nside parameter. If None, inferred from map length

  • padding_deg (float, default: 5.0 ) –

    Padding in degrees to add around the data region

  • hit_threshold (float, default: 1e-18 ) –

    Minimum value to consider a pixel as having data

Returns:

  • tuple ( tuple[list[float], list[float]] ) –

    (lonra, latra) where lonra=[lon_min, lon_max] and latra=[lat_min, lat_max] Ranges are in degrees, suitable for use with plot_cartview()

Examples:

>>> import healpy as hp
>>> import numpy as np
>>> # Create a small patch map
>>> nside = 64
>>> npix = hp.nside2npix(nside)
>>> hpx_map = np.zeros(npix)
>>> # Add signal in a small region
>>> theta, phi = hp.pix2ang(nside, np.arange(1000, 2000))
>>> hpx_map[1000:2000] = 1.0
>>> lonra, latra = get_healpix_lonlat_ranges(hpx_map, nside)
>>> print(f"Longitude range: {lonra}")
>>> print(f"Latitude range: {latra}")
Source code in src/furax/mapmaking/pixell_utils.py
def get_healpix_lonlat_ranges(
    healpix_map: NDArray[Any],
    nside: int | None = None,
    padding_deg: float = 5.0,
    hit_threshold: float = 1e-18,
) -> tuple[list[float], list[float]]:
    """Find appropriate longitude and latitude ranges for a HEALPix map.

    This function identifies the sky region covered by non-zero pixels in a HEALPix map
    and returns appropriate longitude and latitude ranges for visualization.

    Args:
        healpix_map: HEALPix map array
        nside: HEALPix nside parameter. If None, inferred from map length
        padding_deg: Padding in degrees to add around the data region
        hit_threshold: Minimum value to consider a pixel as having data

    Returns:
        tuple: (lonra, latra) where lonra=[lon_min, lon_max] and latra=[lat_min, lat_max]
               Ranges are in degrees, suitable for use with plot_cartview()

    Examples:
        >>> import healpy as hp
        >>> import numpy as np
        >>> # Create a small patch map
        >>> nside = 64
        >>> npix = hp.nside2npix(nside)
        >>> hpx_map = np.zeros(npix)
        >>> # Add signal in a small region
        >>> theta, phi = hp.pix2ang(nside, np.arange(1000, 2000))
        >>> hpx_map[1000:2000] = 1.0
        >>> lonra, latra = get_healpix_lonlat_ranges(hpx_map, nside)
        >>> print(f"Longitude range: {lonra}")
        >>> print(f"Latitude range: {latra}")
    """
    # Infer nside if not provided
    if nside is None:
        npix = len(healpix_map)
        nside = hp.npix2nside(npix)

    # Find pixels with data above threshold
    data_mask = np.abs(healpix_map) > hit_threshold
    data_pixels = np.where(data_mask)[0]

    if len(data_pixels) == 0:
        # No data found, return full sky
        return [-180.0, 180.0], [-90.0, 90.0]

    # Convert pixel indices to longitude and latitude in degrees
    lon, lat = hp.pix2ang(nside, data_pixels, lonlat=True)

    # Convert longitude to [-180, 180] range
    lon_wrapped = ((lon + 180.0) % 360.0) - 180.0
    lon_min = np.min(lon_wrapped) - padding_deg
    lon_max = np.max(lon_wrapped) + padding_deg

    # Clamp longitude to valid range [-180, 180]
    lon_min = max(lon_min, -180.0)
    lon_max = min(lon_max, 180.0)

    # Handle latitude range
    lat_min = np.min(lat) - padding_deg
    lat_max = np.max(lat) + padding_deg

    # Clamp latitude to valid range [-90, 90]
    lat_min = max(lat_min, -90.0)
    lat_max = min(lat_max, 90.0)

    return [lon_min, lon_max], [lat_min, lat_max]

furax.mapmaking.preconditioner

Classes:

  • BJPreconditioner

    Block-diagonal (per-pixel) Jacobi preconditioner for Stokes sky maps.

BJPreconditioner

Bases: AbstractLinearOperator

Block-diagonal (per-pixel) Jacobi preconditioner for Stokes sky maps.

Holds one dense (n, n) block per pixel (n = len(stokes)), coupling the Stokes components at that pixel: blocks has shape (*sky, n, n) with blocks[..., i, j] the response of output component i to input component j. Applied by an einsum over the Stokes axis of the map's backing array; symmetric (self-adjoint) for a symmetric operator.

Methods:

  • __init__
  • create

    Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

  • mv
  • inverse

Attributes:

  • blocks (Float[Array, '*sky n n']) –
Source code in src/furax/mapmaking/preconditioner.py
@symmetric
class BJPreconditioner(AbstractLinearOperator):
    """Block-diagonal (per-pixel) Jacobi preconditioner for Stokes sky maps.

    Holds one dense ``(n, n)`` block per pixel (``n = len(stokes)``), coupling the Stokes
    components at that pixel: ``blocks`` has shape ``(*sky, n, n)`` with ``blocks[..., i, j]`` the
    response of output component ``i`` to input component ``j``. Applied by an einsum over the
    Stokes axis of the map's backing array; symmetric (self-adjoint) for a symmetric operator.
    """

    blocks: Float[Array, '*sky n n']

    def __init__(
        self,
        blocks: Float[Array, '*sky n n'],
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        object.__setattr__(self, 'blocks', blocks)
        super().__init__(in_structure=in_structure)

    @classmethod
    def create(cls, op: AbstractLinearOperator) -> 'BJPreconditioner':
        """Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

        The operator is assumed diagonal over the pixel (map) axes. Each Stokes component is probed
        with a unit map; the response is that column of every pixel's block at once.
        """
        in_struct = op.in_structure
        if not isinstance(in_struct, Stokes):
            raise ValueError('operator must act on Stokes pytrees (sky maps)')
        if not structure_equal(in_struct, op.out_structure):
            raise ValueError('operator must be square')

        stokes_cls = type(in_struct)
        n = len(in_struct.stokes)
        sky_shape = in_struct.shape
        dtype = in_struct.dtype

        columns = []
        for j in range(n):
            # unit map on component j (one everywhere on that component, zero on the others);
            # the backing array has the Stokes components on the leading axis.
            probe = stokes_cls.from_array(jnp.zeros((n, *sky_shape), dtype).at[j].set(1.0))
            columns.append(_apply(op, probe).data)  # (n_i, *sky) = column j, indexed by row i
        blocks = jnp.moveaxis(jnp.stack(columns, axis=-1), 0, -2)  # (i, *sky, j) -> (*sky, i, j)
        return cls(blocks, in_structure=in_struct)

    def mv(self, x: Stokes) -> Stokes:
        # einsum aligns the blocks' trailing (i, j) axes against x.data's leading Stokes axis
        # directly, without physically transposing either array.
        return type(x).from_array(jnp.einsum('...ij,j...->i...', self.blocks, x.data))

    def inverse(self) -> 'BJPreconditioner':
        # Per-pixel matrix inverse; stays a BJPreconditioner (keeps the @symmetric tag).
        return BJPreconditioner(jnp.linalg.inv(self.blocks), in_structure=self.in_structure)

blocks instance-attribute

__init__(blocks, *, in_structure)

Source code in src/furax/mapmaking/preconditioner.py
def __init__(
    self,
    blocks: Float[Array, '*sky n n'],
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    object.__setattr__(self, 'blocks', blocks)
    super().__init__(in_structure=in_structure)

create(op) classmethod

Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

The operator is assumed diagonal over the pixel (map) axes. Each Stokes component is probed with a unit map; the response is that column of every pixel's block at once.

Source code in src/furax/mapmaking/preconditioner.py
@classmethod
def create(cls, op: AbstractLinearOperator) -> 'BJPreconditioner':
    """Assemble the per-pixel blocks from a symmetric operator acting on Stokes sky maps.

    The operator is assumed diagonal over the pixel (map) axes. Each Stokes component is probed
    with a unit map; the response is that column of every pixel's block at once.
    """
    in_struct = op.in_structure
    if not isinstance(in_struct, Stokes):
        raise ValueError('operator must act on Stokes pytrees (sky maps)')
    if not structure_equal(in_struct, op.out_structure):
        raise ValueError('operator must be square')

    stokes_cls = type(in_struct)
    n = len(in_struct.stokes)
    sky_shape = in_struct.shape
    dtype = in_struct.dtype

    columns = []
    for j in range(n):
        # unit map on component j (one everywhere on that component, zero on the others);
        # the backing array has the Stokes components on the leading axis.
        probe = stokes_cls.from_array(jnp.zeros((n, *sky_shape), dtype).at[j].set(1.0))
        columns.append(_apply(op, probe).data)  # (n_i, *sky) = column j, indexed by row i
    blocks = jnp.moveaxis(jnp.stack(columns, axis=-1), 0, -2)  # (i, *sky, j) -> (*sky, i, j)
    return cls(blocks, in_structure=in_struct)

mv(x)

Source code in src/furax/mapmaking/preconditioner.py
def mv(self, x: Stokes) -> Stokes:
    # einsum aligns the blocks' trailing (i, j) axes against x.data's leading Stokes axis
    # directly, without physically transposing either array.
    return type(x).from_array(jnp.einsum('...ij,j...->i...', self.blocks, x.data))

inverse()

Source code in src/furax/mapmaking/preconditioner.py
def inverse(self) -> 'BJPreconditioner':
    # Per-pixel matrix inverse; stays a BJPreconditioner (keeps the @symmetric tag).
    return BJPreconditioner(jnp.linalg.inv(self.blocks), in_structure=self.in_structure)

furax.mapmaking.results

Classes:

MapMakingResults dataclass

Methods:

  • __init__
  • save
  • load

    Load a previously saved MapMakingResults from disk.

Attributes:

  • map (StokesType) –

    The estimated sky map

  • landscape (StokesLandscape) –

    The landscape corresponding to the map

  • hit_map (Integer[Array, ' *dims']) –

    The map of hit counts per pixel

  • icov (Float[Array, 'stokes stokes *dims']) –

    The per-pixel inverse noise covariance matrix (H^T N^{-1} H)

  • solver_stats (dict[str, Any] | None) –

    Statistics from the linear solver (e.g. num_steps, max_steps)

  • noise_fits (Float[Array, ...] | None) –

    The fitted noise PSD parameters

  • failed_observations (list[str] | None) –

    Names of observations that failed to load and were excluded from the maps

Source code in src/furax/mapmaking/results.py
@dataclass
class MapMakingResults:
    map: StokesType
    """The estimated sky map"""

    landscape: StokesLandscape
    """The landscape corresponding to the map"""

    hit_map: Integer[Array, ' *dims']
    """The map of hit counts per pixel"""

    icov: Float[Array, 'stokes stokes *dims']
    """The per-pixel inverse noise covariance matrix (H^T N^{-1} H)"""

    solver_stats: dict[str, Any] | None = None
    """Statistics from the linear solver (e.g. num_steps, max_steps)"""

    noise_fits: Float[Array, '...'] | None = None
    """The fitted noise PSD parameters"""

    failed_observations: list[str] | None = None
    """Names of observations that failed to load and were excluded from the maps"""

    def save(self, out_dir: str | Path) -> None:
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
        self._save_array(np.array(self.map.data), 'map', out_dir)
        self._save_array(np.array(self.hit_map), 'hit_map', out_dir, column_names=['HITS'])
        self._save_icov(np.array(self.icov), out_dir)
        if self.noise_fits is not None:
            np.save(out_dir / 'noise_fits', np.array(self.noise_fits))
        if self.solver_stats is not None:
            with open(out_dir / 'solver_stats.json', 'w') as f:
                json.dump(self.solver_stats, f, indent=2, cls=_JsonEncoder)
        if self.failed_observations:
            with open(out_dir / 'failed_observations.txt', 'w') as f:
                for name in self.failed_observations:
                    f.write(f'{name}\n')

    @classmethod
    def load(
        cls,
        out_dir: str | Path,
        landscape: StokesLandscape,
        fields: set[str] | list[str] | None = None,
    ) -> 'MapMakingResults':
        """Load a previously saved MapMakingResults from disk.

        Args:
            out_dir: Directory containing the saved files.
            landscape: The landscape used when the results were saved.
            fields: Fields to load. Defaults to all fields. Required fields
                (map, hit_map, icov) must always be included if specified.
        """
        out_dir = Path(out_dir)
        if not out_dir.exists():
            raise FileNotFoundError(f'Output directory not found: {out_dir}')

        if fields is None:
            fields_to_load = _VALID_FIELDS
        else:
            fields_to_load = frozenset(fields)
            invalid = fields_to_load - _VALID_FIELDS
            if invalid:
                raise ValueError(
                    f'Unknown fields: {sorted(invalid)}. Valid fields: {sorted(_VALID_FIELDS)}'
                )
            missing_required = _REQUIRED_FIELDS - fields_to_load
            if missing_required:
                raise ValueError(f'Required fields cannot be excluded: {sorted(missing_required)}')

        sky_map = cls._load_map(out_dir, landscape)
        hit_map = cls._load_hit_map(out_dir, landscape)
        icov = cls._load_icov(out_dir, landscape)

        noise_fits = cls._load_noise_fits(out_dir) if 'noise_fits' in fields_to_load else None
        solver_stats = cls._load_solver_stats(out_dir) if 'solver_stats' in fields_to_load else None

        return cls(
            map=sky_map,
            landscape=landscape,
            hit_map=hit_map,
            icov=icov,
            solver_stats=solver_stats,
            noise_fits=noise_fits,
        )

    @staticmethod
    def _load_array(
        name: str, out_dir: Path, landscape: StokesLandscape, n_fields: int
    ) -> np.ndarray:
        """Load a [n_fields, *pixel_dims] array from FITS or npy.

        For HEALPix landscapes with n_fields=1, a leading dimension is added
        so the returned shape is always [n_fields, npix].
        """
        if isinstance(landscape, (WCSLandscape, AstropyWCSLandscape)):
            path = out_dir / f'{name}.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            with fits.open(path) as hdul:
                arr = np.asarray(hdul[0].data)
                return arr.astype(arr.dtype.newbyteorder('='), copy=False)
        elif isinstance(landscape, HealpixLandscape):
            path = out_dir / f'{name}.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            if n_fields == 1:
                arr = np.array(hp.read_map(str(path), field=0))
            else:
                maps = hp.read_map(str(path), field=list(range(n_fields)))
                arr = np.stack(maps, axis=0)
            # hp.read_map with field=0 drops the leading dim; restore it
            if arr.ndim == len(landscape.shape):
                arr = arr[np.newaxis]
            return arr.astype(arr.dtype.newbyteorder('='), copy=False)
        else:
            path = out_dir / f'{name}.npy'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            return np.load(path)  # type: ignore[no-any-return]

    @staticmethod
    def _load_map(out_dir: Path, landscape: StokesLandscape) -> StokesType:
        ns = len(landscape.stokes)
        arr = MapMakingResults._load_array('map', out_dir, landscape, ns)
        stokes_cls = Stokes.class_for(landscape.stokes)
        return stokes_cls(*[jnp.array(arr[i]) for i in range(ns)])

    @staticmethod
    def _load_hit_map(out_dir: Path, landscape: StokesLandscape) -> Array:
        if isinstance(landscape, (WCSLandscape, AstropyWCSLandscape)):
            path = out_dir / 'hit_map.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            with fits.open(path) as hdul:
                arr = hdul[0].data
                return jnp.array(arr.astype(arr.dtype.newbyteorder('='), copy=False))
        elif isinstance(landscape, HealpixLandscape):
            path = out_dir / 'hit_map.fits'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            hits = hp.read_map(str(path), field=0)
            return jnp.array(hits.astype(hits.dtype.newbyteorder('='), copy=False))
        else:
            path = out_dir / 'hit_map.npy'
            if not path.exists():
                raise FileNotFoundError(f'Expected file not found: {path}')
            return jnp.array(np.load(path))

    @staticmethod
    def _load_icov(out_dir: Path, landscape: StokesLandscape) -> Array:
        stokes = landscape.stokes
        ns = len(stokes)
        n_upper = ns * (ns + 1) // 2
        arr_upper = MapMakingResults._load_array('icov', out_dir, landscape, n_upper)

        upper = [(i, j) for i in range(ns) for j in range(i, ns)]
        pixel_shape = arr_upper.shape[1:]
        icov = np.zeros((ns, ns, *pixel_shape), dtype=arr_upper.dtype)
        for k, (i, j) in enumerate(upper):
            icov[i, j] = arr_upper[k]
            if i != j:
                icov[j, i] = arr_upper[k]
        return jnp.array(icov)

    @staticmethod
    def _load_noise_fits(out_dir: Path) -> Array | None:
        path = out_dir / 'noise_fits.npy'
        if not path.exists():
            return None
        return jnp.array(np.load(path))

    @staticmethod
    def _load_solver_stats(out_dir: Path) -> dict[str, Any] | None:
        path = out_dir / 'solver_stats.json'
        if not path.exists():
            return None
        with open(path) as f:
            return json.load(f)  # type: ignore[no-any-return]

    def _save_icov(self, arr: np.ndarray, out_dir: Path) -> None:
        """Save the inverse covariance, storing only the upper triangle with stokes-aware names."""
        stokes = self.landscape.stokes
        ns = len(stokes)
        upper = [(i, j) for i in range(ns) for j in range(i, ns)]
        column_names = [stokes[i] + stokes[j] for i, j in upper]
        arr_upper = np.stack([arr[i, j] for i, j in upper], axis=0)
        self._save_array(arr_upper, 'icov', out_dir, column_names=column_names)

    def _save_array(
        self, arr: np.ndarray, name: str, out_dir: Path, column_names: list[str] | None = None
    ) -> None:
        """Save a numpy array as FITS (WCS or HEALPix) or npy depending on the landscape."""
        if isinstance(self.landscape, WCSLandscape):
            hdu = fits.PrimaryHDU(arr, header=fits.Header(self.landscape.to_wcs().to_header()))
            hdu.writeto(out_dir / f'{name}.fits', overwrite=True)
        elif isinstance(self.landscape, AstropyWCSLandscape):
            hdu = fits.PrimaryHDU(arr, header=fits.Header(self.landscape.wcs.to_header()))
            hdu.writeto(out_dir / f'{name}.fits', overwrite=True)
        elif isinstance(self.landscape, HealpixLandscape):
            maps = [arr] if arr.ndim == 1 else list(arr.reshape(-1, arr.shape[-1]))
            hp.write_map(
                str(out_dir / f'{name}.fits'),
                maps,
                nest=self.landscape.nested,
                column_names=column_names,
                overwrite=True,
            )
        else:
            furax_logger.warning(
                f'saving {name} as npy: geometry information will not be embedded in the file'
            )
            np.save(out_dir / name, arr)

map instance-attribute

The estimated sky map

landscape instance-attribute

The landscape corresponding to the map

hit_map instance-attribute

The map of hit counts per pixel

icov instance-attribute

The per-pixel inverse noise covariance matrix (H^T N^{-1} H)

solver_stats = None class-attribute instance-attribute

Statistics from the linear solver (e.g. num_steps, max_steps)

noise_fits = None class-attribute instance-attribute

The fitted noise PSD parameters

failed_observations = None class-attribute instance-attribute

Names of observations that failed to load and were excluded from the maps

__init__(map, landscape, hit_map, icov, solver_stats=None, noise_fits=None, failed_observations=None)

save(out_dir)

Source code in src/furax/mapmaking/results.py
def save(self, out_dir: str | Path) -> None:
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    self._save_array(np.array(self.map.data), 'map', out_dir)
    self._save_array(np.array(self.hit_map), 'hit_map', out_dir, column_names=['HITS'])
    self._save_icov(np.array(self.icov), out_dir)
    if self.noise_fits is not None:
        np.save(out_dir / 'noise_fits', np.array(self.noise_fits))
    if self.solver_stats is not None:
        with open(out_dir / 'solver_stats.json', 'w') as f:
            json.dump(self.solver_stats, f, indent=2, cls=_JsonEncoder)
    if self.failed_observations:
        with open(out_dir / 'failed_observations.txt', 'w') as f:
            for name in self.failed_observations:
                f.write(f'{name}\n')

load(out_dir, landscape, fields=None) classmethod

Load a previously saved MapMakingResults from disk.

Parameters:

  • out_dir (str | Path) –

    Directory containing the saved files.

  • landscape (StokesLandscape) –

    The landscape used when the results were saved.

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

    Fields to load. Defaults to all fields. Required fields (map, hit_map, icov) must always be included if specified.

Source code in src/furax/mapmaking/results.py
@classmethod
def load(
    cls,
    out_dir: str | Path,
    landscape: StokesLandscape,
    fields: set[str] | list[str] | None = None,
) -> 'MapMakingResults':
    """Load a previously saved MapMakingResults from disk.

    Args:
        out_dir: Directory containing the saved files.
        landscape: The landscape used when the results were saved.
        fields: Fields to load. Defaults to all fields. Required fields
            (map, hit_map, icov) must always be included if specified.
    """
    out_dir = Path(out_dir)
    if not out_dir.exists():
        raise FileNotFoundError(f'Output directory not found: {out_dir}')

    if fields is None:
        fields_to_load = _VALID_FIELDS
    else:
        fields_to_load = frozenset(fields)
        invalid = fields_to_load - _VALID_FIELDS
        if invalid:
            raise ValueError(
                f'Unknown fields: {sorted(invalid)}. Valid fields: {sorted(_VALID_FIELDS)}'
            )
        missing_required = _REQUIRED_FIELDS - fields_to_load
        if missing_required:
            raise ValueError(f'Required fields cannot be excluded: {sorted(missing_required)}')

    sky_map = cls._load_map(out_dir, landscape)
    hit_map = cls._load_hit_map(out_dir, landscape)
    icov = cls._load_icov(out_dir, landscape)

    noise_fits = cls._load_noise_fits(out_dir) if 'noise_fits' in fields_to_load else None
    solver_stats = cls._load_solver_stats(out_dir) if 'solver_stats' in fields_to_load else None

    return cls(
        map=sky_map,
        landscape=landscape,
        hit_map=hit_map,
        icov=icov,
        solver_stats=solver_stats,
        noise_fits=noise_fits,
    )

furax.mapmaking.templates

Template operators for fitting structured nuisance signals out of the data.

A template turns a small set of per-detector amplitudes into a time stream, so the mapmaker can fit and remove unwanted but predictable signals (slow drifts, scan-synchronous pickup, HWP-synchronous lines, T-to-P leakage).

The building block is a Basis: a small set of functions of time (Legendre polynomials, HWP harmonics, ...). Going from amplitudes to a signal is expand; the reverse is project. A PerDetectorTemplate then gives every detector its own copy (or its own basis), so each detector fits its own amplitudes.

A few Basis flavours trade memory for structure: - TensorBasis: stores every basis function value directly, as a dense array (optionally on a coarser time grid, q > 1, to trade resolution for memory). - KroneckerBasis: a product of independent factors (e.g. azimuth x HWP), stored factored to save memory. - SegmentedBasis: each sample belongs to one segment (e.g. one scan interval), stored sparsely instead of as a mostly-zero dense array. - WindowedBasis: each sample reads a fixed window of overlapping blocks, the overlapping generalisation of SegmentedBasis.

Build several templates and combine them by wrapping each in a PerDetectorTemplate and stacking with BlockRowOperator.

Classes:

Basis dataclass

Bases: AbstractLinearOperator

A set of template functions of time used to model a structured signal.

The basis functions b_k are each evaluated at the same n_points time samples. Conceptually, we can think of them as columns of a matrix B. The modelled signal is then a linear combination s = B a, i.e. s(t) = Σ_k a_k b_k(t) where each a_k is the amplitude of the template function b_k. The index k may be multi dimensional.

Two main operations:

  • expand (synthesis, amplitudes to signal): s = B(a).
  • project (analysis, signal to amplitudes): a = B.T(s).

Methods:

Attributes:

Source code in src/furax/mapmaking/templates.py
class Basis(AbstractLinearOperator):
    """A set of template functions of time used to model a structured signal.

    The basis functions `b_k` are each evaluated at the same `n_points` time samples.
    Conceptually, we can think of them as columns of a matrix `B`. The modelled signal
    is then a linear combination `s = B a`, i.e. `s(t) = Σ_k a_k b_k(t)` where each
    `a_k` is the amplitude of the template function `b_k`. The index `k` may be multi
    dimensional.

    Two main operations:

    - `expand` (synthesis, amplitudes to signal): `s = B(a)`.
    - `project` (analysis, signal to amplitudes): `a = B.T(s)`.
    """

    @property
    def shape(self) -> tuple[int, ...]:
        """Shape of the basis index."""
        return self.in_structure.shape  # type: ignore[no-any-return]

    @property
    @abstractmethod
    def n_points(self) -> int:
        """Number of sample points at which basis functions are evaluated."""

    @property
    def size(self) -> int:
        """Total number of basis functions (product of `shape`)."""
        return prod(self.shape)

    @property
    def dtype(self) -> DTypeLike:
        return self.in_structure.dtype  # type: ignore[no-any-return]

    @property
    def out_structure(self) -> jax.ShapeDtypeStruct:
        return jax.ShapeDtypeStruct((self.n_points,), self.dtype)

    @abstractmethod
    def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
        """Synthesize signal from coefficients."""

    @abstractmethod
    def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
        """Project signal onto basis."""

    def mv(self, x: Float[Array, '*shape']) -> Float[Array, ' samp']:
        return self.expand(x)

    def transpose(self) -> AbstractLinearOperator:
        return _BasisTranspose(self)

shape property

Shape of the basis index.

n_points abstractmethod property

Number of sample points at which basis functions are evaluated.

size property

Total number of basis functions (product of shape).

dtype property

out_structure property

expand(coeffs) abstractmethod

Synthesize signal from coefficients.

Source code in src/furax/mapmaking/templates.py
@abstractmethod
def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
    """Synthesize signal from coefficients."""

project(signal) abstractmethod

Project signal onto basis.

Source code in src/furax/mapmaking/templates.py
@abstractmethod
def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
    """Project signal onto basis."""

mv(x)

Source code in src/furax/mapmaking/templates.py
def mv(self, x: Float[Array, '*shape']) -> Float[Array, ' samp']:
    return self.expand(x)

transpose()

Source code in src/furax/mapmaking/templates.py
def transpose(self) -> AbstractLinearOperator:
    return _BasisTranspose(self)

TensorBasis dataclass

Bases: Basis

Dense basis: the matrix B is stored explicitly.

Holds values[k, t] = b_k(t) as a single dense array, with no assumed structure. When B factorises over independent variables, KroneckerBasis represents the same map with less memory; when each sample belongs to one interval, SegmentedBasis avoids storing the mostly-zero per-interval blocks.

With q > 1 the values are stored on a q-times coarser time grid to save memory: synthesis (expand) holds each coarse value over its block of q samples and analysis (project) sums each block back down. The two are exact transposes. Hold/block-sum is a zeroth-order resampler, exact only for content well below the coarse-grid Nyquist frequency sample_rate / 2q, so choose q to keep that above the template's band edge. The default q = 1 is the plain dense basis with no resampling.

Methods:

Attributes:

Source code in src/furax/mapmaking/templates.py
class TensorBasis(Basis):
    """Dense basis: the matrix `B` is stored explicitly.

    Holds `values[k, t] = b_k(t)` as a single dense array, with no assumed structure.
    When `B` factorises over independent variables, `KroneckerBasis`
    represents the same map with less memory; when each sample belongs to one interval,
    `SegmentedBasis` avoids storing the mostly-zero per-interval blocks.

    With `q > 1` the values are stored on a `q`-times coarser time grid to save memory:
    synthesis (`expand`) holds each coarse value over its block of `q` samples and analysis
    (`project`) sums each block back down. The two are exact transposes. Hold/block-sum is a
    zeroth-order resampler, exact only for content well below the coarse-grid Nyquist
    frequency `sample_rate / 2q`, so choose `q` to keep that above the template's band edge.
    The default `q = 1` is the plain dense basis with no resampling.
    """

    values: Float[Array, '*shape samp_dec']
    q: int = field(metadata={'static': True}, default=1)
    n_full: int = field(metadata={'static': True}, default=0)

    def __post_init__(self) -> None:
        super().__post_init__()
        if self.q < 1:
            raise ValueError(f'q must be >= 1, got {self.q}.')
        n_dec = self.values.shape[-1]
        # the coarse grid must be the q-block count covering n_full, i.e. n_dec = ceil(n_full / q).
        if not (n_dec - 1) * self.q < self.n_full <= n_dec * self.q:
            raise ValueError(f'n_dec={n_dec} inconsistent with n_full={self.n_full}, q={self.q}.')

    @classmethod
    def create(
        cls,
        values: Float[Array, '*shape samp_dec'],
        q: int = 1,
        n_full: int | None = None,
    ) -> Self:
        shape = values.shape[:-1]
        if n_full is None:
            # q == 1: the stored grid is already the full grid.
            n_full = values.shape[-1]
        return cls(
            values=values,
            q=q,
            n_full=n_full,
            in_structure=ShapeDtypeStruct(shape, values.dtype),
        )

    @property
    def n_points(self) -> int:
        return self.n_full

    @property
    def n_dec(self) -> int:
        return self.values.shape[-1]

    def _upsample(self, x: Float[Array, '... dec']) -> Float[Array, '... full']:
        # hold-interpolation: repeat each coarse sample q times, trim the padding tail.
        return jnp.repeat(x, self.q, axis=-1)[..., : self.n_full]

    def _downsample(self, s: Float[Array, '... full']) -> Float[Array, '... dec']:
        # adjoint of _upsample: sum each q-sample block. Pad the tail to a full block.
        pad = self.n_dec * self.q - self.n_full
        s = jnp.pad(s, [(0, 0)] * (s.ndim - 1) + [(0, pad)])
        return s.reshape(*s.shape[:-1], self.n_dec, self.q).sum(axis=-1)

    def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
        # integer axis labels. Index axes are 0..n-1, sample axis is n.
        n = len(self.shape)
        idx = tuple(range(n))
        out = jnp.einsum(coeffs, idx, self.values, (*idx, n), (n,))
        return out if self.q == 1 else self._upsample(out)

    def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
        n = len(self.shape)
        idx = tuple(range(n))
        if self.q != 1:
            signal = self._downsample(signal)
        return jnp.einsum(self.values, (*idx, n), signal, (n,), idx)

values instance-attribute

q = field(metadata={'static': True}, default=1) class-attribute instance-attribute

n_full = field(metadata={'static': True}, default=0) class-attribute instance-attribute

n_points property

n_dec property

__post_init__()

Source code in src/furax/mapmaking/templates.py
def __post_init__(self) -> None:
    super().__post_init__()
    if self.q < 1:
        raise ValueError(f'q must be >= 1, got {self.q}.')
    n_dec = self.values.shape[-1]
    # the coarse grid must be the q-block count covering n_full, i.e. n_dec = ceil(n_full / q).
    if not (n_dec - 1) * self.q < self.n_full <= n_dec * self.q:
        raise ValueError(f'n_dec={n_dec} inconsistent with n_full={self.n_full}, q={self.q}.')

create(values, q=1, n_full=None) classmethod

Source code in src/furax/mapmaking/templates.py
@classmethod
def create(
    cls,
    values: Float[Array, '*shape samp_dec'],
    q: int = 1,
    n_full: int | None = None,
) -> Self:
    shape = values.shape[:-1]
    if n_full is None:
        # q == 1: the stored grid is already the full grid.
        n_full = values.shape[-1]
    return cls(
        values=values,
        q=q,
        n_full=n_full,
        in_structure=ShapeDtypeStruct(shape, values.dtype),
    )

expand(coeffs)

Source code in src/furax/mapmaking/templates.py
def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
    # integer axis labels. Index axes are 0..n-1, sample axis is n.
    n = len(self.shape)
    idx = tuple(range(n))
    out = jnp.einsum(coeffs, idx, self.values, (*idx, n), (n,))
    return out if self.q == 1 else self._upsample(out)

project(signal)

Source code in src/furax/mapmaking/templates.py
def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
    n = len(self.shape)
    idx = tuple(range(n))
    if self.q != 1:
        signal = self._downsample(signal)
    return jnp.einsum(self.values, (*idx, n), signal, (n,), idx)

KroneckerBasis dataclass

Bases: Basis

Basis whose functions factorise over independent variables.

Built from factor matrices F_i (e.g. an azimuth-polynomial set and an HWP-harmonic set), whose rows are the factor's functions of time. The basis function for multi-index k = (k_0, ...) is the elementwise product b_k(t) = Π_i F_i[k_i, t]. Equivalent to a TensorBasis holding the full outer product, but kept factored: memory scales as Σ_i d_i rather than Π_i d_i columns of length n_points.

Use when the basis cleanly separates over independent variables.

Methods:

Attributes:

Source code in src/furax/mapmaking/templates.py
class KroneckerBasis(Basis):
    """Basis whose functions factorise over independent variables.

    Built from factor matrices `F_i` (e.g. an azimuth-polynomial set and an HWP-harmonic
    set), whose rows are the factor's functions of time. The basis function for
    multi-index `k = (k_0, ...)` is the elementwise product `b_k(t) = Π_i F_i[k_i, t]`.
    Equivalent to a `TensorBasis` holding the full outer product, but kept factored:
    memory scales as `Σ_i d_i` rather than `Π_i d_i` columns of length `n_points`.

    Use when the basis cleanly separates over independent variables.
    """

    factors: tuple[Float[Array, 'd samp'], ...]

    @classmethod
    def create(cls, factors: tuple[Float[Array, 'd samp'], ...]) -> Self:
        shape = tuple(f.shape[0] for f in factors)
        dtype = factors[0].dtype
        return cls(
            factors=factors,
            in_structure=ShapeDtypeStruct(shape, dtype),
        )

    @property
    def n_points(self) -> int:
        return self.factors[0].shape[-1]

    def _factor_operands(self) -> list[Any]:
        # interleaved einsum operands: factor i carries index axis i and the
        # shared sample axis n. e.g. F0,(0,n), F1,(1,n), ...
        n = len(self.shape)
        return list(chain.from_iterable((f, (i, n)) for i, f in enumerate(self.factors)))

    def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
        # explicit output (n,): sample axis n repeats across factors, name as output to keep it.
        n = len(self.shape)
        return jnp.einsum(coeffs, tuple(range(n)), *self._factor_operands(), (n,))

    def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
        # implicit output: repeated sample axis n is summed; index axes 0..n-1
        # each appear once and become the (sorted) output.
        n = len(self.shape)
        return jnp.einsum(*self._factor_operands(), signal, (n,))

factors instance-attribute

n_points property

create(factors) classmethod

Source code in src/furax/mapmaking/templates.py
@classmethod
def create(cls, factors: tuple[Float[Array, 'd samp'], ...]) -> Self:
    shape = tuple(f.shape[0] for f in factors)
    dtype = factors[0].dtype
    return cls(
        factors=factors,
        in_structure=ShapeDtypeStruct(shape, dtype),
    )

expand(coeffs)

Source code in src/furax/mapmaking/templates.py
def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
    # explicit output (n,): sample axis n repeats across factors, name as output to keep it.
    n = len(self.shape)
    return jnp.einsum(coeffs, tuple(range(n)), *self._factor_operands(), (n,))

project(signal)

Source code in src/furax/mapmaking/templates.py
def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
    # implicit output: repeated sample axis n is summed; index axes 0..n-1
    # each appear once and become the (sorted) output.
    n = len(self.shape)
    return jnp.einsum(*self._factor_operands(), signal, (n,))

SegmentedBasis dataclass

Bases: Basis

Basis partitioned into segments, each sample belonging to exactly one.

The amplitude index is (j, k) = segment j × shared sub-basis function k, with b_{j,k}(t) = [segment(t) = j] · v_k(t). Since every sample lies in a single segment, only that segment's amplitudes contribute to it.

Stored sparsely as one segment id per sample plus one shared table v_k(t), instead of the equivalent dense per-segment array (segments × functions × samples) that would be almost all zeros. The right choice when the segments form a partition (e.g. per-scan-interval Legendre polynomials); KroneckerBasis does not help here, as it assumes every factor is dense at every sample.

Samples in no segment must have their values column pre-zeroed by the builder; their segment id is then irrelevant.

Methods:

Attributes:

Source code in src/furax/mapmaking/templates.py
class SegmentedBasis(Basis):
    """Basis partitioned into segments, each sample belonging to exactly one.

    The amplitude index is `(j, k)` = segment `j` × shared sub-basis function `k`, with
    `b_{j,k}(t) = [segment(t) = j] · v_k(t)`. Since every sample lies in a single
    segment, only that segment's amplitudes contribute to it.

    Stored sparsely as one segment id per sample plus one shared table `v_k(t)`, instead
    of the equivalent dense per-segment array (segments × functions × samples) that would
    be almost all zeros. The right choice when the segments form a partition (e.g. per-scan-interval
    Legendre polynomials); `KroneckerBasis` does not help here, as it assumes every
    factor is dense at every sample.

    Samples in no segment must have their `values` column pre-zeroed by the builder;
    their segment id is then irrelevant.
    """

    segment: Int[Array, ' samp']
    values: Float[Array, 'k samp']

    @classmethod
    def create(
        cls,
        segment: Int[Array, ' samp'],
        values: Float[Array, 'k samp'],
        n_segments: int,
    ) -> Self:
        k = values.shape[0]
        return cls(
            segment=segment,
            values=values,
            in_structure=ShapeDtypeStruct((n_segments, k), values.dtype),
        )

    @property
    def n_points(self) -> int:
        return self.values.shape[-1]

    def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
        # gather each sample's segment coefficients, then contract over the
        # sub-basis index against the shared per-sample values.
        picked = coeffs[self.segment]  # (n_points, k)
        return jnp.einsum('sk,ks->s', picked, self.values)

    def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
        # adjoint of expand: per-sample contribution scatter-added into its segment.
        contrib = self.values * signal[None, :]  # (k, n_points)
        zeros = jnp.zeros(self.shape, self.dtype)
        return zeros.at[self.segment].add(contrib.T)

segment instance-attribute

values instance-attribute

n_points property

create(segment, values, n_segments) classmethod

Source code in src/furax/mapmaking/templates.py
@classmethod
def create(
    cls,
    segment: Int[Array, ' samp'],
    values: Float[Array, 'k samp'],
    n_segments: int,
) -> Self:
    k = values.shape[0]
    return cls(
        segment=segment,
        values=values,
        in_structure=ShapeDtypeStruct((n_segments, k), values.dtype),
    )

expand(coeffs)

Source code in src/furax/mapmaking/templates.py
def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
    # gather each sample's segment coefficients, then contract over the
    # sub-basis index against the shared per-sample values.
    picked = coeffs[self.segment]  # (n_points, k)
    return jnp.einsum('sk,ks->s', picked, self.values)

project(signal)

Source code in src/furax/mapmaking/templates.py
def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
    # adjoint of expand: per-sample contribution scatter-added into its segment.
    contrib = self.values * signal[None, :]  # (k, n_points)
    zeros = jnp.zeros(self.shape, self.dtype)
    return zeros.at[self.segment].add(contrib.T)

WindowedBasis dataclass

Bases: Basis

Basis of overlapping blocks, each sample reading a fixed-width window of them.

The amplitude index is a pair (block, sub-basis function). Every sample falls under a fixed number of consecutive blocks, weighting each by how far the sample sits inside it and each sub-basis function by its value there. The typical case is a cubic B-spline, where every sample lies under four consecutive knots.

Stored sparsely as, per sample, the index of its first block, the window weights, and the shared sub-basis values, instead of the equivalent dense TensorBasis whose columns would be almost all zeros. The right choice when blocks overlap by a fixed amount; SegmentedBasis is the non-overlapping single-block-per-sample special case, kept separate to avoid storing its trivial unit window.

The builder must keep every window inside the block range, pre-zeroing the weights of any sample whose window overhangs the ends.

Methods:

Attributes:

Source code in src/furax/mapmaking/templates.py
class WindowedBasis(Basis):
    """Basis of overlapping blocks, each sample reading a fixed-width window of them.

    The amplitude index is a pair (block, sub-basis function). Every sample falls under a fixed
    number of consecutive blocks, weighting each by how far the sample sits inside it and each
    sub-basis function by its value there. The typical case is a cubic B-spline, where every
    sample lies under four consecutive knots.

    Stored sparsely as, per sample, the index of its first block, the window weights, and the
    shared sub-basis values, instead of the equivalent dense `TensorBasis` whose columns would
    be almost all zeros. The right choice when blocks overlap by a fixed amount;
    `SegmentedBasis` is the non-overlapping single-block-per-sample special case, kept separate
    to avoid storing its trivial unit window.

    The builder must keep every window inside the block range, pre-zeroing the weights of any
    sample whose window overhangs the ends.
    """

    offset: Int[Array, ' samp']
    block_weights: Float[Array, 'O samp']
    sub_values: Float[Array, 'k samp']

    @classmethod
    def create(
        cls,
        offset: Int[Array, ' samp'],
        block_weights: Float[Array, 'O samp'],
        sub_values: Float[Array, 'k samp'],
        n_blocks: int,
    ) -> Self:
        n_window, n_points = block_weights.shape
        k = sub_values.shape[0]
        if offset.shape != (n_points,) or sub_values.shape[1] != n_points:
            raise ValueError(
                f'sample axes disagree: offset {offset.shape}, block_weights {block_weights.shape}'
                f', sub_values {sub_values.shape}'
            )
        if n_window > n_blocks:
            raise ValueError(f'window ({n_window}) wider than block count ({n_blocks})')
        return cls(
            offset=offset,
            block_weights=block_weights,
            sub_values=sub_values,
            in_structure=ShapeDtypeStruct((n_blocks, k), sub_values.dtype),
        )

    @property
    def n_points(self) -> int:
        return self.offset.shape[0]

    def _block_indices(self) -> Int[Array, 'samp O']:
        # each sample's window: O consecutive block ids starting at its offset.
        n_window = self.block_weights.shape[0]
        return self.offset[:, None] + jnp.arange(n_window)

    def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
        # gather each sample's window of block coefficients, contract over the sub-basis
        # index against the shared values and over the window against its taper.
        gathered = coeffs[self._block_indices()]  # (samp, O, k)
        return jnp.einsum('soj,os,js->s', gathered, self.block_weights, self.sub_values)

    def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
        # adjoint of expand: per-sample rank-one contribution scatter-added into its window.
        contrib = jnp.einsum('os,js,s->soj', self.block_weights, self.sub_values, signal)
        zeros = jnp.zeros(self.shape, self.dtype)
        return zeros.at[self._block_indices()].add(contrib)

offset instance-attribute

block_weights instance-attribute

sub_values instance-attribute

n_points property

create(offset, block_weights, sub_values, n_blocks) classmethod

Source code in src/furax/mapmaking/templates.py
@classmethod
def create(
    cls,
    offset: Int[Array, ' samp'],
    block_weights: Float[Array, 'O samp'],
    sub_values: Float[Array, 'k samp'],
    n_blocks: int,
) -> Self:
    n_window, n_points = block_weights.shape
    k = sub_values.shape[0]
    if offset.shape != (n_points,) or sub_values.shape[1] != n_points:
        raise ValueError(
            f'sample axes disagree: offset {offset.shape}, block_weights {block_weights.shape}'
            f', sub_values {sub_values.shape}'
        )
    if n_window > n_blocks:
        raise ValueError(f'window ({n_window}) wider than block count ({n_blocks})')
    return cls(
        offset=offset,
        block_weights=block_weights,
        sub_values=sub_values,
        in_structure=ShapeDtypeStruct((n_blocks, k), sub_values.dtype),
    )

expand(coeffs)

Source code in src/furax/mapmaking/templates.py
def expand(self, coeffs: Float[Array, '*shape']) -> Float[Array, ' samp']:
    # gather each sample's window of block coefficients, contract over the sub-basis
    # index against the shared values and over the window against its taper.
    gathered = coeffs[self._block_indices()]  # (samp, O, k)
    return jnp.einsum('soj,os,js->s', gathered, self.block_weights, self.sub_values)

project(signal)

Source code in src/furax/mapmaking/templates.py
def project(self, signal: Float[Array, ' samp']) -> Float[Array, '*shape']:
    # adjoint of expand: per-sample rank-one contribution scatter-added into its window.
    contrib = jnp.einsum('os,js,s->soj', self.block_weights, self.sub_values, signal)
    zeros = jnp.zeros(self.shape, self.dtype)
    return zeros.at[self._block_indices()].add(contrib)

PerDetectorTemplate dataclass

Bases: AbstractLinearOperator

Turn a single-detector Basis into a per-detector template operator.

Each detector is an independent block fitting its own amplitudes. Two modes:

  • shared_basis=True (default): all detectors use the same basis. Used by templates whose basis depends only on shared quantities (azimuth, HWP angle): polynomial, scan- and HWP-synchronous.
  • shared_basis=False: each detector has its own basis. Used by the T2P leakage template, where detector d's basis is its own temperature stream.

Methods:

  • from_basis

    Build the per-detector operator over n_dets detectors from a single basis.

  • mv
  • transpose
  • scan_synchronous

    Scan-synchronous (azimuth-only) template on a global Legendre basis.

  • binaz_synchronous

    Binned azimuth-synchronous template, no HWP coupling.

  • hwp_synchronous

    HWP-synchronous template: harmonics of the HWP angle, k = 1..n_harmonics.

  • azhwp_synchronous

    Azimuth-Legendre × HWP-harmonic template (Kronecker product of the two).

  • binazhwp_synchronous

    Azimuth-binned × HWP-harmonic template (azimuth is always binned).

  • polynomial

    A polynomial drift template, one polynomial per scanning interval.

  • temperature

    Temperature-to-polarization leakage template.

  • none

    Empty template: no amplitudes, zero output.

  • bspline_hwpss

    Spline-based HWP synchronous template.

Attributes:

Source code in src/furax/mapmaking/templates.py
class PerDetectorTemplate(AbstractLinearOperator):
    """Turn a single-detector `Basis` into a per-detector template operator.

    Each detector is an independent block fitting its own amplitudes. Two modes:

    - `shared_basis=True` (default): all detectors use the same basis. Used by templates
      whose basis depends only on shared quantities (azimuth, HWP angle): polynomial,
      scan- and HWP-synchronous.
    - `shared_basis=False`: each detector has its own basis. Used by the T2P leakage
      template, where detector `d`'s basis is its own temperature stream.
    """

    operator: AbstractLinearOperator
    shared_basis: bool = field(default=True, metadata={'static': True})

    @classmethod
    def from_basis(cls, basis: Basis, n_dets: int, *, shared: bool = True) -> Self:
        """Build the per-detector operator over `n_dets` detectors from a single `basis`.

        `shared=True` uses one basis for all detectors; `shared=False` expects a
        per-detector basis, one per detector (see the class docstring).
        """
        return cls(
            operator=basis,
            shared_basis=shared,
            in_structure=jax.ShapeDtypeStruct((n_dets, *basis.shape), basis.dtype),
        )

    @property
    def out_structure(self) -> jax.ShapeDtypeStruct:
        n_dets = self.in_structure.shape[0]
        out = self.operator.out_structure
        return jax.ShapeDtypeStruct((n_dets, *out.shape), out.dtype)

    def mv(self, x: Float[Array, ' det *shape']) -> Float[Array, 'det samp']:
        if self.shared_basis:
            # broadcast the shared operator across detectors.
            return jax.vmap(self.operator.mv)(x)  # type: ignore[no-any-return]
        # slice the basis values on the detector axis in lockstep with x
        return jax.vmap(lambda op, xi: op.mv(xi), in_axes=(0, 0))(self.operator, x)  # type: ignore[no-any-return]

    def transpose(self) -> AbstractLinearOperator:
        return PerDetectorTemplate(
            self.operator.T, shared_basis=self.shared_basis, in_structure=self.out_structure
        )

    @classmethod
    def scan_synchronous(
        cls,
        legendre: PolynomialOrders,
        azimuth: Float[Array, ' samp'],
        n_dets: int,
        dtype: DTypeLike,
    ) -> Self:
        """Scan-synchronous (azimuth-only) template on a global Legendre basis."""
        legs = _legendre(azimuth, legendre.min_order, legendre.max_order, dtype)
        return cls.from_basis(TensorBasis.create(legs), n_dets=n_dets)

    @classmethod
    def binaz_synchronous(
        cls,
        bins: BinsConfig,
        azimuth: Float[Array, ' samp'],
        n_dets: int,
        dtype: DTypeLike,
    ) -> Self:
        """Binned azimuth-synchronous template, no HWP coupling.

        One amplitude per azimuth bin per detector.
        """
        weights = _bin_weights(azimuth, bins.n_bins, bins.interpolate, bins.smooth, dtype)
        return cls.from_basis(TensorBasis.create(weights), n_dets=n_dets)

    @classmethod
    def hwp_synchronous(
        cls,
        n_harmonics: int,
        hwp_angles: Float[Array, ' samp'],
        n_dets: int,
        dtype: DTypeLike,
    ) -> Self:
        """HWP-synchronous template: harmonics of the HWP angle, `k = 1..n_harmonics`."""
        matrix = _harmonics(hwp_angles, n_harmonics, dtype, dc=False)
        return cls.from_basis(TensorBasis.create(matrix), n_dets=n_dets)

    @classmethod
    def azhwp_synchronous(
        cls,
        legendre: PolynomialOrders,
        n_harmonics: int,
        azimuth: Float[Array, ' samp'],
        hwp_angles: Float[Array, ' samp'],
        n_dets: int,
        dtype: DTypeLike,
        scan_mask: Float[Array, ' samp'] | None = None,
    ) -> Self:
        """Azimuth-Legendre × HWP-harmonic template (Kronecker product of the two).

        `scan_mask` optionally zeroes the azimuth leg on flagged samples (e.g. to fit
        separate amplitudes per scan direction).
        """
        poly = _legendre(azimuth, legendre.min_order, legendre.max_order, dtype)
        if scan_mask is not None:
            poly = scan_mask[None, :] * poly
        harm = _harmonics(hwp_angles, n_harmonics, dtype, dc=True)
        return cls.from_basis(KroneckerBasis.create((poly, harm)), n_dets=n_dets)

    @classmethod
    def binazhwp_synchronous(
        cls,
        bins: BinsConfig,
        n_harmonics: int,
        azimuth: Float[Array, ' samp'],
        hwp_angles: Float[Array, ' samp'],
        n_dets: int,
        dtype: DTypeLike,
    ) -> Self:
        """Azimuth-binned × HWP-harmonic template (azimuth is always binned)."""
        bin_basis = _bin_weights(azimuth, bins.n_bins, bins.interpolate, bins.smooth, dtype)
        harm = _harmonics(hwp_angles, n_harmonics, dtype, dc=True)
        return cls.from_basis(KroneckerBasis.create((bin_basis, harm)), n_dets=n_dets)

    @classmethod
    def polynomial(
        cls,
        max_poly_order: int,
        intervals: Float[Array, 'n_intervals 2'],
        times: Float[Array, ' samp'],
        n_dets: int,
        dtype: DTypeLike,
        valid_mask: Float[Array, ' samp'] | None = None,
    ) -> Self:
        """A polynomial drift template, one polynomial per scanning interval.

        Each sample belongs to one interval and is fitted with Legendre orders
        `0..max_poly_order` over that interval.

        Assumes `intervals` are sorted, non-overlapping `[start, end)` rows. Samples in
        gaps or past the last interval get a zero basis column. `valid_mask` optionally
        zeroes flagged samples (1 = keep, 0 = drop) so they neither carry template
        signal nor constrain the fitted amplitudes.
        """
        n_samps = times.size
        n_intervals = intervals.shape[0]
        starts = intervals[:, 0]
        ends = intervals[:, 1]

        s = jnp.arange(n_samps)
        # interval id per sample: last interval whose start <= s (intervals sorted),
        # clamped into range. Gaps/out-of-range are caught by ``in_range`` below.
        segment = jnp.clip(jnp.searchsorted(starts, s, side='right') - 1, 0, n_intervals - 1)
        seg_start = starts[segment]
        seg_end = ends[segment]
        in_range = (s >= seg_start) & (s < seg_end)

        t0 = times[seg_start]
        span = jnp.where(seg_end > seg_start + 1, times[seg_end - 1] - t0, 1.0)
        # rescale each sample to [-1, 1] within its own interval; out-of-range
        # samples sit at 0 and are zeroed by ``in_range`` below.
        u = jnp.where(in_range, -1.0 + 2.0 * (times - t0) / span, 0.0)
        legs = _legendre_values(u, 0, max_poly_order, dtype)  # (k, n_samps)
        legs = legs * in_range[None, :]
        if valid_mask is not None:
            legs = legs * valid_mask[None, :].astype(dtype)

        basis = SegmentedBasis.create(segment.astype(jnp.int32), legs, n_intervals)
        return cls.from_basis(basis, n_dets=n_dets)

    @classmethod
    def temperature(
        cls,
        temperature: Float[Array, 'det samp'],
        dtype: DTypeLike,
        fit_band: tuple[float, float] | None = None,
        sample_rate: Float[Array, ''] | float = 1.0,
        decimation_factor: int = 1,
    ) -> Self:
        """Temperature-to-polarization leakage template.

        Each detector's basis is just its own temperature stream, so fitting one
        amplitude per detector estimates how much temperature leaks into its
        polarization.

        `fit_band=(f0, f1)` restricts the temperature basis to that frequency band (Hz),
        so the leakage is both estimated and removed only there, keeping the template a
        clean linear operator.

        `decimation_factor=q` stores the basis on a `q`-times coarser grid to cut memory; the
        coarse-grid Nyquist frequency `sample_rate / 2q` must stay above `f1`. As a rule
        of thumb keep it at a few times `f1` (`q ≲ sample_rate / 6·f1`): the fractional
        error on the fitted amplitude grows like `(f1 / (sample_rate / 2q))²`.

        Assumes `temperature` is already deglitched/gap-filled upstream: a glitch left in
        it would smear across the band and bias the fitted amplitude.
        """
        t = temperature
        if fit_band is not None:
            f0, f1 = fit_band
            freqs = jnp.fft.rfftfreq(t.shape[-1], d=1.0 / sample_rate)
            band = (freqs > f0) & (freqs < f1)
            t = jnp.fft.irfft(jnp.fft.rfft(t, axis=-1) * band, n=t.shape[-1], axis=-1)
        n_dets, n_full = t.shape
        q = decimation_factor
        if q > 1:
            # Block-average onto a q-times coarser grid: pad the tail to a whole block,
            # reshape (..., n_dec, q) and mean. ``TensorBasis`` hold-upsamples back to
            # ``n_full`` in synthesis (band-limits above sample_rate / 2q).
            n_dec = -(-n_full // q)  # ceil
            pad = n_dec * q - n_full
            tp = jnp.pad(t, [(0, 0), (0, pad)])
            t_dec = tp.reshape(n_dets, n_dec, q).mean(axis=-1)
            values = t_dec[:, None, :].astype(dtype)  # (det, k=1, dec)
            # per-detector basis: values carry a leading det axis sliced by ``from_basis``,
            # so ``in_structure`` is the single-detector shape (k=1,).
            basis = TensorBasis(
                values=values, q=q, n_full=n_full, in_structure=ShapeDtypeStruct((1,), dtype)
            )
        else:
            values = t[:, None, :].astype(dtype)  # (det, k=1, samp)
            basis = TensorBasis(
                values=values, n_full=n_full, in_structure=ShapeDtypeStruct((1,), dtype)
            )
        return cls.from_basis(basis, n_dets=n_dets, shared=False)

    @classmethod
    def none(cls, n_dets: int, n_samps: int, dtype: DTypeLike) -> Self:
        """Empty template: no amplitudes, zero output.

        Used to leave a Stokes leg untouched in a per-leg block (e.g. the I leg of the
        T2P template, which acts on Q/U only).
        """
        # k-axis is 0, so this is a zero-size array; n_samps only sizes the einsum output.
        values = jnp.zeros((n_dets, 0, n_samps), dtype)
        basis = TensorBasis(
            values=values, n_full=n_samps, in_structure=ShapeDtypeStruct((0,), dtype)
        )
        return cls.from_basis(basis, n_dets=n_dets, shared=False)

    @classmethod
    def bspline_hwpss(
        cls,
        times: Float[Array, ' samp'],
        hwp_angles: Float[Array, ' samp'],
        n_dets: int,
        n_knots: int,
        harmonics: int | Sequence[int] = (4,),
        dtype: DTypeLike = jnp.float32,
    ) -> Self:
        """Spline-based HWP synchronous template.

        A cubic B-spline models the slowly time-varying amplitude of the HWP-synchronous
        signal: knot `j` carries a `(sin kχ, cos kχ)` pair for each harmonic `k`, so the
        amplitudes have shape `(K, 2*n_harmonics)` with `K = n_knots + 2`.

        Args:
            times: Per-sample timestamps used to place the spline knots.
            hwp_angles: Per-sample HWP angle `χ` (radians).
            n_dets: Number of detectors; each fits its own amplitudes.
            n_knots: Number of interior spline knots (see `SplineHWPSSConfig.resolve_n_knots`).
            harmonics: HWP harmonics to model, either an int `n` (the harmonics `1..n`)
                or an explicit sequence of orders.
            dtype: Floating dtype of the basis values.
        """
        offset, weights = bspline.spline_window(times, n_knots)  # weights (samp, 4)
        sub_values = _harmonics(hwp_angles, harmonics, dtype, dc=False).astype(dtype)
        basis = WindowedBasis.create(
            offset, weights.T.astype(dtype), sub_values, n_blocks=n_knots + 2
        )
        return cls.from_basis(basis, n_dets=n_dets, shared=True)

operator instance-attribute

shared_basis = field(default=True, metadata={'static': True}) class-attribute instance-attribute

out_structure property

from_basis(basis, n_dets, *, shared=True) classmethod

Build the per-detector operator over n_dets detectors from a single basis.

shared=True uses one basis for all detectors; shared=False expects a per-detector basis, one per detector (see the class docstring).

Source code in src/furax/mapmaking/templates.py
@classmethod
def from_basis(cls, basis: Basis, n_dets: int, *, shared: bool = True) -> Self:
    """Build the per-detector operator over `n_dets` detectors from a single `basis`.

    `shared=True` uses one basis for all detectors; `shared=False` expects a
    per-detector basis, one per detector (see the class docstring).
    """
    return cls(
        operator=basis,
        shared_basis=shared,
        in_structure=jax.ShapeDtypeStruct((n_dets, *basis.shape), basis.dtype),
    )

mv(x)

Source code in src/furax/mapmaking/templates.py
def mv(self, x: Float[Array, ' det *shape']) -> Float[Array, 'det samp']:
    if self.shared_basis:
        # broadcast the shared operator across detectors.
        return jax.vmap(self.operator.mv)(x)  # type: ignore[no-any-return]
    # slice the basis values on the detector axis in lockstep with x
    return jax.vmap(lambda op, xi: op.mv(xi), in_axes=(0, 0))(self.operator, x)  # type: ignore[no-any-return]

transpose()

Source code in src/furax/mapmaking/templates.py
def transpose(self) -> AbstractLinearOperator:
    return PerDetectorTemplate(
        self.operator.T, shared_basis=self.shared_basis, in_structure=self.out_structure
    )

scan_synchronous(legendre, azimuth, n_dets, dtype) classmethod

Scan-synchronous (azimuth-only) template on a global Legendre basis.

Source code in src/furax/mapmaking/templates.py
@classmethod
def scan_synchronous(
    cls,
    legendre: PolynomialOrders,
    azimuth: Float[Array, ' samp'],
    n_dets: int,
    dtype: DTypeLike,
) -> Self:
    """Scan-synchronous (azimuth-only) template on a global Legendre basis."""
    legs = _legendre(azimuth, legendre.min_order, legendre.max_order, dtype)
    return cls.from_basis(TensorBasis.create(legs), n_dets=n_dets)

binaz_synchronous(bins, azimuth, n_dets, dtype) classmethod

Binned azimuth-synchronous template, no HWP coupling.

One amplitude per azimuth bin per detector.

Source code in src/furax/mapmaking/templates.py
@classmethod
def binaz_synchronous(
    cls,
    bins: BinsConfig,
    azimuth: Float[Array, ' samp'],
    n_dets: int,
    dtype: DTypeLike,
) -> Self:
    """Binned azimuth-synchronous template, no HWP coupling.

    One amplitude per azimuth bin per detector.
    """
    weights = _bin_weights(azimuth, bins.n_bins, bins.interpolate, bins.smooth, dtype)
    return cls.from_basis(TensorBasis.create(weights), n_dets=n_dets)

hwp_synchronous(n_harmonics, hwp_angles, n_dets, dtype) classmethod

HWP-synchronous template: harmonics of the HWP angle, k = 1..n_harmonics.

Source code in src/furax/mapmaking/templates.py
@classmethod
def hwp_synchronous(
    cls,
    n_harmonics: int,
    hwp_angles: Float[Array, ' samp'],
    n_dets: int,
    dtype: DTypeLike,
) -> Self:
    """HWP-synchronous template: harmonics of the HWP angle, `k = 1..n_harmonics`."""
    matrix = _harmonics(hwp_angles, n_harmonics, dtype, dc=False)
    return cls.from_basis(TensorBasis.create(matrix), n_dets=n_dets)

azhwp_synchronous(legendre, n_harmonics, azimuth, hwp_angles, n_dets, dtype, scan_mask=None) classmethod

Azimuth-Legendre × HWP-harmonic template (Kronecker product of the two).

scan_mask optionally zeroes the azimuth leg on flagged samples (e.g. to fit separate amplitudes per scan direction).

Source code in src/furax/mapmaking/templates.py
@classmethod
def azhwp_synchronous(
    cls,
    legendre: PolynomialOrders,
    n_harmonics: int,
    azimuth: Float[Array, ' samp'],
    hwp_angles: Float[Array, ' samp'],
    n_dets: int,
    dtype: DTypeLike,
    scan_mask: Float[Array, ' samp'] | None = None,
) -> Self:
    """Azimuth-Legendre × HWP-harmonic template (Kronecker product of the two).

    `scan_mask` optionally zeroes the azimuth leg on flagged samples (e.g. to fit
    separate amplitudes per scan direction).
    """
    poly = _legendre(azimuth, legendre.min_order, legendre.max_order, dtype)
    if scan_mask is not None:
        poly = scan_mask[None, :] * poly
    harm = _harmonics(hwp_angles, n_harmonics, dtype, dc=True)
    return cls.from_basis(KroneckerBasis.create((poly, harm)), n_dets=n_dets)

binazhwp_synchronous(bins, n_harmonics, azimuth, hwp_angles, n_dets, dtype) classmethod

Azimuth-binned × HWP-harmonic template (azimuth is always binned).

Source code in src/furax/mapmaking/templates.py
@classmethod
def binazhwp_synchronous(
    cls,
    bins: BinsConfig,
    n_harmonics: int,
    azimuth: Float[Array, ' samp'],
    hwp_angles: Float[Array, ' samp'],
    n_dets: int,
    dtype: DTypeLike,
) -> Self:
    """Azimuth-binned × HWP-harmonic template (azimuth is always binned)."""
    bin_basis = _bin_weights(azimuth, bins.n_bins, bins.interpolate, bins.smooth, dtype)
    harm = _harmonics(hwp_angles, n_harmonics, dtype, dc=True)
    return cls.from_basis(KroneckerBasis.create((bin_basis, harm)), n_dets=n_dets)

polynomial(max_poly_order, intervals, times, n_dets, dtype, valid_mask=None) classmethod

A polynomial drift template, one polynomial per scanning interval.

Each sample belongs to one interval and is fitted with Legendre orders 0..max_poly_order over that interval.

Assumes intervals are sorted, non-overlapping [start, end) rows. Samples in gaps or past the last interval get a zero basis column. valid_mask optionally zeroes flagged samples (1 = keep, 0 = drop) so they neither carry template signal nor constrain the fitted amplitudes.

Source code in src/furax/mapmaking/templates.py
@classmethod
def polynomial(
    cls,
    max_poly_order: int,
    intervals: Float[Array, 'n_intervals 2'],
    times: Float[Array, ' samp'],
    n_dets: int,
    dtype: DTypeLike,
    valid_mask: Float[Array, ' samp'] | None = None,
) -> Self:
    """A polynomial drift template, one polynomial per scanning interval.

    Each sample belongs to one interval and is fitted with Legendre orders
    `0..max_poly_order` over that interval.

    Assumes `intervals` are sorted, non-overlapping `[start, end)` rows. Samples in
    gaps or past the last interval get a zero basis column. `valid_mask` optionally
    zeroes flagged samples (1 = keep, 0 = drop) so they neither carry template
    signal nor constrain the fitted amplitudes.
    """
    n_samps = times.size
    n_intervals = intervals.shape[0]
    starts = intervals[:, 0]
    ends = intervals[:, 1]

    s = jnp.arange(n_samps)
    # interval id per sample: last interval whose start <= s (intervals sorted),
    # clamped into range. Gaps/out-of-range are caught by ``in_range`` below.
    segment = jnp.clip(jnp.searchsorted(starts, s, side='right') - 1, 0, n_intervals - 1)
    seg_start = starts[segment]
    seg_end = ends[segment]
    in_range = (s >= seg_start) & (s < seg_end)

    t0 = times[seg_start]
    span = jnp.where(seg_end > seg_start + 1, times[seg_end - 1] - t0, 1.0)
    # rescale each sample to [-1, 1] within its own interval; out-of-range
    # samples sit at 0 and are zeroed by ``in_range`` below.
    u = jnp.where(in_range, -1.0 + 2.0 * (times - t0) / span, 0.0)
    legs = _legendre_values(u, 0, max_poly_order, dtype)  # (k, n_samps)
    legs = legs * in_range[None, :]
    if valid_mask is not None:
        legs = legs * valid_mask[None, :].astype(dtype)

    basis = SegmentedBasis.create(segment.astype(jnp.int32), legs, n_intervals)
    return cls.from_basis(basis, n_dets=n_dets)

temperature(temperature, dtype, fit_band=None, sample_rate=1.0, decimation_factor=1) classmethod

Temperature-to-polarization leakage template.

Each detector's basis is just its own temperature stream, so fitting one amplitude per detector estimates how much temperature leaks into its polarization.

fit_band=(f0, f1) restricts the temperature basis to that frequency band (Hz), so the leakage is both estimated and removed only there, keeping the template a clean linear operator.

decimation_factor=q stores the basis on a q-times coarser grid to cut memory; the coarse-grid Nyquist frequency sample_rate / 2q must stay above f1. As a rule of thumb keep it at a few times f1 (q ≲ sample_rate / 6·f1): the fractional error on the fitted amplitude grows like (f1 / (sample_rate / 2q))².

Assumes temperature is already deglitched/gap-filled upstream: a glitch left in it would smear across the band and bias the fitted amplitude.

Source code in src/furax/mapmaking/templates.py
@classmethod
def temperature(
    cls,
    temperature: Float[Array, 'det samp'],
    dtype: DTypeLike,
    fit_band: tuple[float, float] | None = None,
    sample_rate: Float[Array, ''] | float = 1.0,
    decimation_factor: int = 1,
) -> Self:
    """Temperature-to-polarization leakage template.

    Each detector's basis is just its own temperature stream, so fitting one
    amplitude per detector estimates how much temperature leaks into its
    polarization.

    `fit_band=(f0, f1)` restricts the temperature basis to that frequency band (Hz),
    so the leakage is both estimated and removed only there, keeping the template a
    clean linear operator.

    `decimation_factor=q` stores the basis on a `q`-times coarser grid to cut memory; the
    coarse-grid Nyquist frequency `sample_rate / 2q` must stay above `f1`. As a rule
    of thumb keep it at a few times `f1` (`q ≲ sample_rate / 6·f1`): the fractional
    error on the fitted amplitude grows like `(f1 / (sample_rate / 2q))²`.

    Assumes `temperature` is already deglitched/gap-filled upstream: a glitch left in
    it would smear across the band and bias the fitted amplitude.
    """
    t = temperature
    if fit_band is not None:
        f0, f1 = fit_band
        freqs = jnp.fft.rfftfreq(t.shape[-1], d=1.0 / sample_rate)
        band = (freqs > f0) & (freqs < f1)
        t = jnp.fft.irfft(jnp.fft.rfft(t, axis=-1) * band, n=t.shape[-1], axis=-1)
    n_dets, n_full = t.shape
    q = decimation_factor
    if q > 1:
        # Block-average onto a q-times coarser grid: pad the tail to a whole block,
        # reshape (..., n_dec, q) and mean. ``TensorBasis`` hold-upsamples back to
        # ``n_full`` in synthesis (band-limits above sample_rate / 2q).
        n_dec = -(-n_full // q)  # ceil
        pad = n_dec * q - n_full
        tp = jnp.pad(t, [(0, 0), (0, pad)])
        t_dec = tp.reshape(n_dets, n_dec, q).mean(axis=-1)
        values = t_dec[:, None, :].astype(dtype)  # (det, k=1, dec)
        # per-detector basis: values carry a leading det axis sliced by ``from_basis``,
        # so ``in_structure`` is the single-detector shape (k=1,).
        basis = TensorBasis(
            values=values, q=q, n_full=n_full, in_structure=ShapeDtypeStruct((1,), dtype)
        )
    else:
        values = t[:, None, :].astype(dtype)  # (det, k=1, samp)
        basis = TensorBasis(
            values=values, n_full=n_full, in_structure=ShapeDtypeStruct((1,), dtype)
        )
    return cls.from_basis(basis, n_dets=n_dets, shared=False)

none(n_dets, n_samps, dtype) classmethod

Empty template: no amplitudes, zero output.

Used to leave a Stokes leg untouched in a per-leg block (e.g. the I leg of the T2P template, which acts on Q/U only).

Source code in src/furax/mapmaking/templates.py
@classmethod
def none(cls, n_dets: int, n_samps: int, dtype: DTypeLike) -> Self:
    """Empty template: no amplitudes, zero output.

    Used to leave a Stokes leg untouched in a per-leg block (e.g. the I leg of the
    T2P template, which acts on Q/U only).
    """
    # k-axis is 0, so this is a zero-size array; n_samps only sizes the einsum output.
    values = jnp.zeros((n_dets, 0, n_samps), dtype)
    basis = TensorBasis(
        values=values, n_full=n_samps, in_structure=ShapeDtypeStruct((0,), dtype)
    )
    return cls.from_basis(basis, n_dets=n_dets, shared=False)

bspline_hwpss(times, hwp_angles, n_dets, n_knots, harmonics=(4,), dtype=jnp.float32) classmethod

Spline-based HWP synchronous template.

A cubic B-spline models the slowly time-varying amplitude of the HWP-synchronous signal: knot j carries a (sin kχ, cos kχ) pair for each harmonic k, so the amplitudes have shape (K, 2*n_harmonics) with K = n_knots + 2.

Parameters:

  • times (Float[Array, ' samp']) –

    Per-sample timestamps used to place the spline knots.

  • hwp_angles (Float[Array, ' samp']) –

    Per-sample HWP angle χ (radians).

  • n_dets (int) –

    Number of detectors; each fits its own amplitudes.

  • n_knots (int) –

    Number of interior spline knots (see SplineHWPSSConfig.resolve_n_knots).

  • harmonics (int | Sequence[int], default: (4,) ) –

    HWP harmonics to model, either an int n (the harmonics 1..n) or an explicit sequence of orders.

  • dtype (DTypeLike, default: float32 ) –

    Floating dtype of the basis values.

Source code in src/furax/mapmaking/templates.py
@classmethod
def bspline_hwpss(
    cls,
    times: Float[Array, ' samp'],
    hwp_angles: Float[Array, ' samp'],
    n_dets: int,
    n_knots: int,
    harmonics: int | Sequence[int] = (4,),
    dtype: DTypeLike = jnp.float32,
) -> Self:
    """Spline-based HWP synchronous template.

    A cubic B-spline models the slowly time-varying amplitude of the HWP-synchronous
    signal: knot `j` carries a `(sin kχ, cos kχ)` pair for each harmonic `k`, so the
    amplitudes have shape `(K, 2*n_harmonics)` with `K = n_knots + 2`.

    Args:
        times: Per-sample timestamps used to place the spline knots.
        hwp_angles: Per-sample HWP angle `χ` (radians).
        n_dets: Number of detectors; each fits its own amplitudes.
        n_knots: Number of interior spline knots (see `SplineHWPSSConfig.resolve_n_knots`).
        harmonics: HWP harmonics to model, either an int `n` (the harmonics `1..n`)
            or an explicit sequence of orders.
        dtype: Floating dtype of the basis values.
    """
    offset, weights = bspline.spline_window(times, n_knots)  # weights (samp, 4)
    sub_values = _harmonics(hwp_angles, harmonics, dtype, dc=False).astype(dtype)
    basis = WindowedBasis.create(
        offset, weights.T.astype(dtype), sub_values, n_blocks=n_knots + 2
    )
    return cls.from_basis(basis, n_dets=n_dets, shared=True)

GroundTemplateOperator dataclass

Bases: AbstractLinearOperator

Operator for ground signal templates.

The template amplitudes form a two-dimensional (elevation, azimuth) IQU map of the ground observed that is shared across detectors for the observation range. This class only contains a factory method. All argument angles should be provided in radians.

Methods:

Source code in src/furax/mapmaking/templates.py
class GroundTemplateOperator(AbstractLinearOperator):
    """Operator for ground signal templates.

    The template amplitudes form a two-dimensional (elevation, azimuth) IQU map of the
    ground observed that is shared across detectors for the observation range.
    This class only contains a factory method.
    All argument angles should be provided in radians.
    """

    @classmethod
    def create(
        cls,
        azimuth_resolution: float,
        elevation_resolution: float,
        boresight_azimuth: Float[Array, ' samps'],
        boresight_elevation: Float[Array, ' samps'],
        boresight_rotation: Float[Array, ' samps'],
        detector_quaternions: Float[Array, 'dets 4'],
        hwp_angles: Float[Array, ' samps'],
        stokes: ValidStokesType,
        dtype: DTypeLike,
        landscape: HorizonLandscape | None = None,
        batch_size: int = 0,
    ) -> AbstractLinearOperator:
        # Compute landscape if not provided
        if landscape is None:
            horizon_landscape: HorizonLandscape = cls.get_landscape(
                azimuth_resolution=azimuth_resolution,
                elevation_resolution=elevation_resolution,
                boresight_azimuth=boresight_azimuth,
                boresight_elevation=boresight_elevation,
                detector_quaternions=detector_quaternions,
                stokes=stokes,
                dtype=dtype,
            )
        else:
            horizon_landscape = landscape

        # Azimuth increases in an opposite way to longitude
        boresight_quaternions = quaternion.from_lonlat_angles(
            -boresight_azimuth, boresight_elevation, boresight_rotation
        )
        _, _, det_gamma = quaternion.to_xieta_angles(detector_quaternions)

        n_dets = detector_quaternions.shape[0]
        n_samps = boresight_azimuth.size

        pointing = PointingOperator.create(
            horizon_landscape,
            boresight_quaternions,
            detector_quaternions,
            batch_size=batch_size,
        )

        polarizer = LinearPolarizerOperator.create(
            shape=(n_dets, n_samps),
            dtype=dtype,
            stokes=stokes,
            angles=det_gamma[:, None].astype(dtype),
        )

        if stokes == 'I':
            return polarizer @ pointing

        hwp = HWPOperator.create(
            shape=(n_dets, n_samps), dtype=dtype, stokes=stokes, angles=hwp_angles.astype(dtype)
        )

        return polarizer @ hwp @ pointing

    @classmethod
    def get_landscape(
        cls,
        azimuth_resolution: float,
        elevation_resolution: float,
        boresight_azimuth: Float[Array, ' samps'],
        boresight_elevation: Float[Array, ' samps'],
        detector_quaternions: Float[Array, 'dets 4'],
        stokes: ValidStokesType,
        dtype: DTypeLike,
    ) -> HorizonLandscape:
        # First, set up a grid of (az, el) pairs
        n_grid = 10
        az_grid = jnp.linspace(jnp.min(boresight_azimuth), jnp.max(boresight_azimuth), n_grid)
        el_grid = jnp.linspace(jnp.min(boresight_elevation), jnp.max(boresight_elevation), n_grid)
        az_mesh, el_mesh = jnp.meshgrid(az_grid, el_grid, indexing='ij')
        qbore_mesh = quaternion.from_lonlat_angles(
            -az_mesh, el_mesh, jnp.zeros_like(az_mesh)
        )  # (ndet,N_GRID,N_GRID,4)
        qfull_mesh = quaternion.qmul(
            qbore_mesh[None, :, :, :], detector_quaternions[:, None, None, :]
        )
        det_az_mesh, det_el_mesh, _ = quaternion.to_lonlat_angles(qfull_mesh)
        det_az_mesh = -det_az_mesh

        # Azimuth angle is first restricted to to [0,2pi),
        # and unwrapped along the elevation grid, azimuth grid, and detector axes in order
        det_az_mesh = jnp.unwrap(
            jnp.unwrap(jnp.unwrap(det_az_mesh % (2 * jnp.pi), axis=2), axis=1), axis=0
        )

        # Allow small margins
        az_min = jnp.min(det_az_mesh) - 1e-4
        az_max = jnp.max(det_az_mesh) + 1e-4
        el_min = jnp.min(det_el_mesh) - 1e-4
        el_max = jnp.max(det_el_mesh) + 1e-4

        n_alt = int(np.ceil((el_max - el_min) / elevation_resolution))
        n_az = int(np.ceil((az_max - az_min) / azimuth_resolution))

        landscape = HorizonLandscape(
            shape=(n_az, n_alt),
            altitude_limits=(el_min, el_max),
            azimuth_limits=(az_min, az_max),
            stokes=stokes,
            dtype=dtype,
        )

        return landscape

create(azimuth_resolution, elevation_resolution, boresight_azimuth, boresight_elevation, boresight_rotation, detector_quaternions, hwp_angles, stokes, dtype, landscape=None, batch_size=0) classmethod

Source code in src/furax/mapmaking/templates.py
@classmethod
def create(
    cls,
    azimuth_resolution: float,
    elevation_resolution: float,
    boresight_azimuth: Float[Array, ' samps'],
    boresight_elevation: Float[Array, ' samps'],
    boresight_rotation: Float[Array, ' samps'],
    detector_quaternions: Float[Array, 'dets 4'],
    hwp_angles: Float[Array, ' samps'],
    stokes: ValidStokesType,
    dtype: DTypeLike,
    landscape: HorizonLandscape | None = None,
    batch_size: int = 0,
) -> AbstractLinearOperator:
    # Compute landscape if not provided
    if landscape is None:
        horizon_landscape: HorizonLandscape = cls.get_landscape(
            azimuth_resolution=azimuth_resolution,
            elevation_resolution=elevation_resolution,
            boresight_azimuth=boresight_azimuth,
            boresight_elevation=boresight_elevation,
            detector_quaternions=detector_quaternions,
            stokes=stokes,
            dtype=dtype,
        )
    else:
        horizon_landscape = landscape

    # Azimuth increases in an opposite way to longitude
    boresight_quaternions = quaternion.from_lonlat_angles(
        -boresight_azimuth, boresight_elevation, boresight_rotation
    )
    _, _, det_gamma = quaternion.to_xieta_angles(detector_quaternions)

    n_dets = detector_quaternions.shape[0]
    n_samps = boresight_azimuth.size

    pointing = PointingOperator.create(
        horizon_landscape,
        boresight_quaternions,
        detector_quaternions,
        batch_size=batch_size,
    )

    polarizer = LinearPolarizerOperator.create(
        shape=(n_dets, n_samps),
        dtype=dtype,
        stokes=stokes,
        angles=det_gamma[:, None].astype(dtype),
    )

    if stokes == 'I':
        return polarizer @ pointing

    hwp = HWPOperator.create(
        shape=(n_dets, n_samps), dtype=dtype, stokes=stokes, angles=hwp_angles.astype(dtype)
    )

    return polarizer @ hwp @ pointing

get_landscape(azimuth_resolution, elevation_resolution, boresight_azimuth, boresight_elevation, detector_quaternions, stokes, dtype) classmethod

Source code in src/furax/mapmaking/templates.py
@classmethod
def get_landscape(
    cls,
    azimuth_resolution: float,
    elevation_resolution: float,
    boresight_azimuth: Float[Array, ' samps'],
    boresight_elevation: Float[Array, ' samps'],
    detector_quaternions: Float[Array, 'dets 4'],
    stokes: ValidStokesType,
    dtype: DTypeLike,
) -> HorizonLandscape:
    # First, set up a grid of (az, el) pairs
    n_grid = 10
    az_grid = jnp.linspace(jnp.min(boresight_azimuth), jnp.max(boresight_azimuth), n_grid)
    el_grid = jnp.linspace(jnp.min(boresight_elevation), jnp.max(boresight_elevation), n_grid)
    az_mesh, el_mesh = jnp.meshgrid(az_grid, el_grid, indexing='ij')
    qbore_mesh = quaternion.from_lonlat_angles(
        -az_mesh, el_mesh, jnp.zeros_like(az_mesh)
    )  # (ndet,N_GRID,N_GRID,4)
    qfull_mesh = quaternion.qmul(
        qbore_mesh[None, :, :, :], detector_quaternions[:, None, None, :]
    )
    det_az_mesh, det_el_mesh, _ = quaternion.to_lonlat_angles(qfull_mesh)
    det_az_mesh = -det_az_mesh

    # Azimuth angle is first restricted to to [0,2pi),
    # and unwrapped along the elevation grid, azimuth grid, and detector axes in order
    det_az_mesh = jnp.unwrap(
        jnp.unwrap(jnp.unwrap(det_az_mesh % (2 * jnp.pi), axis=2), axis=1), axis=0
    )

    # Allow small margins
    az_min = jnp.min(det_az_mesh) - 1e-4
    az_max = jnp.max(det_az_mesh) + 1e-4
    el_min = jnp.min(det_el_mesh) - 1e-4
    el_max = jnp.max(det_el_mesh) + 1e-4

    n_alt = int(np.ceil((el_max - el_min) / elevation_resolution))
    n_az = int(np.ceil((az_max - az_min) / azimuth_resolution))

    landscape = HorizonLandscape(
        shape=(n_az, n_alt),
        altitude_limits=(el_min, el_max),
        azimuth_limits=(az_min, az_max),
        stokes=stokes,
        dtype=dtype,
    )

    return landscape

ATOPProjectionOperator

Bases: AbstractLinearOperator

Methods:

Attributes:

Source code in src/furax/mapmaking/templates.py
@square
class ATOPProjectionOperator(AbstractLinearOperator):
    tau: int = field(metadata={'static': True})

    def __init__(
        self,
        tau: int,
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        object.__setattr__(self, 'tau', tau)
        object.__setattr__(self, 'in_structure', in_structure)

    def mv(self, x: Float[Array, 'det samp']) -> Float[Array, 'det samp']:
        n_det, n_samp = self.in_structure.shape
        n_int, n_rem = divmod(n_samp, self.tau)
        y = x[:, : n_int * self.tau].reshape(n_det, n_int, self.tau)
        y = y - jnp.mean(y, axis=-1, keepdims=True)
        y = y.reshape(n_det, n_int * self.tau)
        if n_rem == 0:
            return y
        return jnp.concatenate([y, x[:, -n_rem:]], axis=1)

tau = field(metadata={'static': True}) class-attribute instance-attribute

__init__(tau, *, in_structure)

Source code in src/furax/mapmaking/templates.py
def __init__(
    self,
    tau: int,
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    object.__setattr__(self, 'tau', tau)
    object.__setattr__(self, 'in_structure', in_structure)

mv(x)

Source code in src/furax/mapmaking/templates.py
def mv(self, x: Float[Array, 'det samp']) -> Float[Array, 'det samp']:
    n_det, n_samp = self.in_structure.shape
    n_int, n_rem = divmod(n_samp, self.tau)
    y = x[:, : n_int * self.tau].reshape(n_det, n_int, self.tau)
    y = y - jnp.mean(y, axis=-1, keepdims=True)
    y = y.reshape(n_det, n_int * self.tau)
    if n_rem == 0:
        return y
    return jnp.concatenate([y, x[:, -n_rem:]], axis=1)

furax.mapmaking.weight

Classes:

  • WeightOperator

    Masked noise weight M W M as a single operator.

  • NestedWeightOperator

    Minimum-variance inverse-noise weighting for gappy data, using an iterative algorithm.

WeightOperator dataclass

Bases: AbstractLinearOperator

Masked noise weight M W M as a single operator.

Methods:

Attributes:

Source code in src/furax/mapmaking/weight.py
@symmetric
class WeightOperator(AbstractLinearOperator):
    """Masked noise weight `M W M` as a single operator."""

    weight: AbstractLinearOperator  # symmetric
    mask: MaskOperator

    @classmethod
    def create(cls, weight: AbstractLinearOperator, mask: MaskOperator) -> Self:
        return cls(weight, mask, in_structure=mask.in_structure)

    def with_mask(self, mask: MaskOperator) -> 'WeightOperator':
        """Rebuild the weight around a new mask."""
        return WeightOperator.create(self.weight, mask)

    def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
        W, M = self.weight, self.mask
        if W.is_diagonal:
            return W(M(x))
        return M(W(M(x)))

weight instance-attribute

mask instance-attribute

create(weight, mask) classmethod

Source code in src/furax/mapmaking/weight.py
@classmethod
def create(cls, weight: AbstractLinearOperator, mask: MaskOperator) -> Self:
    return cls(weight, mask, in_structure=mask.in_structure)

with_mask(mask)

Rebuild the weight around a new mask.

Source code in src/furax/mapmaking/weight.py
def with_mask(self, mask: MaskOperator) -> 'WeightOperator':
    """Rebuild the weight around a new mask."""
    return WeightOperator.create(self.weight, mask)

mv(x)

Source code in src/furax/mapmaking/weight.py
def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
    W, M = self.weight, self.mask
    if W.is_diagonal:
        return W(M(x))
    return M(W(M(x)))

NestedWeightOperator dataclass

Bases: AbstractLinearOperator

Minimum-variance inverse-noise weighting for gappy data, using an iterative algorithm.

The minimum-variance weight for gappy data is the inverse of the good-good block of the noise covariance, W_exact = Pᵀ N_gg⁻¹ P (with P selecting the good samples). Forming N_gg⁻¹ directly would need the covariance N; instead, block-inverse algebra moves the whole correction onto the flagged samples, expressing the weight through N⁻¹ alone:

W_exact = N⁻¹ − N⁻¹ Qᵀ (Q N⁻¹ Qᵀ)⁻¹ Q N⁻¹,

where Q packs the flagged samples. Sketch: order the samples good/bad and split N⁻¹ into 2×2 blocks; the Schur complement of its good-good block is exactly N_gg⁻¹, and rearranging that relation gives the line above. The correction lives entirely in the flagged subspace, whose block Q N⁻¹ Qᵀ is small and well-conditioned; the flagged rows of the output are zeroed automatically (no outer mask needed). Only N⁻¹ appears, so this is valid for any symmetric PSD inverse-noise -- including det-det correlations, where forming N = (N⁻¹)⁻¹ is intractable. The inner factor (Q N⁻¹ Qᵀ)⁻¹ is applied by a CG solve, never materialised.

Fixed flagged-subspace budget. Under jit the flagged subspace must have a static size, but the flag count is a runtime value. We therefore pad the flagged-index set to a fixed n_flag_max (jnp.nonzero(..., size=n_flag_max)); a validity vector v marks the real flagged slots, and the inner system is V (Q N⁻¹ Qᵀ) V + (I − V) so padding slots act as the identity and contribute nothing. When the flag count exceeds the budget the weight falls back (per jax.lax.cond) to the cheap inner-mask weight M N⁻¹ M -- still unbiased, just suboptimal -- so correctness never depends on the budget. A fully-masked input exceeds the budget and falls back to M N⁻¹ M = 0, contributing nothing.

The inner solve runs a fixed number of iterations, so W is a constant linear operator -- identical on the RHS and on every system-operator apply. A tolerance-based inner solve would make W depend on its input, i.e. no longer linear. Assumes a single-leaf time-ordered structure (the SO TOD layout).

Optional preconditioner. When there are a few gaps wider than the correlation length the inner block Q N⁻¹ Qᵀ is ill-conditioned; passing the covariance N (cov, the banded/Fourier approximation from the noise model) builds the flagged-block preconditioner Q N Qᵀ, whose product with the inner operator differs from the identity by a boundary term of rank ≈ 2×(correlation bandwidth)×(number of gaps), so inner CG converges in roughly that many steps. This wins only when that rank is below the bare inner-solve count. For the common case of many short gaps (turnarounds/glitches) the bare block is already well-conditioned and preconditioning is a net loss -- pass no cov there (the default). Without cov the inner solve is unpreconditioned.

Methods:

  • create
  • with_mask

    Rebuild the weight around a new mask, re-resolving the budget.

  • mv

Attributes:

Source code in src/furax/mapmaking/weight.py
@symmetric
class NestedWeightOperator(AbstractLinearOperator):
    r"""Minimum-variance inverse-noise weighting for gappy data, using an iterative algorithm.

    The minimum-variance weight for gappy data is the inverse of the *good-good block* of the noise
    covariance, `W_exact = Pᵀ N_gg⁻¹ P` (with `P` selecting the good samples). Forming `N_gg⁻¹`
    directly would need the covariance `N`; instead, block-inverse algebra moves the whole correction
    onto the *flagged* samples, expressing the weight through `N⁻¹` alone:

        W_exact = N⁻¹ − N⁻¹ Qᵀ (Q N⁻¹ Qᵀ)⁻¹ Q N⁻¹,

    where `Q` packs the flagged samples. Sketch: order the samples good/bad and split `N⁻¹` into
    `2×2` blocks; the Schur complement of its good-good block is exactly `N_gg⁻¹`, and rearranging
    that relation gives the line above. The correction lives entirely in the flagged subspace, whose
    block `Q N⁻¹ Qᵀ` is small and well-conditioned; the flagged rows of the output are zeroed
    automatically (no outer mask needed). Only `N⁻¹` appears, so this is valid for any symmetric PSD
    inverse-noise -- including det-det correlations, where forming `N = (N⁻¹)⁻¹` is intractable. The
    inner factor `(Q N⁻¹ Qᵀ)⁻¹` is applied by a CG solve, never materialised.

    **Fixed flagged-subspace budget.** Under jit the flagged subspace must have a static size, but
    the flag count is a runtime value. We therefore pad the flagged-index set to a fixed
    `n_flag_max` (``jnp.nonzero(..., size=n_flag_max)``); a validity vector `v` marks the real
    flagged slots, and the inner system is `V (Q N⁻¹ Qᵀ) V + (I − V)` so padding slots act as the
    identity and contribute nothing. When the flag count exceeds the budget the weight falls back
    (per ``jax.lax.cond``) to the cheap inner-mask weight `M N⁻¹ M` -- still unbiased, just
    suboptimal -- so correctness never depends on the budget. A fully-masked input exceeds the
    budget and falls back to `M N⁻¹ M = 0`, contributing nothing.

    The inner solve runs a fixed number of iterations, so `W` is a constant linear operator --
    identical on the RHS and on every system-operator apply. A tolerance-based inner solve would
    make `W` depend on its input, i.e. no longer linear. Assumes a single-leaf time-ordered
    structure (the SO TOD layout).

    **Optional preconditioner.** When there are a few gaps wider than the correlation length the inner
    block `Q N⁻¹ Qᵀ` is ill-conditioned; passing the covariance `N` (``cov``, the banded/Fourier
    approximation from the noise model) builds the flagged-block preconditioner `Q N Qᵀ`, whose product
    with the inner operator differs from the identity by a boundary term of rank ≈ 2×(correlation
    bandwidth)×(number of gaps), so inner CG converges in roughly that many steps. This wins only when
    that rank is below the bare inner-solve count. For the common case of many short gaps
    (turnarounds/glitches) the bare block is already well-conditioned and preconditioning is a net
    loss -- pass no ``cov`` there (the default). Without ``cov`` the inner solve is unpreconditioned.
    """

    ninv: AbstractLinearOperator  # N⁻¹, symmetric PSD
    mask: MaskOperator  # M (defines the flagged set and the .mask contract)
    max_flag_fraction: float = field(metadata={'static': True})
    n_flag_max: int = field(metadata={'static': True})
    inner_steps: int = field(metadata={'static': True})
    rtol: float = field(metadata={'static': True})
    atol: float = field(metadata={'static': True})
    cov: AbstractLinearOperator | None = None

    @classmethod
    def create(
        cls,
        ninv: AbstractLinearOperator,
        mask: MaskOperator,
        config: NestedConfig,
        cov: AbstractLinearOperator | None = None,
    ) -> Self:
        return cls(
            ninv,
            mask,
            max_flag_fraction=config.max_flag_fraction,
            n_flag_max=_resolve_n_flag_max(mask, config.max_flag_fraction),
            inner_steps=config.inner_steps,
            rtol=config.rtol,
            atol=config.atol,
            cov=cov,
            in_structure=mask.in_structure,
        )

    def with_mask(self, mask: MaskOperator) -> 'NestedWeightOperator':
        """Rebuild the weight around a new mask, re-resolving the budget."""
        new_flag_max = _resolve_n_flag_max(mask, self.max_flag_fraction)
        return replace(self, mask=mask, n_flag_max=new_flag_max)

    def _inner_mask(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
        """Fallback weight `M N⁻¹ M` for an over-budget (or fully-masked) input."""
        return (self.mask @ self.ninv @ self.mask).reduce()(x)

    def _woodbury(
        self,
        x: PyTree[Inexact[Array, '...']],
        bad: Array,
        n_flag: Array,
    ) -> PyTree[Inexact[Array, '...']]:
        ninv, k = self.ninv, self.n_flag_max
        dtype = jax.tree.leaves(self.in_structure)[0].dtype

        # Pack the flagged samples into a fixed-size block; padding slots point at index 0 and are
        # deactivated by the validity vector v.
        idx = jnp.nonzero(bad, size=k, fill_value=0)
        q = IndexOperator(idx, in_structure=self.in_structure)
        kstruct = jax.ShapeDtypeStruct((k,), dtype)
        v = (jnp.arange(k) < n_flag).astype(dtype)
        vop = DiagonalOperator(v, in_structure=kstruct)
        vcomp = DiagonalOperator(1 - v, in_structure=kstruct)  # I − V

        # Inner system A_in = V (Q N⁻¹ Qᵀ) V + (I − V): the real flagged block on valid slots,
        # the identity on padding (so padded slots solve to 0 and drop out of the correction).
        a_in = vop @ q @ ninv @ q.T @ vop + vcomp
        # Preconditioner V (Q N Qᵀ) V + (I − V): the flagged block of the covariance N.
        preconditioner = None
        if self.cov is not None:
            preconditioner = (vop @ q @ self.cov @ q.T @ vop + vcomp).reduce()
        nx = ninv(x)
        rhs = vop(q(nx))
        y = cg(
            a_in,
            rhs,
            preconditioner=preconditioner,
            max_steps=self.inner_steps,
            rtol=self.rtol,
            atol=self.atol,
        ).solution
        correction = ninv(q.T(vop(y)))
        return tree.sub(nx, correction)

    def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
        bad = jax.tree.leaves(self.mask.to_boolean_mask())[0] == 0
        n_flag = bad.sum()
        # Over-budget inputs (including fully-masked ones) fall back to the inner-mask
        # weight; both branches are traced, one is selected at runtime.
        return jax.lax.cond(
            n_flag <= self.n_flag_max,
            lambda operand: self._woodbury(operand, bad, n_flag),
            self._inner_mask,
            x,
        )

ninv instance-attribute

mask instance-attribute

max_flag_fraction = field(metadata={'static': True}) class-attribute instance-attribute

n_flag_max = field(metadata={'static': True}) class-attribute instance-attribute

inner_steps = field(metadata={'static': True}) class-attribute instance-attribute

rtol = field(metadata={'static': True}) class-attribute instance-attribute

atol = field(metadata={'static': True}) class-attribute instance-attribute

cov = None class-attribute instance-attribute

create(ninv, mask, config, cov=None) classmethod

Source code in src/furax/mapmaking/weight.py
@classmethod
def create(
    cls,
    ninv: AbstractLinearOperator,
    mask: MaskOperator,
    config: NestedConfig,
    cov: AbstractLinearOperator | None = None,
) -> Self:
    return cls(
        ninv,
        mask,
        max_flag_fraction=config.max_flag_fraction,
        n_flag_max=_resolve_n_flag_max(mask, config.max_flag_fraction),
        inner_steps=config.inner_steps,
        rtol=config.rtol,
        atol=config.atol,
        cov=cov,
        in_structure=mask.in_structure,
    )

with_mask(mask)

Rebuild the weight around a new mask, re-resolving the budget.

Source code in src/furax/mapmaking/weight.py
def with_mask(self, mask: MaskOperator) -> 'NestedWeightOperator':
    """Rebuild the weight around a new mask, re-resolving the budget."""
    new_flag_max = _resolve_n_flag_max(mask, self.max_flag_fraction)
    return replace(self, mask=mask, n_flag_max=new_flag_max)

mv(x)

Source code in src/furax/mapmaking/weight.py
def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
    bad = jax.tree.leaves(self.mask.to_boolean_mask())[0] == 0
    n_flag = bad.sum()
    # Over-budget inputs (including fully-masked ones) fall back to the inner-mask
    # weight; both branches are traced, one is selected at runtime.
    return jax.lax.cond(
        n_flag <= self.n_flag_max,
        lambda operand: self._woodbury(operand, bad, n_flag),
        self._inner_mask,
        x,
    )