Skip to content

furax.obs

Modules:

Classes:

  • AbstractSEDOperator

    Abstract base class for Spectral Energy Distribution (SED) operators.

  • BeamOperator

    Beam operator with a single transfer function shared across all Stokes components.

  • BeamOperatorIQU

    Beam operator with an independent transfer function per Stokes component.

  • CMBOperator

    Operator for Cosmic Microwave Background (CMB) spectral energy distribution.

  • DustOperator

    Operator for thermal dust spectral energy distribution.

  • HWPOperator

    Operator for an ideal half-wave plate (HWP).

  • LinearPolarizerOperator

    Operator for an ideal linear polarizer.

  • QURotationOperator

    Operator that rotates Q and U Stokes parameters by angle theta.

  • SynchrotronOperator

    Operator for synchrotron spectral energy distribution.

  • PointingOperator

    Operator that projects sky maps to time-ordered data (TOD) using quaternion pointing.

Functions:

furax.obs.AbstractSEDOperator

Bases: AbstractLinearOperator

Abstract base class for Spectral Energy Distribution (SED) operators.

SED operators model how astrophysical components emit radiation across frequencies. They broadcast sky maps to multiple frequency channels.

Subclasses must implement the sed() method to define the spectral energy distribution.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Array of observation frequencies.

Methods:

  • __init__
  • mv
  • sed

    Define the spectral energy distribution transformation.

Source code in src/furax/obs/operators/_seds.py
class AbstractSEDOperator(AbstractLinearOperator):
    """Abstract base class for Spectral Energy Distribution (SED) operators.

    SED operators model how astrophysical components emit radiation across
    frequencies. They broadcast sky maps to multiple frequency channels.

    Subclasses must implement the ``sed()`` method to define the spectral
    energy distribution.

    Attributes:
        frequencies: Array of observation frequencies.
    """

    frequencies: Float[Array, ' a']

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        input_shape = self._get_input_shape(in_structure)
        n_freq = len(frequencies)
        # The operator broadcasts a map (no frequency axis) to a multi-frequency output: the new
        # frequency axis lands just before the trailing spatial axis, with any leading batch axes
        # (e.g. the leading Stokes axis of a single-array Stokes map) broadcasting through.
        n_batch = len(input_shape) - 1
        frequencies = frequencies.reshape((1,) * n_batch + (n_freq, 1))
        object.__setattr__(self, 'frequencies', frequencies)
        object.__setattr__(self, 'in_structure', in_structure)
        # sanity-check shapes at construction time
        _ = jax.eval_shape(self.mv, in_structure)

    def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
        # Insert the frequency axis before the trailing spatial axis, unless already present
        # (e.g. a genuine multi-frequency map passed in directly).
        def func(leaf: Inexact[Array, '...']) -> Inexact[Array, '...']:
            if leaf.ndim < self.frequencies.ndim:
                leaf = jnp.expand_dims(leaf, axis=-2)
            return self.sed() * leaf

        return jax.tree.map(func, x)

    @staticmethod
    def _get_input_shape(in_structure: PyTree[jax.ShapeDtypeStruct]) -> tuple[int, ...]:
        """Determine the shape of the input leaves in the PyTree.

        Args:
            in_structure (PyTree): The PyTree structure.

        Returns:
            tuple[int, ...]: The common shape of the leaves.

        Raises:
            ValueError: If the shapes of the leaves are not consistent.
        """
        input_shapes = set(leaf.shape for leaf in jax.tree.leaves(in_structure))
        if len(input_shapes) != 1:
            raise ValueError(f'the leaves of the input do not have the same shape: {in_structure}')
        return input_shapes.pop()  # type: ignore[no-any-return]

    def _broadcast_over_maps(self, x: Any) -> Float[Array, '...']:
        """Reshape a per-frequency (or scalar) array to broadcast like `frequencies`.

        The frequency axis is placed just before the trailing spatial axis, with batch axes
        broadcasting.
        """
        arr = jnp.asarray(x)
        if arr.ndim == 0:
            return arr.reshape((1,) * self.frequencies.ndim)
        return arr.reshape(self.frequencies.shape)

    @abstractmethod
    def sed(self) -> Float[Array, '...']:
        """Define the spectral energy distribution transformation.

        Returns:
            Float[Array, '...']: The transformed SED.
        """
        ...

    @staticmethod
    def _get_at(
        values: Float[Array, '...'], indices: Int[Array, '...'] | None
    ) -> Float[Array, '...']:
        """Retrieve values at specified indices, or return all values if indices are None.

        Args:
            values (Array): Input array.
            indices (Array | None): Indices to retrieve values from.

        Returns:
            Array: Subset of values or the entire array.
        """
        if indices is None:
            return values
        return values[..., indices]

frequencies instance-attribute

__init__(frequencies, *, in_structure)

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    input_shape = self._get_input_shape(in_structure)
    n_freq = len(frequencies)
    # The operator broadcasts a map (no frequency axis) to a multi-frequency output: the new
    # frequency axis lands just before the trailing spatial axis, with any leading batch axes
    # (e.g. the leading Stokes axis of a single-array Stokes map) broadcasting through.
    n_batch = len(input_shape) - 1
    frequencies = frequencies.reshape((1,) * n_batch + (n_freq, 1))
    object.__setattr__(self, 'frequencies', frequencies)
    object.__setattr__(self, 'in_structure', in_structure)
    # sanity-check shapes at construction time
    _ = jax.eval_shape(self.mv, in_structure)

mv(x)

Source code in src/furax/obs/operators/_seds.py
def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
    # Insert the frequency axis before the trailing spatial axis, unless already present
    # (e.g. a genuine multi-frequency map passed in directly).
    def func(leaf: Inexact[Array, '...']) -> Inexact[Array, '...']:
        if leaf.ndim < self.frequencies.ndim:
            leaf = jnp.expand_dims(leaf, axis=-2)
        return self.sed() * leaf

    return jax.tree.map(func, x)

sed() abstractmethod

Define the spectral energy distribution transformation.

Returns:

  • Float[Array, ...]

    Float[Array, '...']: The transformed SED.

Source code in src/furax/obs/operators/_seds.py
@abstractmethod
def sed(self) -> Float[Array, '...']:
    """Define the spectral energy distribution transformation.

    Returns:
        Float[Array, '...']: The transformed SED.
    """
    ...

furax.obs.BeamOperator dataclass

Bases: AbstractLinearOperator

Beam operator with a single transfer function shared across all Stokes components.

Input PyTree leaves are expected to have shape (nfreq, npix). They are round-tripped through a spherical harmonic transform and the beam is applied in alm space as a per-multipole, per-frequency scaling.

The operator is symmetric (self-adjoint): applying it twice is equivalent to squaring the transfer function. Use inverse to obtain the deconvolution operator (beam_fl replaced by its reciprocal).

Algebraic rule: BeamOperator @ BeamOperator reduces to a single BeamOperator whose beam_fl is the element-wise product of both transfer functions (see BeamRule).

Attributes:

  • lmax (int) –

    Maximum spherical harmonic degree.

  • beam_fl (Float[Array, ...]) –

    Beam transfer function, shape (nfreq, lmax+1) for a per-frequency beam, or (lmax+1,) for a beam shared across all frequencies (promoted to 2-D via jnp.atleast_2d before broadcasting over frequencies).

Examples:

>>> import jax.numpy as jnp
>>> from furax.obs.stokes import StokesIQU
>>> from furax.obs.operators import BeamOperator
>>> nside, lmax, nfreq = 16, 31, 2
>>> npix = 12 * nside ** 2
>>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
>>> beam_fl = jnp.ones((nfreq, lmax + 1))
>>> op = BeamOperator(lmax=lmax, beam_fl=beam_fl, in_structure=structure)

Methods:

  • mv

    Apply the beam to input sky maps.

  • inverse

    Return a BeamOperator with the reciprocal transfer function.

Source code in src/furax/obs/operators/_beam_operator.py
@symmetric
class BeamOperator(AbstractLinearOperator):
    """Beam operator with a single transfer function shared across all Stokes components.

    Input PyTree leaves are expected to have shape ``(nfreq, npix)``.  They are
    round-tripped through a spherical harmonic transform and the beam is applied
    in alm space as a per-multipole, per-frequency scaling.

    The operator is symmetric (self-adjoint): applying it twice is equivalent to
    squaring the transfer function.  Use [`inverse`][] to obtain the deconvolution
    operator (``beam_fl`` replaced by its reciprocal).

    Algebraic rule: ``BeamOperator @ BeamOperator`` reduces to a single
    [`BeamOperator`][] whose ``beam_fl`` is the element-wise product of both
    transfer functions (see `BeamRule`).

    Attributes:
        lmax: Maximum spherical harmonic degree.
        beam_fl: Beam transfer function, shape ``(nfreq, lmax+1)`` for a
            per-frequency beam, or ``(lmax+1,)`` for a beam shared across all
            frequencies (promoted to 2-D via `jnp.atleast_2d` before
            broadcasting over frequencies).

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.obs.stokes import StokesIQU
        >>> from furax.obs.operators import BeamOperator
        >>> nside, lmax, nfreq = 16, 31, 2
        >>> npix = 12 * nside ** 2
        >>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
        >>> beam_fl = jnp.ones((nfreq, lmax + 1))
        >>> op = BeamOperator(lmax=lmax, beam_fl=beam_fl, in_structure=structure)
    """

    lmax: int
    beam_fl: Float[Array, '...']

    def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
        """Apply the beam to input sky maps.

        Args:
            x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
                for a single-frequency input.

        Returns:
            Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
        """
        first_leaf = jax.tree_util.tree_leaves(x)[0]
        input_is_1d = first_leaf.ndim == 1
        first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
        nside = jhp.npix2nside(first_leaf.shape[-1])

        map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
        alm = map2alm.mv(x)

        # Apply the per-multipole transfer function, broadcasting it over every leading axis of the
        # alm leaf (frequency and, for a Stokes-backed map, the leading Stokes axis).
        def apply_beam(alm_leaf: jax.Array) -> jax.Array:
            fl = jnp.broadcast_to(self.beam_fl, alm_leaf.shape[:-2] + (self.lmax + 1,))
            return jnp.asarray(jhp.almxfl(alm_leaf, fl, healpy_ordering=False))

        alm_beam = jax.tree.map(apply_beam, alm)

        out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
        if input_is_1d:
            out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
        return out

    def inverse(self) -> 'BeamOperator':
        """Return a [`BeamOperator`][] with the reciprocal transfer function.

        Returns:
            A new [`BeamOperator`][] whose ``beam_fl`` equals ``1 / self.beam_fl``.
        """
        return BeamOperator(
            lmax=self.lmax, beam_fl=1.0 / self.beam_fl, in_structure=self.in_structure
        )

lmax instance-attribute

beam_fl instance-attribute

mv(x)

Apply the beam to input sky maps.

Parameters:

  • x (PyTree[Inexact[Array, 'nfreq npix']]) –

    Input PyTree whose leaves have shape (nfreq, npix) or (npix,) for a single-frequency input.

Returns:

  • PyTree[Inexact[Array, 'nfreq npix']]

    Beam-smoothed PyTree with the same structure and leaf shapes as x.

Source code in src/furax/obs/operators/_beam_operator.py
def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
    """Apply the beam to input sky maps.

    Args:
        x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
            for a single-frequency input.

    Returns:
        Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
    """
    first_leaf = jax.tree_util.tree_leaves(x)[0]
    input_is_1d = first_leaf.ndim == 1
    first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
    nside = jhp.npix2nside(first_leaf.shape[-1])

    map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
    alm = map2alm.mv(x)

    # Apply the per-multipole transfer function, broadcasting it over every leading axis of the
    # alm leaf (frequency and, for a Stokes-backed map, the leading Stokes axis).
    def apply_beam(alm_leaf: jax.Array) -> jax.Array:
        fl = jnp.broadcast_to(self.beam_fl, alm_leaf.shape[:-2] + (self.lmax + 1,))
        return jnp.asarray(jhp.almxfl(alm_leaf, fl, healpy_ordering=False))

    alm_beam = jax.tree.map(apply_beam, alm)

    out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
    if input_is_1d:
        out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
    return out

inverse()

Return a BeamOperator with the reciprocal transfer function.

Returns:

Source code in src/furax/obs/operators/_beam_operator.py
def inverse(self) -> 'BeamOperator':
    """Return a [`BeamOperator`][] with the reciprocal transfer function.

    Returns:
        A new [`BeamOperator`][] whose ``beam_fl`` equals ``1 / self.beam_fl``.
    """
    return BeamOperator(
        lmax=self.lmax, beam_fl=1.0 / self.beam_fl, in_structure=self.in_structure
    )

furax.obs.BeamOperatorIQU dataclass

Bases: AbstractLinearOperator

Beam operator with an independent transfer function per Stokes component.

Input PyTree leaves are expected to have shape (nfreq, npix). Each leaf is smoothed with its own transfer function taken from the corresponding leaf of beam_fl, enabling different beams for I, Q, and U (or any other Stokes combination).

The operator is symmetric (self-adjoint). Use inverse to obtain the deconvolution operator (each beam_fl leaf replaced by its reciprocal).

Algebraic rule: BeamOperatorIQU @ BeamOperatorIQU reduces to a single operator whose per-leaf transfer functions are the element-wise products of both (see BeamIQURule).

Attributes:

  • lmax (int) –

    Maximum spherical harmonic degree.

  • beam_fl (PyTree[Float[Array, 'nfreq lp1']]) –

    PyTree with the same structure as the input maps; each leaf has shape (nfreq, lmax+1) and carries the per-frequency beam transfer function for that Stokes component.

Examples:

>>> import jax.numpy as jnp
>>> from furax.obs.stokes import StokesIQU
>>> from furax.obs.operators import BeamOperatorIQU
>>> nside, lmax, nfreq = 16, 31, 2
>>> npix = 12 * nside ** 2
>>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
>>> fl = jnp.ones((nfreq, lmax + 1))
>>> beam_fl = StokesIQU(i=fl, q=fl, u=fl)
>>> op = BeamOperatorIQU(lmax=lmax, beam_fl=beam_fl, in_structure=structure)

Methods:

  • mv

    Apply per-Stokes beams to input sky maps.

  • inverse

    Return a BeamOperatorIQU with per-Stokes reciprocal transfer functions.

Source code in src/furax/obs/operators/_beam_operator.py
@symmetric
class BeamOperatorIQU(AbstractLinearOperator):
    """Beam operator with an independent transfer function per Stokes component.

    Input PyTree leaves are expected to have shape ``(nfreq, npix)``.  Each leaf
    is smoothed with its own transfer function taken from the corresponding leaf
    of ``beam_fl``, enabling different beams for I, Q, and U (or any other
    Stokes combination).

    The operator is symmetric (self-adjoint). Use [`inverse`][] to obtain the
    deconvolution operator (each ``beam_fl`` leaf replaced by its reciprocal).

    Algebraic rule: ``BeamOperatorIQU @ BeamOperatorIQU`` reduces to a single
    operator whose per-leaf transfer functions are the element-wise products of
    both (see `BeamIQURule`).

    Attributes:
        lmax: Maximum spherical harmonic degree.
        beam_fl: PyTree with the same structure as the input maps; each leaf has
            shape ``(nfreq, lmax+1)`` and carries the per-frequency beam transfer
            function for that Stokes component.

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.obs.stokes import StokesIQU
        >>> from furax.obs.operators import BeamOperatorIQU
        >>> nside, lmax, nfreq = 16, 31, 2
        >>> npix = 12 * nside ** 2
        >>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
        >>> fl = jnp.ones((nfreq, lmax + 1))
        >>> beam_fl = StokesIQU(i=fl, q=fl, u=fl)
        >>> op = BeamOperatorIQU(lmax=lmax, beam_fl=beam_fl, in_structure=structure)
    """

    lmax: int
    beam_fl: PyTree[Float[Array, 'nfreq lp1']]

    def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
        """Apply per-Stokes beams to input sky maps.

        Args:
            x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
                for a single-frequency input. The PyTree structure must match that
                of ``self.beam_fl``.

        Returns:
            Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
        """
        first_leaf = jax.tree_util.tree_leaves(x)[0]
        input_is_1d = first_leaf.ndim == 1
        first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
        nside = jhp.npix2nside(first_leaf.shape[-1])

        map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
        alm = map2alm.mv(x)

        alm_beam = jax.tree.map(
            lambda alm_leaf, fl: jhp.almxfl(alm_leaf, fl, healpy_ordering=False),
            alm,
            self.beam_fl,
        )
        out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
        if input_is_1d:
            out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
        return out

    def inverse(self) -> 'BeamOperatorIQU':
        """Return a [`BeamOperatorIQU`][] with per-Stokes reciprocal transfer functions.

        Returns:
            A new [`BeamOperatorIQU`][] whose ``beam_fl`` leaves equal
            ``1 / leaf`` for each leaf in ``self.beam_fl``.
        """
        inv_beam = jax.tree.map(lambda fl: 1.0 / fl, self.beam_fl)
        return BeamOperatorIQU(lmax=self.lmax, beam_fl=inv_beam, in_structure=self.in_structure)

lmax instance-attribute

beam_fl instance-attribute

mv(x)

Apply per-Stokes beams to input sky maps.

Parameters:

  • x (PyTree[Inexact[Array, 'nfreq npix']]) –

    Input PyTree whose leaves have shape (nfreq, npix) or (npix,) for a single-frequency input. The PyTree structure must match that of self.beam_fl.

Returns:

  • PyTree[Inexact[Array, 'nfreq npix']]

    Beam-smoothed PyTree with the same structure and leaf shapes as x.

Source code in src/furax/obs/operators/_beam_operator.py
def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
    """Apply per-Stokes beams to input sky maps.

    Args:
        x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
            for a single-frequency input. The PyTree structure must match that
            of ``self.beam_fl``.

    Returns:
        Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
    """
    first_leaf = jax.tree_util.tree_leaves(x)[0]
    input_is_1d = first_leaf.ndim == 1
    first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
    nside = jhp.npix2nside(first_leaf.shape[-1])

    map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
    alm = map2alm.mv(x)

    alm_beam = jax.tree.map(
        lambda alm_leaf, fl: jhp.almxfl(alm_leaf, fl, healpy_ordering=False),
        alm,
        self.beam_fl,
    )
    out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
    if input_is_1d:
        out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
    return out

inverse()

Return a BeamOperatorIQU with per-Stokes reciprocal transfer functions.

Returns:

Source code in src/furax/obs/operators/_beam_operator.py
def inverse(self) -> 'BeamOperatorIQU':
    """Return a [`BeamOperatorIQU`][] with per-Stokes reciprocal transfer functions.

    Returns:
        A new [`BeamOperatorIQU`][] whose ``beam_fl`` leaves equal
        ``1 / leaf`` for each leaf in ``self.beam_fl``.
    """
    inv_beam = jax.tree.map(lambda fl: 1.0 / fl, self.beam_fl)
    return BeamOperatorIQU(lmax=self.lmax, beam_fl=inv_beam, in_structure=self.in_structure)

furax.obs.CMBOperator

Bases: AbstractSEDOperator

Operator for Cosmic Microwave Background (CMB) spectral energy distribution.

The CMB has a blackbody spectrum at T_CMB ~ 2.725 K. In K_CMB units, the SED is constant (unity) across frequencies. In K_RJ units, a frequency-dependent conversion factor is applied.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Observation frequencies [GHz].

  • units (str) –

    Output units ('K_CMB' or 'K_RJ').

  • factor (Float[Array, ...] | float) –

    Unit conversion factor.

Examples:

>>> nu = jnp.array([30, 40, 100])  # GHz
>>> cmb_op = CMBOperator(frequencies=nu, in_structure=landscape.structure)
>>> tod = cmb_op(sky_map)  # Broadcasts CMB map to all frequencies

Methods:

  • __init__
  • sed

    Compute the spectral energy distribution for the CMB.

Source code in src/furax/obs/operators/_seds.py
class CMBOperator(AbstractSEDOperator):
    """Operator for Cosmic Microwave Background (CMB) spectral energy distribution.

    The CMB has a blackbody spectrum at T_CMB ~ 2.725 K. In K_CMB units, the
    SED is constant (unity) across frequencies. In K_RJ units, a frequency-dependent
    conversion factor is applied.

    Attributes:
        frequencies: Observation frequencies [GHz].
        units: Output units ('K_CMB' or 'K_RJ').
        factor: Unit conversion factor.

    Examples:
        >>> nu = jnp.array([30, 40, 100])  # GHz
        >>> cmb_op = CMBOperator(frequencies=nu, in_structure=landscape.structure)
        >>> tod = cmb_op(sky_map)  # Broadcasts CMB map to all frequencies
    """

    factor: Float[Array, '...'] | float
    units: str = field(metadata={'static': True})

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct],
        units: str = 'K_CMB',
    ) -> None:
        factor: Float[Array, ...] | float
        if units == 'K_CMB':
            factor = 1.0
        elif units == 'K_RJ':
            factor = K_RK_2_K_CMB(frequencies)
        else:
            raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
        object.__setattr__(self, 'factor', factor)
        object.__setattr__(self, 'units', units)
        super().__init__(frequencies, in_structure=in_structure)

    def sed(self) -> Float[Array, '...']:
        """Compute the spectral energy distribution for the CMB.

        Returns:
            Float[Array, '...']: The SED for the CMB.
        """
        return jnp.ones_like(self.frequencies) / self._broadcast_over_maps(self.factor)

factor instance-attribute

units = field(metadata={'static': True}) class-attribute instance-attribute

__init__(frequencies, *, in_structure, units='K_CMB')

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct],
    units: str = 'K_CMB',
) -> None:
    factor: Float[Array, ...] | float
    if units == 'K_CMB':
        factor = 1.0
    elif units == 'K_RJ':
        factor = K_RK_2_K_CMB(frequencies)
    else:
        raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
    object.__setattr__(self, 'factor', factor)
    object.__setattr__(self, 'units', units)
    super().__init__(frequencies, in_structure=in_structure)

sed()

Compute the spectral energy distribution for the CMB.

Returns:

  • Float[Array, ...]

    Float[Array, '...']: The SED for the CMB.

Source code in src/furax/obs/operators/_seds.py
def sed(self) -> Float[Array, '...']:
    """Compute the spectral energy distribution for the CMB.

    Returns:
        Float[Array, '...']: The SED for the CMB.
    """
    return jnp.ones_like(self.frequencies) / self._broadcast_over_maps(self.factor)

furax.obs.DustOperator

Bases: AbstractSEDOperator

Operator for thermal dust spectral energy distribution.

Models dust emission as a modified blackbody: a power law times a Planck function. The SED is: (nu/nu0)^(1+beta) * B(nu,T)/B(nu0,T), where B is the Planck function.

Supports spatially varying spectral parameters via patch indices.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Observation frequencies [GHz].

  • frequency0 (float) –

    Reference frequency [GHz].

  • temperature (Float[Array, ...]) –

    Dust temperature [K].

  • beta (Float[Array, ...]) –

    Spectral index (typically ~1.5).

  • units (str) –

    Output units ('K_CMB' or 'K_RJ').

Examples:

>>> nu = jnp.array([100, 143, 217, 353])  # GHz
>>> dust_op = DustOperator(
...     frequencies=nu, frequency0=353, beta=1.54, temperature=20.0,
...     in_structure=landscape.structure
... )
>>> tod = dust_op(dust_map)

Methods:

Source code in src/furax/obs/operators/_seds.py
class DustOperator(AbstractSEDOperator):
    """Operator for thermal dust spectral energy distribution.

    Models dust emission as a modified blackbody: a power law times a Planck
    function. The SED is: (nu/nu0)^(1+beta) * B(nu,T)/B(nu0,T), where B is
    the Planck function.

    Supports spatially varying spectral parameters via patch indices.

    Attributes:
        frequencies: Observation frequencies [GHz].
        frequency0: Reference frequency [GHz].
        temperature: Dust temperature [K].
        beta: Spectral index (typically ~1.5).
        units: Output units ('K_CMB' or 'K_RJ').

    Examples:
        >>> nu = jnp.array([100, 143, 217, 353])  # GHz
        >>> dust_op = DustOperator(
        ...     frequencies=nu, frequency0=353, beta=1.54, temperature=20.0,
        ...     in_structure=landscape.structure
        ... )
        >>> tod = dust_op(dust_map)
    """

    temperature: Float[Array, '...']
    temperature_patch_indices: Int[Array, '...'] | None
    beta: Float[Array, '...']
    beta_patch_indices: Int[Array, '...'] | None
    factor: Float[Array, '...'] | float
    units: str = field(metadata={'static': True})
    frequency0: float = field(metadata={'static': True})

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        frequency0: float = 100,
        temperature: float | Float[Array, '...'],
        units: str = 'K_CMB',
        temperature_patch_indices: Int[Array, '...'] | None = None,
        beta: float | Float[Array, '...'],
        beta_patch_indices: Int[Array, '...'] | None = None,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        factor: Float[Array, ...] | float
        if units == 'K_CMB':
            factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
        elif units == 'K_RJ':
            factor = 1.0
        else:
            raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
        object.__setattr__(self, 'temperature', jnp.asarray(temperature))
        object.__setattr__(self, 'temperature_patch_indices', temperature_patch_indices)
        object.__setattr__(self, 'beta', jnp.asarray(beta))
        object.__setattr__(self, 'beta_patch_indices', beta_patch_indices)
        object.__setattr__(self, 'units', units)
        object.__setattr__(self, 'frequency0', frequency0)
        object.__setattr__(self, 'factor', factor)
        super().__init__(frequencies, in_structure=in_structure)

    def sed(self) -> Float[Array, '...']:
        t = self._get_at(
            jnp.expm1(self.frequency0 / self.temperature * _H_OVER_K_GHZ)
            / jnp.expm1(self.frequencies / self.temperature * _H_OVER_K_GHZ),
            self.temperature_patch_indices,
        )
        b = self._get_at(
            (self.frequencies / self.frequency0) ** (1 + self.beta), self.beta_patch_indices
        )
        sed = (t * b) * self._broadcast_over_maps(self.factor)
        return sed

temperature instance-attribute

temperature_patch_indices instance-attribute

beta instance-attribute

beta_patch_indices instance-attribute

factor instance-attribute

units = field(metadata={'static': True}) class-attribute instance-attribute

frequency0 = field(metadata={'static': True}) class-attribute instance-attribute

__init__(frequencies, *, frequency0=100, temperature, units='K_CMB', temperature_patch_indices=None, beta, beta_patch_indices=None, in_structure)

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    frequency0: float = 100,
    temperature: float | Float[Array, '...'],
    units: str = 'K_CMB',
    temperature_patch_indices: Int[Array, '...'] | None = None,
    beta: float | Float[Array, '...'],
    beta_patch_indices: Int[Array, '...'] | None = None,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    factor: Float[Array, ...] | float
    if units == 'K_CMB':
        factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
    elif units == 'K_RJ':
        factor = 1.0
    else:
        raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
    object.__setattr__(self, 'temperature', jnp.asarray(temperature))
    object.__setattr__(self, 'temperature_patch_indices', temperature_patch_indices)
    object.__setattr__(self, 'beta', jnp.asarray(beta))
    object.__setattr__(self, 'beta_patch_indices', beta_patch_indices)
    object.__setattr__(self, 'units', units)
    object.__setattr__(self, 'frequency0', frequency0)
    object.__setattr__(self, 'factor', factor)
    super().__init__(frequencies, in_structure=in_structure)

sed()

Source code in src/furax/obs/operators/_seds.py
def sed(self) -> Float[Array, '...']:
    t = self._get_at(
        jnp.expm1(self.frequency0 / self.temperature * _H_OVER_K_GHZ)
        / jnp.expm1(self.frequencies / self.temperature * _H_OVER_K_GHZ),
        self.temperature_patch_indices,
    )
    b = self._get_at(
        (self.frequencies / self.frequency0) ** (1 + self.beta), self.beta_patch_indices
    )
    sed = (t * b) * self._broadcast_over_maps(self.factor)
    return sed

furax.obs.HWPOperator dataclass

Bases: AbstractLinearOperator

Operator for an ideal half-wave plate (HWP).

A HWP flips the sign of the U (and V) Stokes parameters while leaving I and Q unchanged. This models the Mueller matrix diag(1, 1, -1, -1).

The operator is diagonal and symmetric. For a rotating HWP at angle theta, use HWPOperator.create(..., angles=theta) which computes R(-theta) @ HWP @ R(theta).

Algebraic rule: R(theta) @ HWP = HWP @ R(-theta).

Methods:

Source code in src/furax/obs/operators/_hwp.py
@diagonal
class HWPOperator(AbstractLinearOperator):
    """Operator for an ideal half-wave plate (HWP).

    A HWP flips the sign of the U (and V) Stokes parameters while leaving
    I and Q unchanged. This models the Mueller matrix diag(1, 1, -1, -1).

    The operator is diagonal and symmetric. For a rotating HWP at angle theta,
    use ``HWPOperator.create(..., angles=theta)`` which computes R(-theta) @ HWP @ R(theta).

    Algebraic rule: R(theta) @ HWP = HWP @ R(-theta).
    """

    @classmethod
    def create(
        cls,
        shape: tuple[int, ...],
        dtype: DTypeLike = np.float64,
        stokes: ValidStokesType = 'IQU',
        *,
        angles: Float[Array, '...'] | None = None,
    ) -> AbstractLinearOperator:
        in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
        hwp = cls(in_structure=in_structure)
        if angles is None:
            return hwp
        rot = QURotationOperator(angles=angles, in_structure=in_structure)
        rotated_hwp: AbstractLinearOperator = rot.T @ hwp @ rot
        return rotated_hwp

    def mv(self, x: StokesType) -> Stokes:
        if isinstance(x, StokesI):
            return x
        if isinstance(x, StokesQU):
            return StokesQU(x.q, -x.u)
        if isinstance(x, StokesIQU):
            return StokesIQU(x.i, x.q, -x.u)
        if isinstance(x, StokesIQUV):
            return StokesIQUV(x.i, x.q, -x.u, -x.v)
        raise NotImplementedError

create(shape, dtype=np.float64, stokes='IQU', *, angles=None) classmethod

Source code in src/furax/obs/operators/_hwp.py
@classmethod
def create(
    cls,
    shape: tuple[int, ...],
    dtype: DTypeLike = np.float64,
    stokes: ValidStokesType = 'IQU',
    *,
    angles: Float[Array, '...'] | None = None,
) -> AbstractLinearOperator:
    in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
    hwp = cls(in_structure=in_structure)
    if angles is None:
        return hwp
    rot = QURotationOperator(angles=angles, in_structure=in_structure)
    rotated_hwp: AbstractLinearOperator = rot.T @ hwp @ rot
    return rotated_hwp

mv(x)

Source code in src/furax/obs/operators/_hwp.py
def mv(self, x: StokesType) -> Stokes:
    if isinstance(x, StokesI):
        return x
    if isinstance(x, StokesQU):
        return StokesQU(x.q, -x.u)
    if isinstance(x, StokesIQU):
        return StokesIQU(x.i, x.q, -x.u)
    if isinstance(x, StokesIQUV):
        return StokesIQUV(x.i, x.q, -x.u, -x.v)
    raise NotImplementedError

furax.obs.LinearPolarizerOperator dataclass

Bases: AbstractLinearOperator

Operator for an ideal linear polarizer.

Extracts the intensity seen by a linear polarizer aligned with the x-axis: d = 0.5 * (I + Q). For a polarizer at angle psi, use LinearPolarizerOperator.create(..., angles=psi).

This implements: d = 0.5 * (I + Qcos(2psi) + Usin(2psi)).

Algebraic rule: LinearPolarizer @ HWP = LinearPolarizer.

Methods:

Source code in src/furax/obs/operators/_polarizers.py
class LinearPolarizerOperator(AbstractLinearOperator):
    """Operator for an ideal linear polarizer.

    Extracts the intensity seen by a linear polarizer aligned with the x-axis:
    d = 0.5 * (I + Q). For a polarizer at angle psi, use
    ``LinearPolarizerOperator.create(..., angles=psi)``.

    This implements: d = 0.5 * (I + Q*cos(2*psi) + U*sin(2*psi)).

    Algebraic rule: LinearPolarizer @ HWP = LinearPolarizer.
    """

    @classmethod
    def create(
        cls,
        shape: tuple[int, ...],
        dtype: DTypeLike = np.float64,
        stokes: ValidStokesType = 'IQU',
        *,
        angles: Float[Array, '...'] | None = None,
    ) -> AbstractLinearOperator:
        in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
        polarizer = cls(in_structure=in_structure)
        if angles is None:
            return polarizer
        rot = QURotationOperator(angles=angles, in_structure=in_structure)
        rotated_polarizer: AbstractLinearOperator = polarizer @ rot
        return rotated_polarizer

    def mv(self, x: StokesType) -> Float[Array, '...']:
        if isinstance(x, StokesI):
            return 0.5 * x.i
        if isinstance(x, StokesQU):
            return 0.5 * x.q
        return 0.5 * (x.i + x.q)

create(shape, dtype=np.float64, stokes='IQU', *, angles=None) classmethod

Source code in src/furax/obs/operators/_polarizers.py
@classmethod
def create(
    cls,
    shape: tuple[int, ...],
    dtype: DTypeLike = np.float64,
    stokes: ValidStokesType = 'IQU',
    *,
    angles: Float[Array, '...'] | None = None,
) -> AbstractLinearOperator:
    in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
    polarizer = cls(in_structure=in_structure)
    if angles is None:
        return polarizer
    rot = QURotationOperator(angles=angles, in_structure=in_structure)
    rotated_polarizer: AbstractLinearOperator = polarizer @ rot
    return rotated_polarizer

mv(x)

Source code in src/furax/obs/operators/_polarizers.py
def mv(self, x: StokesType) -> Float[Array, '...']:
    if isinstance(x, StokesI):
        return 0.5 * x.i
    if isinstance(x, StokesQU):
        return 0.5 * x.q
    return 0.5 * (x.i + x.q)

furax.obs.QURotationOperator dataclass

Bases: AbstractLinearOperator

Operator that rotates Q and U Stokes parameters by angle theta.

Applies the rotation matrix R(theta) which transforms (Q, U) as: Q' = Qcos(2theta) + Usin(2theta) U' = -Qsin(2theta) + Ucos(2theta)

I and V components are unchanged. The operator is orthogonal: R.T = R.I = R(-theta).

Consecutive rotations combine: R(a) @ R(b) = R(a+b).

Attributes:

  • angles (Float[Array, ...]) –

    Rotation angles in radians.

  • atomic (bool) –

    If True, prevents this operator from being merged with adjacent QU rotation operators during algebraic reduction.

Methods:

Source code in src/furax/obs/operators/_qu_rotations.py
@orthogonal
class QURotationOperator(AbstractLinearOperator):
    """Operator that rotates Q and U Stokes parameters by angle theta.

    Applies the rotation matrix R(theta) which transforms (Q, U) as:
        Q' = Q*cos(2*theta) + U*sin(2*theta)
        U' = -Q*sin(2*theta) + U*cos(2*theta)

    I and V components are unchanged. The operator is orthogonal:
    R.T = R.I = R(-theta).

    Consecutive rotations combine: R(a) @ R(b) = R(a+b).

    Attributes:
        angles: Rotation angles in radians.
        atomic: If True, prevents this operator from being merged with adjacent
            QU rotation operators during algebraic reduction.
    """

    angles: Float[Array, '...']
    atomic: bool = field(kw_only=True, default=False, metadata={'static': True})

    @classmethod
    def create(
        cls,
        shape: tuple[int, ...],
        dtype: DTypeLike = np.float64,
        stokes: ValidStokesType = 'IQU',
        *,
        angles: Float[Array, '...'],
        atomic: bool = False,
    ) -> AbstractLinearOperator:
        structure = Stokes.class_for(stokes).structure_for(shape, dtype)
        return cls(angles=angles, atomic=atomic, in_structure=structure)

    def mv(self, x: StokesType) -> StokesType:
        return rotate_qu(x, self.angles)  # type: ignore[no-any-return]

    def transpose(self) -> AbstractLinearOperator:
        return QURotationTransposeOperator(operator=self)

angles instance-attribute

atomic = field(kw_only=True, default=False, metadata={'static': True}) class-attribute instance-attribute

create(shape, dtype=np.float64, stokes='IQU', *, angles, atomic=False) classmethod

Source code in src/furax/obs/operators/_qu_rotations.py
@classmethod
def create(
    cls,
    shape: tuple[int, ...],
    dtype: DTypeLike = np.float64,
    stokes: ValidStokesType = 'IQU',
    *,
    angles: Float[Array, '...'],
    atomic: bool = False,
) -> AbstractLinearOperator:
    structure = Stokes.class_for(stokes).structure_for(shape, dtype)
    return cls(angles=angles, atomic=atomic, in_structure=structure)

mv(x)

Source code in src/furax/obs/operators/_qu_rotations.py
def mv(self, x: StokesType) -> StokesType:
    return rotate_qu(x, self.angles)  # type: ignore[no-any-return]

transpose()

Source code in src/furax/obs/operators/_qu_rotations.py
def transpose(self) -> AbstractLinearOperator:
    return QURotationTransposeOperator(operator=self)

furax.obs.SynchrotronOperator

Bases: AbstractSEDOperator

Operator for synchrotron spectral energy distribution.

Models synchrotron emission as a power law: (nu/nu0)^beta, with optional spectral index running: (nu/nu0)^(beta + running * log(nu/nu_pivot)).

Supports spatially varying spectral parameters via patch indices.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Observation frequencies [GHz].

  • frequency0 (float) –

    Reference frequency [GHz].

  • beta_pl (Float[Array, ...]) –

    Power-law spectral index (typically ~ -3).

  • nu_pivot (float) –

    Pivot frequency for running [GHz].

  • running (float) –

    Running of the spectral index.

  • units (str) –

    Output units ('K_CMB' or 'K_RJ').

Examples:

>>> nu = jnp.array([30, 44, 70])  # GHz
>>> sync_op = SynchrotronOperator(
...     frequencies=nu, frequency0=30, beta_pl=-3.0,
...     in_structure=landscape.structure
... )
>>> tod = sync_op(synchrotron_map)

Methods:

Source code in src/furax/obs/operators/_seds.py
class SynchrotronOperator(AbstractSEDOperator):
    """Operator for synchrotron spectral energy distribution.

    Models synchrotron emission as a power law: (nu/nu0)^beta, with optional
    spectral index running: (nu/nu0)^(beta + running * log(nu/nu_pivot)).

    Supports spatially varying spectral parameters via patch indices.

    Attributes:
        frequencies: Observation frequencies [GHz].
        frequency0: Reference frequency [GHz].
        beta_pl: Power-law spectral index (typically ~ -3).
        nu_pivot: Pivot frequency for running [GHz].
        running: Running of the spectral index.
        units: Output units ('K_CMB' or 'K_RJ').

    Examples:
        >>> nu = jnp.array([30, 44, 70])  # GHz
        >>> sync_op = SynchrotronOperator(
        ...     frequencies=nu, frequency0=30, beta_pl=-3.0,
        ...     in_structure=landscape.structure
        ... )
        >>> tod = sync_op(synchrotron_map)
    """

    beta_pl: Float[Array, '...']
    beta_pl_patch_indices: Int[Array, '...'] | None
    nu_pivot: float = field(metadata={'static': True})
    running: float = field(metadata={'static': True})
    units: str = field(metadata={'static': True})
    frequency0: float = field(metadata={'static': True})
    factor: Float[Array, '...'] | float

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        frequency0: float = 100,
        nu_pivot: float = 1.0,
        running: float = 0.0,
        units: str = 'K_CMB',
        beta_pl: float | Float[Array, '...'],
        beta_pl_patch_indices: Int[Array, '...'] | None = None,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        factor: Float[Array, ...] | float
        if units == 'K_CMB':
            factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
        elif units == 'K_RJ':
            factor = 1.0
        else:
            raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
        object.__setattr__(self, 'beta_pl', jnp.asarray(beta_pl))
        object.__setattr__(self, 'beta_pl_patch_indices', beta_pl_patch_indices)
        object.__setattr__(self, 'nu_pivot', nu_pivot)
        object.__setattr__(self, 'running', running)
        object.__setattr__(self, 'units', units)
        object.__setattr__(self, 'frequency0', frequency0)
        object.__setattr__(self, 'factor', factor)
        super().__init__(frequencies, in_structure=in_structure)

    def sed(self) -> Float[Array, '...']:
        sed = self._get_at(
            (
                (self.frequencies / self.frequency0)
                ** (self.beta_pl + self.running * jnp.log(self.frequencies / self.nu_pivot))
            ),
            self.beta_pl_patch_indices,
        )

        sed = self._get_at(
            (self.frequencies / self.frequency0) ** self.beta_pl, self.beta_pl_patch_indices
        )
        sed *= self._broadcast_over_maps(self.factor)

        return sed

beta_pl instance-attribute

beta_pl_patch_indices instance-attribute

nu_pivot = field(metadata={'static': True}) class-attribute instance-attribute

running = field(metadata={'static': True}) class-attribute instance-attribute

units = field(metadata={'static': True}) class-attribute instance-attribute

frequency0 = field(metadata={'static': True}) class-attribute instance-attribute

factor instance-attribute

__init__(frequencies, *, frequency0=100, nu_pivot=1.0, running=0.0, units='K_CMB', beta_pl, beta_pl_patch_indices=None, in_structure)

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    frequency0: float = 100,
    nu_pivot: float = 1.0,
    running: float = 0.0,
    units: str = 'K_CMB',
    beta_pl: float | Float[Array, '...'],
    beta_pl_patch_indices: Int[Array, '...'] | None = None,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    factor: Float[Array, ...] | float
    if units == 'K_CMB':
        factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
    elif units == 'K_RJ':
        factor = 1.0
    else:
        raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
    object.__setattr__(self, 'beta_pl', jnp.asarray(beta_pl))
    object.__setattr__(self, 'beta_pl_patch_indices', beta_pl_patch_indices)
    object.__setattr__(self, 'nu_pivot', nu_pivot)
    object.__setattr__(self, 'running', running)
    object.__setattr__(self, 'units', units)
    object.__setattr__(self, 'frequency0', frequency0)
    object.__setattr__(self, 'factor', factor)
    super().__init__(frequencies, in_structure=in_structure)

sed()

Source code in src/furax/obs/operators/_seds.py
def sed(self) -> Float[Array, '...']:
    sed = self._get_at(
        (
            (self.frequencies / self.frequency0)
            ** (self.beta_pl + self.running * jnp.log(self.frequencies / self.nu_pivot))
        ),
        self.beta_pl_patch_indices,
    )

    sed = self._get_at(
        (self.frequencies / self.frequency0) ** self.beta_pl, self.beta_pl_patch_indices
    )
    sed *= self._broadcast_over_maps(self.factor)

    return sed

furax.obs.PointingOperator dataclass

Bases: AbstractLinearOperator

Operator that projects sky maps to time-ordered data (TOD) using quaternion pointing.

Equivalent to: QURotation @ Index @ Ravel, but computed on-the-fly to save memory. For each detector and time sample, it: 1. Computes the sky pixel from boresight and detector quaternions 2. Samples the sky map at that pixel 3. Rotates Stokes QU by the polarization angle

The transpose accumulates TOD into a sky map (binning).

Attributes:

  • landscape (StokesLandscape) –

    The sky pixelization (HEALPix landscape).

  • qbore (Float[Array, 'samp 4']) –

    Boresight quaternions, shape (n_samples, 4).

  • qdet (Float[Array, 'det 4']) –

    Detector quaternions, shape (n_detectors, 4).

  • batch_size (int) –

    Detector batch size (memory/speed tradeoff; see jax.lax.map documentation).

Methods:

  • create
  • mv

    Performs the 'un-pointing' operation, i.e. map->tod.

  • as_stokes_i

    Return a copy of this operator restricted to StokesI.

  • as_expanded_operator

    Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

  • transpose
Source code in src/furax/obs/pointing.py
class PointingOperator(AbstractLinearOperator):
    """Operator that projects sky maps to time-ordered data (TOD) using quaternion pointing.

    Equivalent to: QURotation @ Index @ Ravel, but computed on-the-fly to save memory.
    For each detector and time sample, it:
    1. Computes the sky pixel from boresight and detector quaternions
    2. Samples the sky map at that pixel
    3. Rotates Stokes QU by the polarization angle

    The transpose accumulates TOD into a sky map (binning).

    Attributes:
        landscape: The sky pixelization (HEALPix landscape).
        qbore: Boresight quaternions, shape (n_samples, 4).
        qdet: Detector quaternions, shape (n_detectors, 4).
        batch_size: Detector batch size (memory/speed tradeoff; see jax.lax.map documentation).
    """

    landscape: StokesLandscape
    qbore: Float[Array, 'samp 4']
    qdet: Float[Array, 'det 4']
    batch_size: int = field(metadata={'static': True})
    interpolate: bool = field(metadata={'static': True})
    _out_structure: PyTree[jax.ShapeDtypeStruct] = field(metadata={'static': True})

    @classmethod
    def create(
        cls,
        landscape: StokesLandscape,
        boresight_quaternions: Float[Array, 'samp 4'],
        detector_quaternions: Float[Array, 'det 4'],
        *,
        batch_size: int = 32,
        frame: Literal['boresight', 'detector'] = 'boresight',
        interpolate: bool = False,
    ) -> 'PointingOperator':
        # Explicitly determine the output structure
        ndet = detector_quaternions.shape[0]
        nsamp = boresight_quaternions.shape[0]
        out_structure = Stokes.class_for(landscape.stokes).structure_for(
            (ndet, nsamp), dtype=landscape.dtype
        )

        # In boresight frame, strip the z-rotation (gamma) from each detector quaternion.
        # This absorbs the frame correction into qdet so that _get_cos_sin_angles always
        # works the same way, regardless of frame. Pixel indices are unaffected because
        # a z-rotation does not change the direction of the boresight (z) axis.
        #
        # NB: the xieta parametrization is incomplete and cannot describe all rotations.
        # Thus converting to xieta and back (with gamma=0) may not work in full generality.
        # This approach is more general and just as efficient.
        if frame == 'boresight':
            gamma = to_gamma_angles(detector_quaternions)
            q_z_neg = euler(2, -gamma)  # z-rotation by -gamma
            detector_quaternions = qmul(detector_quaternions, q_z_neg)

        return cls(
            landscape,
            qbore=boresight_quaternions,
            qdet=detector_quaternions,
            batch_size=batch_size,
            interpolate=interpolate,
            in_structure=landscape.structure,
            _out_structure=out_structure,
        )

    @jit
    def mv(self, x: StokesType) -> StokesType:
        """Performs the 'un-pointing' operation, i.e. map->tod."""
        x_flat = x.ravel()

        def mv_inner(qdet: Float[Array, ' 4']) -> StokesType:
            # Expand one detector's quaternion from boresight and offset: (samp, 4)
            qdet_full = qmul(self.qbore, qdet)

            tod = self._sample(x_flat, qdet_full)
            tod = self._modulate(tod, qdet_full)

            if isinstance(tod, StokesI):
                # no rotation needed
                return tod

            # Return the rotated Stokes parameters
            cos_angles, sin_angles = to_polarization_angle_cos_sin(qdet_full)
            return rotate_qu_cs(tod, cos_angles, sin_angles)  # type: ignore[no-any-return]

        tod_out: StokesType = lax.map(mv_inner, self.qdet, batch_size=self.batch_size)
        # lax.map stacks the new detector axis at position 0
        # so move it back: (det, n_stokes, samp) -> (n_stokes, det, samp).
        return type(tod_out).from_array(jnp.moveaxis(tod_out.data, 0, 1))

    def as_stokes_i(self, *, interpolate: bool | None = None) -> 'PointingOperator':
        """Return a copy of this operator restricted to StokesI.

        Args:
            interpolate: Override the interpolation flag.  If ``None`` (default),
                the flag is inherited from ``self.interpolate``.
        """
        effective_interpolate = self.interpolate if interpolate is None else interpolate
        if self.landscape.stokes == 'I' and effective_interpolate == self.interpolate:
            return self
        landscape = copy.copy(self.landscape)
        landscape.stokes = 'I'
        ndet, nsamp = self.qdet.shape[0], self.qbore.shape[0]
        out_structure = StokesI.structure_for((ndet, nsamp), dtype=landscape.dtype)
        return PointingOperator(
            landscape,
            qbore=self.qbore,
            qdet=self.qdet,
            batch_size=self.batch_size,
            interpolate=effective_interpolate,
            in_structure=landscape.structure,
            _out_structure=out_structure,
        )

    def as_expanded_operator(self) -> AbstractLinearOperator:
        """Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

        Equivalent to mv() but as an explicit composition, useful for testing.
        """
        if self.interpolate:
            raise NotImplementedError('as_expanded_operator does not support interpolate=True')
        qdet_full = qmul(self.qbore, self.qdet[:, None, :])
        indices = self._quat2index(qdet_full)
        # Ravel the spatial axes only; the Stokes container's backing array carries a leading
        # Stokes axis (axis 0) that must survive, so ravel axes 1..-1 and index the pixel axis last.
        ravel_op = RavelOperator(1, -1, in_structure=self.landscape.structure)
        index_op = IndexOperator((..., indices), in_structure=ravel_op.out_structure)
        pa = to_polarization_angle(qdet_full)
        qu_rot_op = QURotationOperator(angles=pa, in_structure=index_op.out_structure)
        return qu_rot_op @ index_op @ ravel_op

    @property
    def out_structure(self) -> PyTree[jax.ShapeDtypeStruct]:
        return self._out_structure

    def _quat2index(self, qdet_full: Float[Array, '*dims 4']) -> Array:
        """Convert full detector quaternions to flat pixel indices.

        Override in subclasses to change the pointing-to-index mapping.
        """
        return self.landscape.quat2index(qdet_full)

    def _quat2interp(self, qdet_full: Float[Array, '*dims 4']) -> tuple[Array, Array]:
        """Convert full detector quaternions to (indices, weights) for interpolation.

        Override in subclasses to change the pointing-to-index mapping.
        """
        return self.landscape.quat2interp(qdet_full)

    def _modulate(self, tod: StokesType, qdet_full: Float[Array, '*dims 4']) -> StokesType:
        """Hook applied to the sampled TOD (identity in the base class).

        Subclasses override this to inject a per-sample diagonal weighting. Because the
        weighting is a symmetric diagonal, the same hook is applied in mv (after sampling)
        and in the transpose (before binning), keeping the adjoint exact.
        """
        return tod

    def _sample(self, x_flat: StokesType, qdet_full: Float[Array, '*dims 4']) -> StokesType:
        """Sample the flat map at positions given by qdet_full."""
        if not self.interpolate:
            return x_flat[self._quat2index(qdet_full)]

        indices, weights = self._quat2interp(qdet_full)
        # Zero out contributions from out-of-bounds pixels (index == -1)
        # pixel index 0 is guaranteed to exist, and weight is zeroed simultaneously
        valid = indices >= 0
        indices = jnp.where(valid, indices, 0)
        weights = jnp.where(valid, weights, 0.0)
        weight_sum = weights.sum(axis=-1, keepdims=True)
        unit_weights = weights / jnp.where(weight_sum > 0, weight_sum, 1.0)
        # leading Stokes axis: index the (trailing) pixel axis and sum over the neighbour axis (-1);
        # the weights broadcast over the leading Stokes axis for free.
        sampled = jnp.sum(x_flat.data[:, indices] * unit_weights, axis=-1)
        return type(x_flat).from_array(sampled)

    def _bin(self, tod_batch: StokesType, qdet_full: Float[Array, '*dims 4']) -> StokesType:
        """Scatter-add a batch of TOD into a sky map."""
        sky_shape = self.landscape.shape
        n_pixels = int(np.prod(sky_shape))
        # scatter-add per pixel while keeping the leading Stokes axis of the backing array.
        arr = tod_batch.data  # (n_stokes, *det_sample)
        n_stokes = arr.shape[0]
        zeros = jnp.zeros((n_stokes, n_pixels), self.landscape.dtype)

        if not self.interpolate:
            flat_pixels = self._quat2index(qdet_full).ravel()
            binned = zeros.at[:, flat_pixels].add(arr.reshape(n_stokes, -1))
            return type(tod_batch).from_array(binned.reshape(n_stokes, *sky_shape))

        indices, weights = self._quat2interp(qdet_full)
        valid = indices >= 0
        safe_indices = jnp.where(valid, indices, 0)
        valid_weights = jnp.where(valid, weights, jnp.zeros_like(weights))
        weight_sum = valid_weights.sum(axis=-1, keepdims=True)
        valid_weights = valid_weights / jnp.where(weight_sum > 0, weight_sum, 1.0)
        flat_indices = safe_indices.ravel()
        # (n_stokes, *det_sample, n_nb): spread each sample over its neighbours (weights broadcast
        # over the leading Stokes axis for free).
        contrib = arr[..., None] * valid_weights
        binned = zeros.at[:, flat_indices].add(contrib.reshape(n_stokes, -1))
        return type(tod_batch).from_array(binned.reshape(n_stokes, *sky_shape))

    def transpose(self) -> AbstractLinearOperator:
        return PointingTransposeOperator(operator=self)

landscape instance-attribute

qbore instance-attribute

qdet instance-attribute

batch_size = field(metadata={'static': True}) class-attribute instance-attribute

interpolate = field(metadata={'static': True}) class-attribute instance-attribute

out_structure property

create(landscape, boresight_quaternions, detector_quaternions, *, batch_size=32, frame='boresight', interpolate=False) classmethod

Source code in src/furax/obs/pointing.py
@classmethod
def create(
    cls,
    landscape: StokesLandscape,
    boresight_quaternions: Float[Array, 'samp 4'],
    detector_quaternions: Float[Array, 'det 4'],
    *,
    batch_size: int = 32,
    frame: Literal['boresight', 'detector'] = 'boresight',
    interpolate: bool = False,
) -> 'PointingOperator':
    # Explicitly determine the output structure
    ndet = detector_quaternions.shape[0]
    nsamp = boresight_quaternions.shape[0]
    out_structure = Stokes.class_for(landscape.stokes).structure_for(
        (ndet, nsamp), dtype=landscape.dtype
    )

    # In boresight frame, strip the z-rotation (gamma) from each detector quaternion.
    # This absorbs the frame correction into qdet so that _get_cos_sin_angles always
    # works the same way, regardless of frame. Pixel indices are unaffected because
    # a z-rotation does not change the direction of the boresight (z) axis.
    #
    # NB: the xieta parametrization is incomplete and cannot describe all rotations.
    # Thus converting to xieta and back (with gamma=0) may not work in full generality.
    # This approach is more general and just as efficient.
    if frame == 'boresight':
        gamma = to_gamma_angles(detector_quaternions)
        q_z_neg = euler(2, -gamma)  # z-rotation by -gamma
        detector_quaternions = qmul(detector_quaternions, q_z_neg)

    return cls(
        landscape,
        qbore=boresight_quaternions,
        qdet=detector_quaternions,
        batch_size=batch_size,
        interpolate=interpolate,
        in_structure=landscape.structure,
        _out_structure=out_structure,
    )

mv(x)

Performs the 'un-pointing' operation, i.e. map->tod.

Source code in src/furax/obs/pointing.py
@jit
def mv(self, x: StokesType) -> StokesType:
    """Performs the 'un-pointing' operation, i.e. map->tod."""
    x_flat = x.ravel()

    def mv_inner(qdet: Float[Array, ' 4']) -> StokesType:
        # Expand one detector's quaternion from boresight and offset: (samp, 4)
        qdet_full = qmul(self.qbore, qdet)

        tod = self._sample(x_flat, qdet_full)
        tod = self._modulate(tod, qdet_full)

        if isinstance(tod, StokesI):
            # no rotation needed
            return tod

        # Return the rotated Stokes parameters
        cos_angles, sin_angles = to_polarization_angle_cos_sin(qdet_full)
        return rotate_qu_cs(tod, cos_angles, sin_angles)  # type: ignore[no-any-return]

    tod_out: StokesType = lax.map(mv_inner, self.qdet, batch_size=self.batch_size)
    # lax.map stacks the new detector axis at position 0
    # so move it back: (det, n_stokes, samp) -> (n_stokes, det, samp).
    return type(tod_out).from_array(jnp.moveaxis(tod_out.data, 0, 1))

as_stokes_i(*, interpolate=None)

Return a copy of this operator restricted to StokesI.

Parameters:

  • interpolate (bool | None, default: None ) –

    Override the interpolation flag. If None (default), the flag is inherited from self.interpolate.

Source code in src/furax/obs/pointing.py
def as_stokes_i(self, *, interpolate: bool | None = None) -> 'PointingOperator':
    """Return a copy of this operator restricted to StokesI.

    Args:
        interpolate: Override the interpolation flag.  If ``None`` (default),
            the flag is inherited from ``self.interpolate``.
    """
    effective_interpolate = self.interpolate if interpolate is None else interpolate
    if self.landscape.stokes == 'I' and effective_interpolate == self.interpolate:
        return self
    landscape = copy.copy(self.landscape)
    landscape.stokes = 'I'
    ndet, nsamp = self.qdet.shape[0], self.qbore.shape[0]
    out_structure = StokesI.structure_for((ndet, nsamp), dtype=landscape.dtype)
    return PointingOperator(
        landscape,
        qbore=self.qbore,
        qdet=self.qdet,
        batch_size=self.batch_size,
        interpolate=effective_interpolate,
        in_structure=landscape.structure,
        _out_structure=out_structure,
    )

as_expanded_operator()

Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

Equivalent to mv() but as an explicit composition, useful for testing.

Source code in src/furax/obs/pointing.py
def as_expanded_operator(self) -> AbstractLinearOperator:
    """Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

    Equivalent to mv() but as an explicit composition, useful for testing.
    """
    if self.interpolate:
        raise NotImplementedError('as_expanded_operator does not support interpolate=True')
    qdet_full = qmul(self.qbore, self.qdet[:, None, :])
    indices = self._quat2index(qdet_full)
    # Ravel the spatial axes only; the Stokes container's backing array carries a leading
    # Stokes axis (axis 0) that must survive, so ravel axes 1..-1 and index the pixel axis last.
    ravel_op = RavelOperator(1, -1, in_structure=self.landscape.structure)
    index_op = IndexOperator((..., indices), in_structure=ravel_op.out_structure)
    pa = to_polarization_angle(qdet_full)
    qu_rot_op = QURotationOperator(angles=pa, in_structure=index_op.out_structure)
    return qu_rot_op @ index_op @ ravel_op

transpose()

Source code in src/furax/obs/pointing.py
def transpose(self) -> AbstractLinearOperator:
    return PointingTransposeOperator(operator=self)

furax.obs.negative_log_likelihood(params, nu, N, d, dust_nu0, synchrotron_nu0, patch_indices=single_cluster_indices, op=None, N_2=None, analytical_gradient=False)

Compute the negative spectral log likelihood.

This function returns the negative of the spectral log likelihood, which is useful for optimization procedures where minimizing the negative log likelihood is equivalent to maximizing the likelihood.

Parameters:

  • params (PyTree[Array]) –

    Dictionary of spectral parameters.

  • nu (Array) –

    Array of frequencies.

  • N (AbstractLinearOperator) –

    Noise covariance operator.

  • d (Stokes) –

    Data in Stokes parameters.

  • dust_nu0 (float) –

    Reference frequency for dust.

  • synchrotron_nu0 (float) –

    Reference frequency for synchrotron.

  • patch_indices (PyTree[Array], default: single_cluster_indices ) –

    Patch indices for spatially varying parameters (default is single_cluster_indices).

  • op (AbstractLinearOperator or None, default: None ) –

    Operator to be applied (default is None).

  • N_2 (AbstractLinearOperator or None, default: None ) –

    Secondary noise operator (default is None).

  • analytical_gradient (bool, default: False ) –

    If True, use the custom VJP implementation for analytical gradients. If False (default), use standard automatic differentiation.

Returns:

  • Scalar ( Scalar ) –

    The negative spectral log likelihood.

Examples:

>>> from furax.obs import negative_log_likelihood
>>> from furax.obs.stokes import Stokes
>>> from furax import HomothetyOperator
>>> import jax.numpy as jnp
>>> nside = 64
>>> nu_freqs = jnp.array([30., 40., 100.])
>>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
>>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
>>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
>>> dust_nu0_ref = 150.0
>>> synchrotron_nu0_ref = 20.0
>>> nll_val = negative_log_likelihood(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
>>> # print(nll_val)
Source code in src/furax/obs/_likelihoods.py
@partial(jax.jit, static_argnums=(4, 5, 9))
def negative_log_likelihood(
    params: PyTree[Array],
    nu: Array,
    N: AbstractLinearOperator,
    d: Stokes,
    dust_nu0: float,
    synchrotron_nu0: float,
    patch_indices: PyTree[Array] = single_cluster_indices,
    op: AbstractLinearOperator | None = None,
    N_2: AbstractLinearOperator | None = None,
    analytical_gradient: bool = False,
) -> Scalar:
    """Compute the negative spectral log likelihood.

    This function returns the negative of the spectral log likelihood, which is useful for
    optimization procedures where minimizing the negative log likelihood is equivalent to
    maximizing the likelihood.

    Args:
        params (PyTree[Array]): Dictionary of spectral parameters.
        nu (Array): Array of frequencies.
        N (AbstractLinearOperator): Noise covariance operator.
        d (Stokes): Data in Stokes parameters.
        dust_nu0 (float): Reference frequency for dust.
        synchrotron_nu0 (float): Reference frequency for synchrotron.
        patch_indices (PyTree[Array], optional): Patch indices for spatially varying parameters (default is single_cluster_indices).
        op (AbstractLinearOperator or None, optional): Operator to be applied (default is None).
        N_2 (AbstractLinearOperator or None, optional): Secondary noise operator (default is None).
        analytical_gradient (bool, optional): If True, use the custom VJP implementation for analytical gradients.
                                            If False (default), use standard automatic differentiation.

    Returns:
        Scalar: The negative spectral log likelihood.

    Examples:
        >>> from furax.obs import negative_log_likelihood
        >>> from furax.obs.stokes import Stokes
        >>> from furax import HomothetyOperator
        >>> import jax.numpy as jnp
        >>> nside = 64
        >>> nu_freqs = jnp.array([30., 40., 100.])
        >>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
        >>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
        >>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
        >>> dust_nu0_ref = 150.0
        >>> synchrotron_nu0_ref = 20.0
        >>> nll_val = negative_log_likelihood(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
        >>> # print(nll_val)
    """
    nll: Scalar = -spectral_log_likelihood(
        params,
        nu,
        N,
        d,
        dust_nu0,
        synchrotron_nu0,
        patch_indices,
        op,
        N_2,
        analytical_gradient=analytical_gradient,
    )
    return nll

furax.obs.preconditionner(params, nu, d, dust_nu0, synchrotron_nu0, patch_indices=single_cluster_indices)

Constructs the MixingMatrixOperator for preconditioning purposes.

This function builds the mixing matrix operator based on the provided spectral parameters and frequencies, without directly involving the observed data or noise operators. It is typically used to create a preconditioner for iterative solvers in component separation.

Parameters:

  • params (PyTree[Array]) –

    Dictionary of spectral parameters.

  • nu (Array) –

    Array of frequencies.

  • d (Stokes) –

    Data in Stokes parameters, used only to infer the in_structure for the MixingMatrixOperator. Its values are not used.

  • dust_nu0 (float) –

    Reference frequency for dust.

  • synchrotron_nu0 (float) –

    Reference frequency for synchrotron.

  • patch_indices (PyTree[Array], default: single_cluster_indices ) –

    Patch indices for spatially varying parameters (default is single_cluster_indices).

Returns:

  • MixingMatrixOperator ( MixingMatrixOperator ) –

    The constructed mixing matrix operator suitable for preconditioning.

Examples:

>>> from furax.obs import preconditionner
>>> from furax.obs.stokes import Stokes
>>> import jax.numpy as jnp
>>> nside = 64
>>> nu_freqs = jnp.array([30., 40., 100.])
>>> dummy_d = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Dummy data for structure
>>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
>>> dust_nu0_ref = 150.0
>>> synchrotron_nu0_ref = 20.0
>>> A_precond = preconditionner(params, nu_freqs, dummy_d, dust_nu0_ref, synchrotron_nu0_ref)
>>> # The preconditioner can now be used with a solver.
Source code in src/furax/obs/_likelihoods.py
@partial(jax.jit, static_argnums=(3, 4))
def preconditionner(
    params: PyTree[Array],
    nu: Array,
    d: Stokes,
    dust_nu0: float,
    synchrotron_nu0: float,
    patch_indices: PyTree[Array] = single_cluster_indices,
) -> MixingMatrixOperator:  # type: ignore[valid-type]
    """Constructs the MixingMatrixOperator for preconditioning purposes.

    This function builds the mixing matrix operator based on the provided spectral parameters
    and frequencies, without directly involving the observed data or noise operators.
    It is typically used to create a preconditioner for iterative solvers in component separation.

    Args:
        params (PyTree[Array]): Dictionary of spectral parameters.
        nu (Array): Array of frequencies.
        d (Stokes): Data in Stokes parameters, used only to infer the `in_structure`
                    for the MixingMatrixOperator. Its values are not used.
        dust_nu0 (float): Reference frequency for dust.
        synchrotron_nu0 (float): Reference frequency for synchrotron.
        patch_indices (PyTree[Array], optional): Patch indices for spatially varying parameters (default is single_cluster_indices).

    Returns:
        MixingMatrixOperator: The constructed mixing matrix operator suitable for preconditioning.

    Examples:
        >>> from furax.obs import preconditionner
        >>> from furax.obs.stokes import Stokes
        >>> import jax.numpy as jnp
        >>> nside = 64
        >>> nu_freqs = jnp.array([30., 40., 100.])
        >>> dummy_d = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Dummy data for structure
        >>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
        >>> dust_nu0_ref = 150.0
        >>> synchrotron_nu0_ref = 20.0
        >>> A_precond = preconditionner(params, nu_freqs, dummy_d, dust_nu0_ref, synchrotron_nu0_ref)
        >>> # The preconditioner can now be used with a solver.
    """
    in_structure = Stokes.structure_for((d.shape[1],))
    A = _get_mixing_matrix(params, nu, dust_nu0, synchrotron_nu0, patch_indices, in_structure)
    return A

furax.obs.sky_signal(params, nu, N, d, dust_nu0, synchrotron_nu0, patch_indices=single_cluster_indices, op=None, N_2=None, analytical_gradient=False)

Computes the estimated sky signal 's'.

Parameters:

  • params (PyTree[Array]) –

    Dictionary of spectral parameters.

  • nu (Array) –

    Array of frequencies.

  • N (AbstractLinearOperator) –

    Noise covariance operator.

  • d (Stokes) –

    Data in Stokes parameters.

  • dust_nu0 (float) –

    Reference frequency for dust.

  • synchrotron_nu0 (float) –

    Reference frequency for synchrotron.

  • patch_indices (PyTree[Array], default: single_cluster_indices ) –

    Patch indices for spatially varying parameters (default is single_cluster_indices).

  • op (AbstractLinearOperator or None, default: None ) –

    Operator to be applied (default is None).

  • N_2 (AbstractLinearOperator or None, default: None ) –

    Secondary noise operator (default is None).

  • analytical_gradient (bool, default: False ) –

    If True, use the custom VJP implementation for analytical gradients. If False (default), use standard automatic differentiation.

Returns:

  • ComponentParametersDict ( ComponentParametersDict ) –

    The estimated sky signal components (e.g., 'cmb', 'dust', 'synchrotron').

Examples:

>>> from furax.obs import sky_signal
>>> from furax.obs.stokes import Stokes
>>> from furax import HomothetyOperator
>>> import jax.numpy as jnp
>>> nside = 64
>>> nu_freqs = jnp.array([30., 40., 100.])
>>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
>>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
>>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
>>> dust_nu0_ref = 150.0
>>> synchrotron_nu0_ref = 20.0
>>> sky_comp = sky_signal(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
>>> # print(sky_comp['cmb'].i.shape)
Source code in src/furax/obs/_likelihoods.py
@partial(jax.jit, static_argnums=(4, 5, 9))
def sky_signal(
    params: PyTree[Array],
    nu: Array,
    N: AbstractLinearOperator,
    d: Stokes,
    dust_nu0: float,
    synchrotron_nu0: float,
    patch_indices: PyTree[Array] = single_cluster_indices,
    op: AbstractLinearOperator | None = None,
    N_2: AbstractLinearOperator | None = None,
    analytical_gradient: bool = False,
) -> ComponentParametersDict:
    """Computes the estimated sky signal 's'.

    Args:
        params (PyTree[Array]): Dictionary of spectral parameters.
        nu (Array): Array of frequencies.
        N (AbstractLinearOperator): Noise covariance operator.
        d (Stokes): Data in Stokes parameters.
        dust_nu0 (float): Reference frequency for dust.
        synchrotron_nu0 (float): Reference frequency for synchrotron.
        patch_indices (PyTree[Array], optional): Patch indices for spatially varying parameters (default is single_cluster_indices).
        op (AbstractLinearOperator or None, optional): Operator to be applied (default is None).
        N_2 (AbstractLinearOperator or None, optional): Secondary noise operator (default is None).
        analytical_gradient (bool, optional): If True, use the custom VJP implementation for analytical gradients.
                                            If False (default), use standard automatic differentiation.

    Returns:
        ComponentParametersDict: The estimated sky signal components (e.g., 'cmb', 'dust', 'synchrotron').

    Examples:
        >>> from furax.obs import sky_signal
        >>> from furax.obs.stokes import Stokes
        >>> from furax import HomothetyOperator
        >>> import jax.numpy as jnp
        >>> nside = 64
        >>> nu_freqs = jnp.array([30., 40., 100.])
        >>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
        >>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
        >>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
        >>> dust_nu0_ref = 150.0
        >>> synchrotron_nu0_ref = 20.0
        >>> sky_comp = sky_signal(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
        >>> # print(sky_comp['cmb'].i.shape)
    """
    if analytical_gradient:
        return _sky_signal_analytical(
            params, nu, N, d, dust_nu0, synchrotron_nu0, patch_indices, op, N_2
        )

    _, s = _spectral_likelihood_core(
        params, patch_indices, nu, N, d, dust_nu0, synchrotron_nu0, op, N_2
    )
    return s

furax.obs.spectral_cmb_variance(params, nu, N, d, dust_nu0, synchrotron_nu0, patch_indices=single_cluster_indices, op=None, N_2=None, analytical_gradient=False)

Compute the variance of the CMB component from the spectral estimation.

This function calculates the variance of the CMB component from the estimated sky signal 's'.

Parameters:

  • params (PyTree[Array]) –

    Dictionary of spectral parameters.

  • nu (Array) –

    Array of frequencies.

  • N (AbstractLinearOperator) –

    Noise covariance operator.

  • d (Stokes) –

    Data in Stokes parameters.

  • dust_nu0 (float) –

    Reference frequency for dust.

  • synchrotron_nu0 (float) –

    Reference frequency for synchrotron.

  • patch_indices (PyTree[Array], default: single_cluster_indices ) –

    Patch indices for spatially varying parameters (default is single_cluster_indices).

  • op (AbstractLinearOperator or None, default: None ) –

    Operator to be applied (default is None).

  • N_2 (AbstractLinearOperator or None, default: None ) –

    Secondary noise operator (default is None).

  • analytical_gradient (bool, default: False ) –

    If True, use the custom VJP implementation for analytical gradients. If False (default), use standard automatic differentiation.

Returns:

  • Scalar ( Scalar ) –

    The variance of the CMB component.

Examples:

>>> from furax.obs import spectral_cmb_variance
>>> from furax.obs.stokes import Stokes
>>> from furax import HomothetyOperator
>>> import jax.numpy as jnp
>>> nside = 64
>>> nu_freqs = jnp.array([30., 40., 100.])
>>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
>>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
>>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
>>> dust_nu0_ref = 150.0
>>> synchrotron_nu0_ref = 20.0
>>> cmb_var_val = spectral_cmb_variance(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
>>> # print(cmb_var_val)
Source code in src/furax/obs/_likelihoods.py
@partial(jax.jit, static_argnums=(4, 5, 9))
def spectral_cmb_variance(
    params: PyTree[Array],
    nu: Array,
    N: AbstractLinearOperator,
    d: Stokes,
    dust_nu0: float,
    synchrotron_nu0: float,
    patch_indices: PyTree[Array] = single_cluster_indices,
    op: AbstractLinearOperator | None = None,
    N_2: AbstractLinearOperator | None = None,
    analytical_gradient: bool = False,
) -> Scalar:
    """Compute the variance of the CMB component from the spectral estimation.

    This function calculates the variance of the CMB component from the estimated sky signal 's'.

    Args:
        params (PyTree[Array]): Dictionary of spectral parameters.
        nu (Array): Array of frequencies.
        N (AbstractLinearOperator): Noise covariance operator.
        d (Stokes): Data in Stokes parameters.
        dust_nu0 (float): Reference frequency for dust.
        synchrotron_nu0 (float): Reference frequency for synchrotron.
        patch_indices (PyTree[Array], optional): Patch indices for spatially varying parameters (default is single_cluster_indices).
        op (AbstractLinearOperator or None, optional): Operator to be applied (default is None).
        N_2 (AbstractLinearOperator or None, optional): Secondary noise operator (default is None).
        analytical_gradient (bool, optional): If True, use the custom VJP implementation for analytical gradients.
                                            If False (default), use standard automatic differentiation.

    Returns:
        Scalar: The variance of the CMB component.

    Examples:
        >>> from furax.obs import spectral_cmb_variance
        >>> from furax.obs.stokes import Stokes
        >>> from furax import HomothetyOperator
        >>> import jax.numpy as jnp
        >>> nside = 64
        >>> nu_freqs = jnp.array([30., 40., 100.])
        >>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
        >>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
        >>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
        >>> dust_nu0_ref = 150.0
        >>> synchrotron_nu0_ref = 20.0
        >>> cmb_var_val = spectral_cmb_variance(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
        >>> # print(cmb_var_val)
    """
    s = sky_signal(
        params,
        nu,
        N,
        d,
        dust_nu0,
        synchrotron_nu0,
        patch_indices,
        op,
        N_2,
        analytical_gradient=analytical_gradient,
    )

    cmb_var: Scalar = jax.tree.reduce(operator.add, jax.tree.map(jnp.var, s['cmb']))
    return cmb_var

furax.obs.spectral_log_likelihood(params, nu, N, d, dust_nu0, synchrotron_nu0, patch_indices=single_cluster_indices, op=None, N_2=None, analytical_gradient=False)

Compute the spectral log likelihood.

Parameters:

  • params (PyTree[Array]) –

    Dictionary of spectral parameters.

  • nu (Array) –

    Array of frequencies.

  • N (AbstractLinearOperator) –

    Noise covariance operator.

  • d (Stokes) –

    Data in Stokes parameters.

  • dust_nu0 (float) –

    Reference frequency for dust.

  • synchrotron_nu0 (float) –

    Reference frequency for synchrotron.

  • patch_indices (PyTree[Array], default: single_cluster_indices ) –

    Patch indices for spatially varying parameters (default is single_cluster_indices).

  • op (AbstractLinearOperator or None, default: None ) –

    Operator to be applied (default is None).

  • N_2 (AbstractLinearOperator or None, default: None ) –

    Secondary noise operator (default is None).

  • analytical_gradient (bool, default: False ) –

    If True, use the custom VJP implementation for analytical gradients. If False (default), use standard automatic differentiation.

Returns:

  • Scalar ( Scalar ) –

    The spectral log likelihood.

Examples:

>>> from furax.obs import spectral_log_likelihood
>>> from furax.obs.stokes import Stokes
>>> from furax import HomothetyOperator
>>> import jax.numpy as jnp
>>> nside = 64
>>> nu_freqs = jnp.array([30., 40., 100.])
>>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
>>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
>>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
>>> dust_nu0_ref = 150.0
>>> synchrotron_nu0_ref = 20.0
>>> ll_val = spectral_log_likelihood(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
>>> # print(ll_val)
Source code in src/furax/obs/_likelihoods.py
@partial(jax.jit, static_argnums=(4, 5, 9))
def spectral_log_likelihood(
    params: PyTree[Array],
    nu: Array,
    N: AbstractLinearOperator,
    d: Stokes,
    dust_nu0: float,
    synchrotron_nu0: float,
    patch_indices: PyTree[Array] = single_cluster_indices,
    op: AbstractLinearOperator | None = None,
    N_2: AbstractLinearOperator | None = None,
    analytical_gradient: bool = False,
) -> Scalar:
    """Compute the spectral log likelihood.

    Args:
        params (PyTree[Array]): Dictionary of spectral parameters.
        nu (Array): Array of frequencies.
        N (AbstractLinearOperator): Noise covariance operator.
        d (Stokes): Data in Stokes parameters.
        dust_nu0 (float): Reference frequency for dust.
        synchrotron_nu0 (float): Reference frequency for synchrotron.
        patch_indices (PyTree[Array], optional): Patch indices for spatially varying parameters (default is single_cluster_indices).
        op (AbstractLinearOperator or None, optional): Operator to be applied (default is None).
        N_2 (AbstractLinearOperator or None, optional): Secondary noise operator (default is None).
        analytical_gradient (bool, optional): If True, use the custom VJP implementation for analytical gradients.
                                            If False (default), use standard automatic differentiation.

    Returns:
        Scalar: The spectral log likelihood.

    Examples:
        >>> from furax.obs import spectral_log_likelihood
        >>> from furax.obs.stokes import Stokes
        >>> from furax import HomothetyOperator
        >>> import jax.numpy as jnp
        >>> nside = 64
        >>> nu_freqs = jnp.array([30., 40., 100.])
        >>> d_data = Stokes.zeros((len(nu_freqs), 12 * nside**2)) # Example observed data
        >>> inv_noise = HomothetyOperator(jnp.ones(1), in_structure=d_data.structure) # Example inverse noise
        >>> params = {'temp_dust': 20.0, 'beta_dust': 1.54, 'beta_pl': -3.0}
        >>> dust_nu0_ref = 150.0
        >>> synchrotron_nu0_ref = 20.0
        >>> ll_val = spectral_log_likelihood(params, nu_freqs, inv_noise, d_data, dust_nu0_ref, synchrotron_nu0_ref)
        >>> # print(ll_val)
    """
    if analytical_gradient:
        return _spectral_log_likelihood_analytical(
            params, nu, N, d, dust_nu0, synchrotron_nu0, patch_indices, op, N_2
        )

    AND, s = _spectral_likelihood_core(
        params, patch_indices, nu, N, d, dust_nu0, synchrotron_nu0, op, N_2
    )
    ll: Scalar = dot(AND, s)
    return ll

furax.obs.atmosphere

Classes:

Functions:

AtmospherePointingOperator dataclass

Bases: PointingOperator

Operator that projects a flat atmosphere map to TOD using quaternion pointing.

Models a "frozen" atmosphere as a 2D intensity pattern on a horizontal plane at height h, drifting with wind velocity (vx, vy). For each detector and time sample it:

  1. Computes the gnomonic projection of the pointing direction onto the plane, giving physical coordinates (x, y).
  2. Adds the wind displacement (vx * t, vy * t) to obtain the atmosphere sample position.
  3. Samples the atmosphere map at that position (nearest-neighbour or bilinear).
  4. Optionally weights the sample by the airmass loading modulation 1 / sin(el) (enabled with elevation_modulation), accounting for the longer line-of-sight path through the layer at low elevation.

The transpose accumulates TOD back into the atmosphere map (binning).

Attributes:

  • landscape (TangentialLandscape) –

    The atmosphere map pixelization.

  • qbore (Float[Array, 'samp 4']) –

    Boresight quaternions in the horizon frame (z-axis = zenith), shape (n_samples, 4).

  • qdet (Float[Array, 'det 4']) –

    Detector offset quaternions, shape (n_detectors, 4).

  • wind_displacement (Float[Array, 'samp 2']) –

    Pre-computed wind offset (vx * t_k, vy * t_k) for each sample, shape (n_samples, 2).

  • batch_size (int) –

    Detector batch size.

  • interpolate (bool) –

    If True, use bilinear interpolation; otherwise nearest-neighbour.

  • elevation_modulation (bool) –

    If True, weight each sample by 1 / sin(el) (airmass).

Methods:

  • from_wind

    Create an AtmospherePointingOperator.

Source code in src/furax/obs/atmosphere/_operator.py
class AtmospherePointingOperator(PointingOperator):
    """Operator that projects a flat atmosphere map to TOD using quaternion pointing.

    Models a "frozen" atmosphere as a 2D intensity pattern on a horizontal plane at
    height ``h``, drifting with wind velocity ``(vx, vy)``. For each detector and time
    sample it:

    1. Computes the gnomonic projection of the pointing direction onto the plane,
       giving physical coordinates ``(x, y)``.
    2. Adds the wind displacement ``(vx * t, vy * t)`` to obtain the atmosphere sample
       position.
    3. Samples the atmosphere map at that position (nearest-neighbour or bilinear).
    4. Optionally weights the sample by the airmass loading modulation ``1 / sin(el)``
       (enabled with ``elevation_modulation``), accounting for the longer line-of-sight
       path through the layer at low elevation.

    The transpose accumulates TOD back into the atmosphere map (binning).

    Attributes:
        landscape: The atmosphere map pixelization.
        qbore: Boresight quaternions in the horizon frame (z-axis = zenith),
            shape ``(n_samples, 4)``.
        qdet: Detector offset quaternions, shape ``(n_detectors, 4)``.
        wind_displacement: Pre-computed wind offset ``(vx * t_k, vy * t_k)`` for each
            sample, shape ``(n_samples, 2)``.
        batch_size: Detector batch size.
        interpolate: If ``True``, use bilinear interpolation; otherwise nearest-neighbour.
        elevation_modulation: If ``True``, weight each sample by ``1 / sin(el)`` (airmass).
    """

    landscape: TangentialLandscape  # narrows PointingOperator.landscape
    wind_displacement: Float[Array, 'samp 2']
    elevation_modulation: bool = field(metadata={'static': True})

    @classmethod
    def from_wind(
        cls,
        landscape: TangentialLandscape,
        boresight_quaternions: Float[Array, 'samp 4'],
        detector_quaternions: Float[Array, 'det 4'],
        wind_velocity: Float[Array, '2'],
        times: Float[Array, ' samp'],
        *,
        batch_size: int = 16,
        interpolate: bool = True,
        elevation_modulation: bool = False,
    ) -> 'AtmospherePointingOperator':
        """Create an AtmospherePointingOperator.

        Args:
            landscape: The atmosphere map pixelization (height stored here).
            boresight_quaternions: Boresight pointing in the horizon frame,
                shape ``(n_samples, 4)``.
            detector_quaternions: Detector offset quaternions, shape ``(n_detectors, 4)``.
            wind_velocity: Wind velocity ``(vx, vy)`` in the same physical units as
                ``landscape.height`` per second.
            times: Elapsed time for each sample, shape ``(n_samples,)``.
            batch_size: Detector batch size.
            interpolate: Use bilinear interpolation (default: nearest-neighbour).
            elevation_modulation: Weight each sample by the airmass loading ``1 / sin(el)``.
        """
        ndet = detector_quaternions.shape[0]
        nsamp = boresight_quaternions.shape[0]
        wind_displacement = times[:, None] * wind_velocity[None, :]  # (samp, 2)
        out_structure = StokesI.structure_for((ndet, nsamp), dtype=landscape.dtype)
        return cls(
            landscape,
            qbore=boresight_quaternions,
            qdet=detector_quaternions,
            batch_size=batch_size,
            interpolate=interpolate,
            _out_structure=out_structure,
            wind_displacement=wind_displacement,
            elevation_modulation=elevation_modulation,
            in_structure=landscape.structure,
        )

    def _wind_xy(
        self, qdet_full: Float[Array, '*dims 4']
    ) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
        """Gnomonic projection onto the atmosphere screen, including wind displacement."""
        x, y = self.landscape.quat2xy(qdet_full)
        return (
            x + self.wind_displacement[:, 0],
            y + self.wind_displacement[:, 1],
        )

    def _modulate(self, tod: StokesType, qdet_full: Float[Array, '*dims 4']) -> StokesType:
        """Weight each sample by the airmass loading ``1 / sin(el)`` when enabled."""
        if not self.elevation_modulation:
            return tod
        sin_el = qrot_zaxis(qdet_full)[..., 2]  # (det, samp)
        return tree.truediv(tod, sin_el)  # type: ignore[no-any-return]

    def _quat2index(self, qdet_full: Float[Array, '*dims 4']) -> Array:
        x, y = self._wind_xy(qdet_full)
        return self.landscape.pixel2index(*self.landscape.xy2pixel(x, y))

    def _quat2interp(self, qdet_full: Float[Array, '*dims 4']) -> tuple[Array, Array]:
        return self.landscape.xy2interp(*self._wind_xy(qdet_full))

landscape instance-attribute

wind_displacement instance-attribute

elevation_modulation = field(metadata={'static': True}) class-attribute instance-attribute

from_wind(landscape, boresight_quaternions, detector_quaternions, wind_velocity, times, *, batch_size=16, interpolate=True, elevation_modulation=False) classmethod

Create an AtmospherePointingOperator.

Parameters:

  • landscape (TangentialLandscape) –

    The atmosphere map pixelization (height stored here).

  • boresight_quaternions (Float[Array, 'samp 4']) –

    Boresight pointing in the horizon frame, shape (n_samples, 4).

  • detector_quaternions (Float[Array, 'det 4']) –

    Detector offset quaternions, shape (n_detectors, 4).

  • wind_velocity (Float[Array, 2]) –

    Wind velocity (vx, vy) in the same physical units as landscape.height per second.

  • times (Float[Array, ' samp']) –

    Elapsed time for each sample, shape (n_samples,).

  • batch_size (int, default: 16 ) –

    Detector batch size.

  • interpolate (bool, default: True ) –

    Use bilinear interpolation (default: nearest-neighbour).

  • elevation_modulation (bool, default: False ) –

    Weight each sample by the airmass loading 1 / sin(el).

Source code in src/furax/obs/atmosphere/_operator.py
@classmethod
def from_wind(
    cls,
    landscape: TangentialLandscape,
    boresight_quaternions: Float[Array, 'samp 4'],
    detector_quaternions: Float[Array, 'det 4'],
    wind_velocity: Float[Array, '2'],
    times: Float[Array, ' samp'],
    *,
    batch_size: int = 16,
    interpolate: bool = True,
    elevation_modulation: bool = False,
) -> 'AtmospherePointingOperator':
    """Create an AtmospherePointingOperator.

    Args:
        landscape: The atmosphere map pixelization (height stored here).
        boresight_quaternions: Boresight pointing in the horizon frame,
            shape ``(n_samples, 4)``.
        detector_quaternions: Detector offset quaternions, shape ``(n_detectors, 4)``.
        wind_velocity: Wind velocity ``(vx, vy)`` in the same physical units as
            ``landscape.height`` per second.
        times: Elapsed time for each sample, shape ``(n_samples,)``.
        batch_size: Detector batch size.
        interpolate: Use bilinear interpolation (default: nearest-neighbour).
        elevation_modulation: Weight each sample by the airmass loading ``1 / sin(el)``.
    """
    ndet = detector_quaternions.shape[0]
    nsamp = boresight_quaternions.shape[0]
    wind_displacement = times[:, None] * wind_velocity[None, :]  # (samp, 2)
    out_structure = StokesI.structure_for((ndet, nsamp), dtype=landscape.dtype)
    return cls(
        landscape,
        qbore=boresight_quaternions,
        qdet=detector_quaternions,
        batch_size=batch_size,
        interpolate=interpolate,
        _out_structure=out_structure,
        wind_displacement=wind_displacement,
        elevation_modulation=elevation_modulation,
        in_structure=landscape.structure,
    )

profile_neg_log_likelihood(pointing_op, d, noise_cov_inv, *, solver=None)

Negative profile log-likelihood for atmosphere pointing parameters.

Assumes a data model d = P(v) m + n where P(v) is the atmosphere pointing operator parameterised by wind velocity v (or any other pointing parameters).

Computes the "spectral" (profile) negative log-likelihood given (up to a constant) by -2 log L(v) = (P.T N.I d).T (P.T N.I P).I (P.T N.I d) for use with a minimiser.

Examples:

>>> import lineax as lx
>>> def loss(wind_velocity):
...     P = AtmospherePointingOperator.from_wind(
...         landscape, qbore, qdet, wind_velocity, times, interpolate=True
...     )
...     return profile_neg_log_likelihood(P, d_obs, N_inv)
>>> grad = jax.grad(loss)(wind_velocity_init)

Parameters:

  • pointing_op (AbstractLinearOperator) –

    Atmosphere pointing operator P(v) for current parameters.

  • d (StokesI) –

    Observed TOD.

  • noise_cov_inv (AbstractLinearOperator) –

    Inverse noise covariance operator.

  • solver (AbstractLinearSolver | None, default: None ) –

    Linear solver for the map-making step.

Returns:

  • Scalar

    Scalar negative profile log-likelihood (suitable for minimisation).

Source code in src/furax/obs/atmosphere/_likelihood.py
def profile_neg_log_likelihood(
    pointing_op: AbstractLinearOperator,
    d: StokesI,
    noise_cov_inv: AbstractLinearOperator,
    *,
    solver: lx.AbstractLinearSolver | None = None,
) -> Scalar:
    r"""Negative profile log-likelihood for atmosphere pointing parameters.

    Assumes a data model `d = P(v) m + n` where P(v) is the atmosphere pointing
    operator parameterised by wind velocity `v` (or any other pointing parameters).

    Computes the "spectral" (profile) negative log-likelihood given (up to a constant) by
    `-2 log L(v) = (P.T N.I d).T (P.T N.I P).I (P.T N.I d)` for use with a minimiser.

    Examples:
        >>> import lineax as lx
        >>> def loss(wind_velocity):
        ...     P = AtmospherePointingOperator.from_wind(
        ...         landscape, qbore, qdet, wind_velocity, times, interpolate=True
        ...     )
        ...     return profile_neg_log_likelihood(P, d_obs, N_inv)
        >>> grad = jax.grad(loss)(wind_velocity_init)

    Args:
        pointing_op: Atmosphere pointing operator `P(v)` for current parameters.
        d: Observed TOD.
        noise_cov_inv: Inverse noise covariance operator.
        solver: Linear solver for the map-making step.

    Returns:
        Scalar negative profile log-likelihood (suitable for minimisation).
    """
    x = (pointing_op.T @ noise_cov_inv)(d)
    ones_tod = ones_like(d)
    h = jax.lax.stop_gradient(pointing_op.T(ones_tod).i)
    h_safe = jnp.where(h > 0, h, 1.0)
    precond = DiagonalOperator(1.0 / h_safe, in_structure=x.structure)
    PtNiP = pointing_op.T @ noise_cov_inv @ pointing_op
    return -dot(x, PtNiP.I(solver=solver, preconditioner=precond)(x))

simulate_kolmogorov_screen(landscape, key, *, power=-11 / 3, amplitude=1.0)

Generate a 2D Gaussian random field with an isotropic power-law spectrum.

Models a frozen turbulent atmosphere screen using a Kolmogorov-like power spectrum \(P(k) \propto k^{\text{power}}\). The default exponent power = -11/3 corresponds to the two-dimensional projection of a 3D Kolmogorov turbulence field (outer-scale-free regime).

The field is generated in Fourier space: draw complex Gaussian white noise, multiply by \(\sqrt{A}\,k^{\text{power}/2}\), then transform back with an inverse FFT. The DC component is excluded (set to zero) to avoid the singularity at \(k=0\).

Parameters:

  • landscape (TangentialLandscape) –

    Defines the map shape and dtype. Only landscape.shape and landscape.dtype are used; height and pixel spacing do not affect the generated field.

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

    JAX random key.

  • power (float, default: -11 / 3 ) –

    Exponent of the power spectrum \(P(k) \propto k^p\). Default -11/3 (Kolmogorov).

  • amplitude (float, default: 1.0 ) –

    Overall amplitude prefactor \(A\). Scales the variance of the output field.

Returns:

  • StokesI

    Stokes I map matching the shape and dtype of landscape.

Source code in src/furax/obs/atmosphere/_simulation.py
def simulate_kolmogorov_screen(
    landscape: TangentialLandscape,
    key: Key[Array, ''],
    *,
    power: float = -11 / 3,
    amplitude: float = 1.0,
) -> StokesI:
    r"""Generate a 2D Gaussian random field with an isotropic power-law spectrum.

    Models a frozen turbulent atmosphere screen using a Kolmogorov-like power
    spectrum $P(k) \propto k^{\text{power}}$. The default exponent
    ``power = -11/3`` corresponds to the two-dimensional projection of a 3D
    Kolmogorov turbulence field (outer-scale-free regime).

    The field is generated in Fourier space: draw complex Gaussian white noise,
    multiply by $\sqrt{A}\,k^{\text{power}/2}$, then transform back with
    an inverse FFT.  The DC component is excluded (set to zero) to avoid the
    singularity at $k=0$.

    Args:
        landscape: Defines the map shape and dtype.  Only ``landscape.shape``
            and ``landscape.dtype`` are used; height and pixel spacing do not
            affect the generated field.
        key: JAX random key.
        power: Exponent of the power spectrum $P(k) \propto k^p$.
            Default ``-11/3`` (Kolmogorov).
        amplitude: Overall amplitude prefactor $A$.  Scales the variance
            of the output field.

    Returns:
        Stokes I map matching the shape and dtype of ``landscape``.
    """
    ny, nx = landscape.shape
    white_k = jnp.fft.fft2(jax.random.normal(key, (ny, nx), dtype=landscape.dtype))
    kx = jnp.fft.fftfreq(nx) * nx
    ky = jnp.fft.fftfreq(ny) * ny
    kgrid_x, kgrid_y = jnp.meshgrid(kx, ky)
    k_norm = jnp.sqrt(kgrid_x**2 + kgrid_y**2).at[0, 0].set(jnp.inf)
    field_k = white_k * jnp.sqrt(amplitude * k_norm**power)
    field = jnp.real(jnp.fft.ifft2(field_k)).astype(landscape.dtype)
    return StokesI(i=field)

furax.obs.landscapes

Classes:

Landscape

Bases: ABC

Methods:

Attributes:

Source code in src/furax/obs/landscapes.py
@register_static
class Landscape(ABC):
    def __init__(self, shape: tuple[int, ...], dtype: DTypeLike = np.float64):
        self.shape = shape
        self.dtype = dtype

    def __len__(self) -> int:
        return math.prod(self.shape)

    @property
    def size(self) -> int:
        return len(self)

    @abstractmethod
    def normal(self, key: Key[Array, '']) -> PyTree[Shaped[Array, '...']]: ...

    @abstractmethod
    def uniform(
        self, key: Key[Array, ''], minval: float = 0.0, maxval: float = 1.0
    ) -> PyTree[Shaped[Array, '...']]: ...

    @abstractmethod
    def full(self, fill_value: ScalarLike) -> PyTree[Shaped[Array, '...']]: ...

    def zeros(self) -> PyTree[Shaped[Array, '...']]:
        return self.full(0)

    def ones(self) -> PyTree[Shaped[Array, '...']]:
        return self.full(1)

shape = shape instance-attribute

dtype = dtype instance-attribute

size property

__init__(shape, dtype=np.float64)

Source code in src/furax/obs/landscapes.py
def __init__(self, shape: tuple[int, ...], dtype: DTypeLike = np.float64):
    self.shape = shape
    self.dtype = dtype

__len__()

Source code in src/furax/obs/landscapes.py
def __len__(self) -> int:
    return math.prod(self.shape)

normal(key) abstractmethod

Source code in src/furax/obs/landscapes.py
@abstractmethod
def normal(self, key: Key[Array, '']) -> PyTree[Shaped[Array, '...']]: ...

uniform(key, minval=0.0, maxval=1.0) abstractmethod

Source code in src/furax/obs/landscapes.py
@abstractmethod
def uniform(
    self, key: Key[Array, ''], minval: float = 0.0, maxval: float = 1.0
) -> PyTree[Shaped[Array, '...']]: ...

full(fill_value) abstractmethod

Source code in src/furax/obs/landscapes.py
@abstractmethod
def full(self, fill_value: ScalarLike) -> PyTree[Shaped[Array, '...']]: ...

zeros()

Source code in src/furax/obs/landscapes.py
def zeros(self) -> PyTree[Shaped[Array, '...']]:
    return self.full(0)

ones()

Source code in src/furax/obs/landscapes.py
def ones(self) -> PyTree[Shaped[Array, '...']]:
    return self.full(1)

StokesLandscape

Bases: Landscape

Class representing a multidimensional map of Stokes vectors.

We assume that integer pixel values fall at the center of pixels (as in the FITS WCS standard, see Section 2.1.4 of Greisen et al., 2002, A&A 446, 747).

Attributes:

  • shape

    The shape of the array that stores the map values. The dimensions are in the reverse order of the FITS NAXIS* keywords. For a 2-dimensional map, the shape corresponds to (NAXIS2, NAXIS1) or (\(n_\mathrm{row}\), \(n_\mathrm{col}\)), i.e. (\(n_y\), \(n_x\)).

  • pixel_shape

    The shape in reversed order. For a 2-dimensional map, the shape corresponds to (NAXIS1, NAXIS2) or (\(n_\mathrm{col}\), \(n_\mathrm{row}\)), i.e. (\(n_x\), \(n_y\)).

  • stokes

    The identifier for the Stokes vectors (I, QU, IQU or IQUV)

  • dtype

    The data type for the values of the landscape.

Methods:

Source code in src/furax/obs/landscapes.py
@register_static
class StokesLandscape(Landscape):
    r"""Class representing a multidimensional map of Stokes vectors.

    We assume that integer pixel values fall at the center of pixels (as in the FITS WCS standard,
    see Section 2.1.4 of Greisen et al., 2002, A&A 446, 747).

    Attributes:
        shape: The shape of the array that stores the map values. The dimensions are in the reverse
            order of the FITS NAXIS* keywords. For a 2-dimensional map, the shape corresponds to
            (NAXIS2, NAXIS1) or ($n_\mathrm{row}$, $n_\mathrm{col}$), i.e. ($n_y$, $n_x$).
        pixel_shape: The shape in reversed order. For a 2-dimensional map, the shape corresponds to
            (NAXIS1, NAXIS2) or ($n_\mathrm{col}$, $n_\mathrm{row}$), i.e. ($n_x$, $n_y$).
        stokes: The identifier for the Stokes vectors (`I`, `QU`, `IQU` or `IQUV`)
        dtype: The data type for the values of the landscape.
    """

    def __init__(
        self,
        shape: tuple[int, ...] | None = None,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
        pixel_shape: tuple[int, ...] | None = None,
    ):
        if shape is None and pixel_shape is None:
            raise TypeError('The shape is not specified.')
        if shape is not None and pixel_shape is not None:
            raise TypeError('Either the shape or pixel_shape should be specified.')
        shape = shape if pixel_shape is None else pixel_shape[::-1]
        assert shape is not None  # mypy assert
        super().__init__(shape, dtype)
        self.stokes = stokes
        self.pixel_shape = shape[::-1]

    @property
    def size(self) -> int:
        return len(self.stokes) * len(self)

    @property
    def structure(self) -> PyTree[jax.ShapeDtypeStruct]:
        cls = Stokes.class_for(self.stokes)
        return cls.structure_for(self.shape, self.dtype)

    def full(self, fill_value: ScalarLike) -> PyTree[Shaped[Array, ' {self.npixel}']]:
        cls = Stokes.class_for(self.stokes)
        return cls.full(self.shape, fill_value, self.dtype)

    def normal(self, key: Key[Array, '']) -> PyTree[Shaped[Array, ' {self.npixel}']]:
        cls = Stokes.class_for(self.stokes)
        return cls.normal(key, self.shape, self.dtype)

    def uniform(
        self, key: Key[Array, ''], minval: float = 0.0, maxval: float = 1.0
    ) -> PyTree[Shaped[Array, ' {self.npixel}']]:
        cls = Stokes.class_for(self.stokes)
        return cls.uniform(self.shape, key, self.dtype, minval, maxval)

    def get_coverage(self, arg: Sampling) -> Integer[Array, ' {self.npixel}']:
        indices = self.world2index(arg.theta, arg.phi)
        unique_indices, counts = jnp.unique(indices, return_counts=True)
        coverage = jnp.zeros(len(self), dtype=np.int64)
        coverage = coverage.at[unique_indices].add(
            counts, indices_are_sorted=True, unique_indices=True
        )
        return coverage.reshape(self.shape)

    def world2index(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> Integer[Array, ' *dims']:
        pixels = self.world2pixel(theta, phi)
        return self.pixel2index(*pixels)

    @abstractmethod
    def world2pixel(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Float[Array, ' *dims'], ...]:
        r"""Converts angles from WCS to pixel coordinates.

        Args:
            theta (float): Spherical $\theta$ angle.
            phi (float): Spherical $\phi$ angle.

        Returns:
            tuple[float, ...]: x, y, z, ... pixel coordinates
        """

    def pixel2index(self, *coords: Float[Array, ' *dims']) -> Integer[Array, ' *ndims']:
        r"""Converts multidimensional pixel coordinates into 1-dimensional indices.

        For a map of shape $(n_y, n_x)$, the indices of the pixels are walked from the
        rightmost dimension $n_x$ to the leftmost dimension $n_y$ (row-major layout).
        In such a map, the pixel with float coordinates $(p_x, p_y)$ has an index
        $i = round(p_x) + n_x round(p_y)$.

        The indices travel from bottom to top, like the Y-coordinates.

        Integer values of the pixel coordinates correspond to the pixel centers. The points
        $(p_x, p_y)$ strictly inside a pixel centered on the integer coordinates
        $(i_x, i_y)$ verify

        - $i_x - \frac{1}{2} < p_x < i_x + \frac{1}{2}$
        - $i_y - \frac{1}{2} < p_y < i_y + \frac{1}{2}$

        The convention for pixels and indices is that the first one starts at zero.

        Arguments:
            *coords: The floating-point pixel coordinates along the X, Y, Z, ... axes.

        Returns:
            The 1-dimensional integer indices associated to the pixel coordinates. The data type is
            int32, unless the landscape largest index would overflow, in which case it is int64.
        """
        dtype: DTypeLike
        if len(self) - 1 <= np.iinfo(np.iinfo(np.int32)).max:
            dtype = np.int32
        else:
            dtype = np.int64
        if len(coords) == 0:
            raise TypeError('Pixel coordinates are not specified.')

        stride = self.pixel_shape[0]
        indices = jnp.round(coords[0]).astype(dtype)
        valid = (0 <= indices) & (indices < self.pixel_shape[0])
        for coord, dim in zip(coords[1:], self.pixel_shape[1:]):
            indices_axis = jnp.round(coord).astype(dtype)
            valid &= (0 <= indices_axis) & (indices_axis < dim)
            indices += indices_axis * stride
            stride *= dim
        return jnp.where(valid, indices, -1)

    def quat2pixel(self, quat: Float[Array, '*dims 4']) -> tuple[Float[Array, ' *dims'], ...]:
        """Converts quaternion to floating-point pixel coordinates."""
        theta, phi, _ = to_iso_angles(quat)  # psi not needed
        return self.world2pixel(theta, phi)

    def quat2index(self, quat: Float[Array, '*dims 4']) -> Integer[Array, ' *dims']:
        """Converts quaternion to 1-dimensional pixel indices."""
        return self.pixel2index(*self.quat2pixel(quat))

    def world2interp(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Integer[Array, '...'], Float[Array, '...']]:
        """Returns (indices, weights) with a trailing neighbor dimension for interpolation.

        Subclasses must override this to support interpolated pointing.
        """
        raise NotImplementedError(f'{type(self).__name__} does not support interpolation')

    def quat2interp(
        self, quat: Float[Array, '*dims 4']
    ) -> tuple[Integer[Array, '...'], Float[Array, '...']]:
        """Converts quaternion to (indices, weights) for interpolation."""
        theta, phi, _ = to_iso_angles(quat)
        return self.world2interp(theta, phi)

stokes = stokes instance-attribute

pixel_shape = shape[::(-1)] instance-attribute

size property

structure property

__init__(shape=None, stokes='IQU', dtype=np.float64, pixel_shape=None)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    shape: tuple[int, ...] | None = None,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
    pixel_shape: tuple[int, ...] | None = None,
):
    if shape is None and pixel_shape is None:
        raise TypeError('The shape is not specified.')
    if shape is not None and pixel_shape is not None:
        raise TypeError('Either the shape or pixel_shape should be specified.')
    shape = shape if pixel_shape is None else pixel_shape[::-1]
    assert shape is not None  # mypy assert
    super().__init__(shape, dtype)
    self.stokes = stokes
    self.pixel_shape = shape[::-1]

full(fill_value)

Source code in src/furax/obs/landscapes.py
def full(self, fill_value: ScalarLike) -> PyTree[Shaped[Array, ' {self.npixel}']]:
    cls = Stokes.class_for(self.stokes)
    return cls.full(self.shape, fill_value, self.dtype)

normal(key)

Source code in src/furax/obs/landscapes.py
def normal(self, key: Key[Array, '']) -> PyTree[Shaped[Array, ' {self.npixel}']]:
    cls = Stokes.class_for(self.stokes)
    return cls.normal(key, self.shape, self.dtype)

uniform(key, minval=0.0, maxval=1.0)

Source code in src/furax/obs/landscapes.py
def uniform(
    self, key: Key[Array, ''], minval: float = 0.0, maxval: float = 1.0
) -> PyTree[Shaped[Array, ' {self.npixel}']]:
    cls = Stokes.class_for(self.stokes)
    return cls.uniform(self.shape, key, self.dtype, minval, maxval)

get_coverage(arg)

Source code in src/furax/obs/landscapes.py
def get_coverage(self, arg: Sampling) -> Integer[Array, ' {self.npixel}']:
    indices = self.world2index(arg.theta, arg.phi)
    unique_indices, counts = jnp.unique(indices, return_counts=True)
    coverage = jnp.zeros(len(self), dtype=np.int64)
    coverage = coverage.at[unique_indices].add(
        counts, indices_are_sorted=True, unique_indices=True
    )
    return coverage.reshape(self.shape)

world2index(theta, phi)

Source code in src/furax/obs/landscapes.py
def world2index(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> Integer[Array, ' *dims']:
    pixels = self.world2pixel(theta, phi)
    return self.pixel2index(*pixels)

world2pixel(theta, phi) abstractmethod

Converts angles from WCS to pixel coordinates.

Parameters:

  • theta (float) –

    Spherical \(\theta\) angle.

  • phi (float) –

    Spherical \(\phi\) angle.

Returns:

  • tuple[Float[Array, ' *dims'], ...]

    tuple[float, ...]: x, y, z, ... pixel coordinates

Source code in src/furax/obs/landscapes.py
@abstractmethod
def world2pixel(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Float[Array, ' *dims'], ...]:
    r"""Converts angles from WCS to pixel coordinates.

    Args:
        theta (float): Spherical $\theta$ angle.
        phi (float): Spherical $\phi$ angle.

    Returns:
        tuple[float, ...]: x, y, z, ... pixel coordinates
    """

pixel2index(*coords)

Converts multidimensional pixel coordinates into 1-dimensional indices.

For a map of shape \((n_y, n_x)\), the indices of the pixels are walked from the rightmost dimension \(n_x\) to the leftmost dimension \(n_y\) (row-major layout). In such a map, the pixel with float coordinates \((p_x, p_y)\) has an index \(i = round(p_x) + n_x round(p_y)\).

The indices travel from bottom to top, like the Y-coordinates.

Integer values of the pixel coordinates correspond to the pixel centers. The points \((p_x, p_y)\) strictly inside a pixel centered on the integer coordinates \((i_x, i_y)\) verify

  • \(i_x - \frac{1}{2} < p_x < i_x + \frac{1}{2}\)
  • \(i_y - \frac{1}{2} < p_y < i_y + \frac{1}{2}\)

The convention for pixels and indices is that the first one starts at zero.

Parameters:

  • *coords (Float[Array, ' *dims'], default: () ) –

    The floating-point pixel coordinates along the X, Y, Z, ... axes.

Returns:

  • Integer[Array, ' *ndims']

    The 1-dimensional integer indices associated to the pixel coordinates. The data type is

  • Integer[Array, ' *ndims']

    int32, unless the landscape largest index would overflow, in which case it is int64.

Source code in src/furax/obs/landscapes.py
def pixel2index(self, *coords: Float[Array, ' *dims']) -> Integer[Array, ' *ndims']:
    r"""Converts multidimensional pixel coordinates into 1-dimensional indices.

    For a map of shape $(n_y, n_x)$, the indices of the pixels are walked from the
    rightmost dimension $n_x$ to the leftmost dimension $n_y$ (row-major layout).
    In such a map, the pixel with float coordinates $(p_x, p_y)$ has an index
    $i = round(p_x) + n_x round(p_y)$.

    The indices travel from bottom to top, like the Y-coordinates.

    Integer values of the pixel coordinates correspond to the pixel centers. The points
    $(p_x, p_y)$ strictly inside a pixel centered on the integer coordinates
    $(i_x, i_y)$ verify

    - $i_x - \frac{1}{2} < p_x < i_x + \frac{1}{2}$
    - $i_y - \frac{1}{2} < p_y < i_y + \frac{1}{2}$

    The convention for pixels and indices is that the first one starts at zero.

    Arguments:
        *coords: The floating-point pixel coordinates along the X, Y, Z, ... axes.

    Returns:
        The 1-dimensional integer indices associated to the pixel coordinates. The data type is
        int32, unless the landscape largest index would overflow, in which case it is int64.
    """
    dtype: DTypeLike
    if len(self) - 1 <= np.iinfo(np.iinfo(np.int32)).max:
        dtype = np.int32
    else:
        dtype = np.int64
    if len(coords) == 0:
        raise TypeError('Pixel coordinates are not specified.')

    stride = self.pixel_shape[0]
    indices = jnp.round(coords[0]).astype(dtype)
    valid = (0 <= indices) & (indices < self.pixel_shape[0])
    for coord, dim in zip(coords[1:], self.pixel_shape[1:]):
        indices_axis = jnp.round(coord).astype(dtype)
        valid &= (0 <= indices_axis) & (indices_axis < dim)
        indices += indices_axis * stride
        stride *= dim
    return jnp.where(valid, indices, -1)

quat2pixel(quat)

Converts quaternion to floating-point pixel coordinates.

Source code in src/furax/obs/landscapes.py
def quat2pixel(self, quat: Float[Array, '*dims 4']) -> tuple[Float[Array, ' *dims'], ...]:
    """Converts quaternion to floating-point pixel coordinates."""
    theta, phi, _ = to_iso_angles(quat)  # psi not needed
    return self.world2pixel(theta, phi)

quat2index(quat)

Converts quaternion to 1-dimensional pixel indices.

Source code in src/furax/obs/landscapes.py
def quat2index(self, quat: Float[Array, '*dims 4']) -> Integer[Array, ' *dims']:
    """Converts quaternion to 1-dimensional pixel indices."""
    return self.pixel2index(*self.quat2pixel(quat))

world2interp(theta, phi)

Returns (indices, weights) with a trailing neighbor dimension for interpolation.

Subclasses must override this to support interpolated pointing.

Source code in src/furax/obs/landscapes.py
def world2interp(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Integer[Array, '...'], Float[Array, '...']]:
    """Returns (indices, weights) with a trailing neighbor dimension for interpolation.

    Subclasses must override this to support interpolated pointing.
    """
    raise NotImplementedError(f'{type(self).__name__} does not support interpolation')

quat2interp(quat)

Converts quaternion to (indices, weights) for interpolation.

Source code in src/furax/obs/landscapes.py
def quat2interp(
    self, quat: Float[Array, '*dims 4']
) -> tuple[Integer[Array, '...'], Float[Array, '...']]:
    """Converts quaternion to (indices, weights) for interpolation."""
    theta, phi, _ = to_iso_angles(quat)
    return self.world2interp(theta, phi)

ProjectionType

Bases: IntEnum

Supported WCS projection types.

Attributes:

Source code in src/furax/obs/landscapes.py
class ProjectionType(IntEnum):
    """Supported WCS projection types."""

    CAR = 0

CAR = 0 class-attribute instance-attribute

WCSProjection dataclass

Class that holds basic WCS projection parameters.

Methods:

Attributes:

Source code in src/furax/obs/landscapes.py
@register_static
@dataclass
class WCSProjection:
    """Class that holds basic WCS projection parameters."""

    crpix: tuple[float, float]
    """Reference pixel ``(crpix_x, crpix_y)`` in 1-indexed FITS convention."""
    crval: tuple[float, float]
    """Reference coordinate ``(lon_deg, lat_deg)`` in degrees."""
    cdelt: tuple[float, float]
    """Pixel scale ``(cdelt_x_deg, cdelt_y_deg)`` in degrees per pixel."""
    type: ProjectionType = ProjectionType.CAR
    """Projection type (only CAR is supported for now)."""

    def __post_init__(self) -> None:
        if self.cdelt[0] > 0:
            warnings.warn(
                f'cdelt_ra = {self.cdelt[0]} is positive; FITS convention requires cdelt_ra < 0'
                ' (RA decreases left-to-right).',
                UserWarning,
                stacklevel=2,
            )

    @classmethod
    def from_astropy(cls, wcs: WCS) -> 'WCSProjection':
        """Extract a WCSProjection from an astropy WCS object."""
        projection = ProjectionType[wcs.wcs.ctype[0].split('-')[-1]]
        return cls(
            crpix=(float(wcs.wcs.crpix[0]), float(wcs.wcs.crpix[1])),
            crval=(float(wcs.wcs.crval[0]), float(wcs.wcs.crval[1])),
            cdelt=(float(wcs.wcs.cdelt[0]), float(wcs.wcs.cdelt[1])),
            type=projection,
        )

crpix instance-attribute

Reference pixel (crpix_x, crpix_y) in 1-indexed FITS convention.

crval instance-attribute

Reference coordinate (lon_deg, lat_deg) in degrees.

cdelt instance-attribute

Pixel scale (cdelt_x_deg, cdelt_y_deg) in degrees per pixel.

type = ProjectionType.CAR class-attribute instance-attribute

Projection type (only CAR is supported for now).

__init__(crpix, crval, cdelt, type=ProjectionType.CAR)

__post_init__()

Source code in src/furax/obs/landscapes.py
def __post_init__(self) -> None:
    if self.cdelt[0] > 0:
        warnings.warn(
            f'cdelt_ra = {self.cdelt[0]} is positive; FITS convention requires cdelt_ra < 0'
            ' (RA decreases left-to-right).',
            UserWarning,
            stacklevel=2,
        )

from_astropy(wcs) classmethod

Extract a WCSProjection from an astropy WCS object.

Source code in src/furax/obs/landscapes.py
@classmethod
def from_astropy(cls, wcs: WCS) -> 'WCSProjection':
    """Extract a WCSProjection from an astropy WCS object."""
    projection = ProjectionType[wcs.wcs.ctype[0].split('-')[-1]]
    return cls(
        crpix=(float(wcs.wcs.crpix[0]), float(wcs.wcs.crpix[1])),
        crval=(float(wcs.wcs.crval[0]), float(wcs.wcs.crval[1])),
        cdelt=(float(wcs.wcs.cdelt[0]), float(wcs.wcs.cdelt[1])),
        type=projection,
    )

WCSLandscape

Bases: StokesLandscape

Base class for WCS-projected Stokes landscapes.

Methods:

  • __init__
  • to_wcs

    Reconstruct an astropy WCS object from the stored projection parameters.

  • class_for

    Return the WCSLandscape subclass for the given projection type.

  • from_wcs

    Create a WCSLandscape subclass instance from an astropy WCS object.

Attributes:

Source code in src/furax/obs/landscapes.py
@register_static
class WCSLandscape(StokesLandscape):
    """Base class for WCS-projected Stokes landscapes."""

    def __init__(
        self,
        shape: tuple[int, int],
        projection: WCSProjection,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
    ) -> None:
        super().__init__(shape, stokes, dtype)
        self.projection = projection

    @property
    def crpix(self) -> tuple[float, float]:
        return self.projection.crpix

    @property
    def crval(self) -> tuple[float, float]:
        return self.projection.crval

    @property
    def cdelt(self) -> tuple[float, float]:
        return self.projection.cdelt

    def to_wcs(self) -> WCS:
        """Reconstruct an astropy WCS object from the stored projection parameters."""
        proj = self.projection.type.name
        wcs = WCS(naxis=2)
        wcs.wcs.ctype = [f'RA---{proj}', f'DEC--{proj}']
        wcs.wcs.crpix = list(self.crpix)
        wcs.wcs.crval = list(self.crval)
        wcs.wcs.cdelt = list(self.cdelt)
        wcs.wcs.set()
        return wcs

    @classmethod
    def class_for(cls, projection_type: ProjectionType) -> type['WCSLandscape']:
        """Return the WCSLandscape subclass for the given projection type."""
        if projection_type == ProjectionType.CAR:
            return CARLandscape
        raise ValueError(f'Unsupported projection type: {projection_type!r}')

    @classmethod
    def from_wcs(
        cls,
        shape: tuple[int, int],
        wcs: WCS,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
    ) -> 'WCSLandscape':
        """Create a WCSLandscape subclass instance from an astropy WCS object.

        Dispatches to the appropriate subclass based on the projection type.
        """
        projection = WCSProjection.from_astropy(wcs)
        return cls.class_for(projection.type)(shape, projection, stokes, dtype)

projection = projection instance-attribute

crpix property

crval property

cdelt property

__init__(shape, projection, stokes='IQU', dtype=np.float64)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    shape: tuple[int, int],
    projection: WCSProjection,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
) -> None:
    super().__init__(shape, stokes, dtype)
    self.projection = projection

to_wcs()

Reconstruct an astropy WCS object from the stored projection parameters.

Source code in src/furax/obs/landscapes.py
def to_wcs(self) -> WCS:
    """Reconstruct an astropy WCS object from the stored projection parameters."""
    proj = self.projection.type.name
    wcs = WCS(naxis=2)
    wcs.wcs.ctype = [f'RA---{proj}', f'DEC--{proj}']
    wcs.wcs.crpix = list(self.crpix)
    wcs.wcs.crval = list(self.crval)
    wcs.wcs.cdelt = list(self.cdelt)
    wcs.wcs.set()
    return wcs

class_for(projection_type) classmethod

Return the WCSLandscape subclass for the given projection type.

Source code in src/furax/obs/landscapes.py
@classmethod
def class_for(cls, projection_type: ProjectionType) -> type['WCSLandscape']:
    """Return the WCSLandscape subclass for the given projection type."""
    if projection_type == ProjectionType.CAR:
        return CARLandscape
    raise ValueError(f'Unsupported projection type: {projection_type!r}')

from_wcs(shape, wcs, stokes='IQU', dtype=np.float64) classmethod

Create a WCSLandscape subclass instance from an astropy WCS object.

Dispatches to the appropriate subclass based on the projection type.

Source code in src/furax/obs/landscapes.py
@classmethod
def from_wcs(
    cls,
    shape: tuple[int, int],
    wcs: WCS,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
) -> 'WCSLandscape':
    """Create a WCSLandscape subclass instance from an astropy WCS object.

    Dispatches to the appropriate subclass based on the projection type.
    """
    projection = WCSProjection.from_astropy(wcs)
    return cls.class_for(projection.type)(shape, projection, stokes, dtype)

CARLandscape

Bases: WCSLandscape

Class representing a CAR (Plate Carrée) projected map of Stokes vectors.

CAR maps native coordinates linearly to the projection plane (x = φ, y = θ). Requiring phi_0 = 0 aligns the native and celestial equators, reducing the celestial-to-native rotation to a pure RA shift and making the full world-to-pixel mapping linear in (RA, Dec):

\[ p_x = \frac{\Delta\lambda}{\delta_x} + (c_x - 1) \\ p_y = \frac{\phi}{\delta_y} + (c_y - 1) \]

where \(\Delta\lambda = ((\lambda - \lambda_0 + 180) \bmod 360) - 180\) handles the 0°/360° RA wrap, \((\lambda_0, 0.0)\) is crval, \((\delta_x, \delta_y)\) is cdelt in degrees, and \((c_x, c_y)\) is crpix (1-indexed FITS convention).

Methods:

  • __init__
  • world2pixel

    Convert spherical angles to CAR pixel coordinates in pure JAX.

  • world2interp

    Returns (indices, weights) for bilinear interpolation (n=4).

Source code in src/furax/obs/landscapes.py
@register_static
class CARLandscape(WCSLandscape):
    r"""Class representing a CAR (Plate Carrée) projected map of Stokes vectors.

    CAR maps native coordinates linearly to the projection plane (``x = φ, y = θ``).
    Requiring ``phi_0 = 0`` aligns the native and celestial equators, reducing the
    celestial-to-native rotation to a pure RA shift and making the full world-to-pixel
    mapping linear in ``(RA, Dec)``:

    $$
    p_x = \frac{\Delta\lambda}{\delta_x} + (c_x - 1) \\
    p_y = \frac{\phi}{\delta_y} + (c_y - 1)
    $$

    where $\Delta\lambda = ((\lambda - \lambda_0 + 180) \bmod 360) - 180$ handles
    the 0°/360° RA wrap, $(\lambda_0, 0.0)$ is ``crval``, $(\delta_x, \delta_y)$
    is ``cdelt`` in degrees, and $(c_x, c_y)$ is ``crpix`` (1-indexed FITS convention).
    """

    def __init__(
        self,
        shape: tuple[int, int],
        projection: WCSProjection,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
    ) -> None:
        if projection.crval[1] != 0.0:
            msg = (
                f'CAR projection requires crval_dec = 0 (native and celestial equators must'
                f' coincide), got {projection.crval[1]}'
            )
            raise ValueError(msg)
        super().__init__(shape, projection, stokes, dtype)

    @jax.jit
    def world2pixel(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
        r"""Convert spherical angles to CAR pixel coordinates in pure JAX.

        Args:
            theta (float): Spherical $\theta$ angle (co-latitude, 0 at north pole).
            phi (float): Spherical $\phi$ angle (longitude).

        Returns:
            Pixel coordinate pair ``(pix_x, pix_y)`` as float arrays.
        """
        lon_deg = jnp.degrees(phi)
        lat_deg = jnp.degrees(jnp.pi / 2 - theta)
        lon_diff = ((lon_deg - self.crval[0]) + 180) % 360 - 180
        pix_x = lon_diff / self.cdelt[0] + (self.crpix[0] - 1)
        pix_y = lat_deg / self.cdelt[1] + (self.crpix[1] - 1)
        return pix_x, pix_y

    def world2interp(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Integer[Array, '*dims 4'], Float[Array, '*dims 4']]:
        """Returns (indices, weights) for bilinear interpolation (n=4)."""
        pix_x, pix_y = self.world2pixel(theta, phi)
        xs, ys, weights = _2d_bilinear_interp(pix_x, pix_y)
        indices = self.pixel2index(xs, ys)
        return indices, weights

__init__(shape, projection, stokes='IQU', dtype=np.float64)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    shape: tuple[int, int],
    projection: WCSProjection,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
) -> None:
    if projection.crval[1] != 0.0:
        msg = (
            f'CAR projection requires crval_dec = 0 (native and celestial equators must'
            f' coincide), got {projection.crval[1]}'
        )
        raise ValueError(msg)
    super().__init__(shape, projection, stokes, dtype)

world2pixel(theta, phi)

Convert spherical angles to CAR pixel coordinates in pure JAX.

Parameters:

  • theta (float) –

    Spherical \(\theta\) angle (co-latitude, 0 at north pole).

  • phi (float) –

    Spherical \(\phi\) angle (longitude).

Returns:

  • tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]

    Pixel coordinate pair (pix_x, pix_y) as float arrays.

Source code in src/furax/obs/landscapes.py
@jax.jit
def world2pixel(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
    r"""Convert spherical angles to CAR pixel coordinates in pure JAX.

    Args:
        theta (float): Spherical $\theta$ angle (co-latitude, 0 at north pole).
        phi (float): Spherical $\phi$ angle (longitude).

    Returns:
        Pixel coordinate pair ``(pix_x, pix_y)`` as float arrays.
    """
    lon_deg = jnp.degrees(phi)
    lat_deg = jnp.degrees(jnp.pi / 2 - theta)
    lon_diff = ((lon_deg - self.crval[0]) + 180) % 360 - 180
    pix_x = lon_diff / self.cdelt[0] + (self.crpix[0] - 1)
    pix_y = lat_deg / self.cdelt[1] + (self.crpix[1] - 1)
    return pix_x, pix_y

world2interp(theta, phi)

Returns (indices, weights) for bilinear interpolation (n=4).

Source code in src/furax/obs/landscapes.py
def world2interp(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Integer[Array, '*dims 4'], Float[Array, '*dims 4']]:
    """Returns (indices, weights) for bilinear interpolation (n=4)."""
    pix_x, pix_y = self.world2pixel(theta, phi)
    xs, ys, weights = _2d_bilinear_interp(pix_x, pix_y)
    indices = self.pixel2index(xs, ys)
    return indices, weights

HealpixLandscape

Bases: StokesLandscape

Class representing a Healpix-projected map of Stokes vectors.

Methods:

  • __init__
  • quat2index

    Convert quaternion to HEALPix pixel index.

  • world2interp

    Returns (indices, weights) for bilinear HEALPix interpolation (n=4).

  • world2pixel

    Convert angles to HEALPix pixel index.

Attributes:

Source code in src/furax/obs/landscapes.py
@register_static
class HealpixLandscape(StokesLandscape):
    """Class representing a Healpix-projected map of Stokes vectors."""

    def __init__(
        self,
        nside: int,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
        nested: bool = False,
    ) -> None:
        if nested:
            raise NotImplementedError('NESTED pixel ordering is not fully supported by jax-healpy.')
        shape = (12 * nside**2,)
        super().__init__(shape, stokes, dtype)
        self.nside = nside
        self.nested = nested

    @jax.jit
    def quat2index(self, quat: Float[Array, '*dims 4']) -> Integer[Array, ' *dims']:
        r"""Convert quaternion to HEALPix pixel index.

        Args:
            quat (float): Quaternion.

        Returns:
            int: HEALPix pixel index.
        """
        # we want the 3 dimensions on the left
        vec = jnp.moveaxis(qrot_zaxis(quat), -1, 0)
        pix: Integer[Array, ' *dims'] = jhp.vec2pix(self.nside, *vec, nest=self.nested)
        return pix

    def world2interp(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Integer[Array, '*dims 4'], Float[Array, '*dims 4']]:
        """Returns (indices, weights) for bilinear HEALPix interpolation (n=4)."""
        # get_interp_weights returns (4, *dims) for both pixels and weights
        pixels, weights = jhp.get_interp_weights(self.nside, theta, phi, nest=self.nested)
        indices = jnp.moveaxis(pixels, 0, -1)
        weights = jnp.moveaxis(weights, 0, -1).astype(self.dtype)
        return indices, weights

    @jax.jit
    def world2pixel(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Integer[Array, ' *dims'], ...]:
        r"""Convert angles to HEALPix pixel index.

        Args:
            theta (float): Spherical $\theta$ angle.
            phi (float): Spherical $\phi$ angle.

        Returns:
            int: HEALPix pixel index.
        """
        return (jhp.ang2pix(self.nside, theta, phi, nest=self.nested),)

nside = nside instance-attribute

nested = nested instance-attribute

__init__(nside, stokes='IQU', dtype=np.float64, nested=False)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    nside: int,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
    nested: bool = False,
) -> None:
    if nested:
        raise NotImplementedError('NESTED pixel ordering is not fully supported by jax-healpy.')
    shape = (12 * nside**2,)
    super().__init__(shape, stokes, dtype)
    self.nside = nside
    self.nested = nested

quat2index(quat)

Convert quaternion to HEALPix pixel index.

Parameters:

  • quat (float) –

    Quaternion.

Returns:

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

    HEALPix pixel index.

Source code in src/furax/obs/landscapes.py
@jax.jit
def quat2index(self, quat: Float[Array, '*dims 4']) -> Integer[Array, ' *dims']:
    r"""Convert quaternion to HEALPix pixel index.

    Args:
        quat (float): Quaternion.

    Returns:
        int: HEALPix pixel index.
    """
    # we want the 3 dimensions on the left
    vec = jnp.moveaxis(qrot_zaxis(quat), -1, 0)
    pix: Integer[Array, ' *dims'] = jhp.vec2pix(self.nside, *vec, nest=self.nested)
    return pix

world2interp(theta, phi)

Returns (indices, weights) for bilinear HEALPix interpolation (n=4).

Source code in src/furax/obs/landscapes.py
def world2interp(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Integer[Array, '*dims 4'], Float[Array, '*dims 4']]:
    """Returns (indices, weights) for bilinear HEALPix interpolation (n=4)."""
    # get_interp_weights returns (4, *dims) for both pixels and weights
    pixels, weights = jhp.get_interp_weights(self.nside, theta, phi, nest=self.nested)
    indices = jnp.moveaxis(pixels, 0, -1)
    weights = jnp.moveaxis(weights, 0, -1).astype(self.dtype)
    return indices, weights

world2pixel(theta, phi)

Convert angles to HEALPix pixel index.

Parameters:

  • theta (float) –

    Spherical \(\theta\) angle.

  • phi (float) –

    Spherical \(\phi\) angle.

Returns:

  • int ( tuple[Integer[Array, ' *dims'], ...] ) –

    HEALPix pixel index.

Source code in src/furax/obs/landscapes.py
@jax.jit
def world2pixel(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Integer[Array, ' *dims'], ...]:
    r"""Convert angles to HEALPix pixel index.

    Args:
        theta (float): Spherical $\theta$ angle.
        phi (float): Spherical $\phi$ angle.

    Returns:
        int: HEALPix pixel index.
    """
    return (jhp.ang2pix(self.nside, theta, phi, nest=self.nested),)

FrequencyLandscape

Bases: HealpixLandscape

Methods:

Attributes:

Source code in src/furax/obs/landscapes.py
@register_static
class FrequencyLandscape(HealpixLandscape):
    def __init__(
        self,
        nside: int,
        frequencies: Array,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
    ):
        super().__init__(nside, stokes, dtype)
        self.frequencies = frequencies
        self.shape = (len(frequencies), 12 * nside**2)

frequencies = frequencies instance-attribute

shape = (len(frequencies), 12 * nside ** 2) instance-attribute

__init__(nside, frequencies, stokes='IQU', dtype=np.float64)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    nside: int,
    frequencies: Array,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
):
    super().__init__(nside, stokes, dtype)
    self.frequencies = frequencies
    self.shape = (len(frequencies), 12 * nside**2)

AstropyWCSLandscape

Bases: StokesLandscape

Class representing an astropy WCS map of Stokes vectors.

Methods:

Attributes:

Source code in src/furax/obs/landscapes.py
@register_static
class AstropyWCSLandscape(StokesLandscape):
    """Class representing an astropy WCS map of Stokes vectors."""

    def __init__(
        self,
        shape: tuple[int, ...],
        wcs: WCS,
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
    ) -> None:
        super().__init__(shape, stokes, dtype)
        self.wcs = wcs

    @jax.jit
    def world2pixel(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
        r"""Convert angles to WCS map indices.

        Args:
            theta (float): Spherical $\theta$ angle.
            phi (float): Spherical $\phi$ angle.

        Returns:
            WCS map index pairs
        """

        def f(theta, phi):  # type: ignore[no-untyped-def]
            # SkyCoord takes (lon,lat)
            pix_i, pix_j = self.wcs.world_to_pixel(SkyCoord(phi, (np.pi / 2 - theta), unit='rad'))
            return np.array(pix_i), np.array(pix_j)

        struct = jax.ShapeDtypeStruct(theta.shape, theta.dtype)
        result_shape = (struct, struct)

        return jax.pure_callback(f, result_shape, theta, phi)  # type: ignore[no-any-return]

wcs = wcs instance-attribute

__init__(shape, wcs, stokes='IQU', dtype=np.float64)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    shape: tuple[int, ...],
    wcs: WCS,
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
) -> None:
    super().__init__(shape, stokes, dtype)
    self.wcs = wcs

world2pixel(theta, phi)

Convert angles to WCS map indices.

Parameters:

  • theta (float) –

    Spherical \(\theta\) angle.

  • phi (float) –

    Spherical \(\phi\) angle.

Returns:

  • tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]

    WCS map index pairs

Source code in src/furax/obs/landscapes.py
@jax.jit
def world2pixel(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
    r"""Convert angles to WCS map indices.

    Args:
        theta (float): Spherical $\theta$ angle.
        phi (float): Spherical $\phi$ angle.

    Returns:
        WCS map index pairs
    """

    def f(theta, phi):  # type: ignore[no-untyped-def]
        # SkyCoord takes (lon,lat)
        pix_i, pix_j = self.wcs.world_to_pixel(SkyCoord(phi, (np.pi / 2 - theta), unit='rad'))
        return np.array(pix_i), np.array(pix_j)

    struct = jax.ShapeDtypeStruct(theta.shape, theta.dtype)
    result_shape = (struct, struct)

    return jax.pure_callback(f, result_shape, theta, phi)  # type: ignore[no-any-return]

HorizonLandscape

Bases: StokesLandscape

Class representing a map of Stokes vectors in horizon (ground) coordinates.

Contains two axes

AXIS1: altitude (elevation) in radians AXIS2: azimuth in radians

Methods:

Attributes:

Source code in src/furax/obs/landscapes.py
@register_static
class HorizonLandscape(StokesLandscape):
    """Class representing a map of Stokes vectors in horizon (ground) coordinates.

    Contains two axes:
        AXIS1: altitude (elevation) in radians
        AXIS2: azimuth in radians
    """

    def __init__(
        self,
        shape: tuple[int, ...],
        altitude_limits: tuple[Array, Array],
        azimuth_limits: tuple[Array, Array],
        stokes: ValidStokesType = 'IQU',
        dtype: DTypeLike = np.float64,
    ) -> None:
        super().__init__(shape, stokes, dtype)
        self.altitude_limits = altitude_limits
        self.azimuth_limits = azimuth_limits

    def bin_edges(self) -> tuple[Float[Array, ' bins+1'], Float[Array, ' bins+1']]:
        """Return the bin edges of the map."""
        alt_min, alt_max = self.altitude_limits
        az_min, az_max = self.azimuth_limits
        n_az, n_alt = self.shape
        return jnp.linspace(alt_min, alt_max, n_alt + 1), jnp.linspace(az_min, az_max, n_az + 1)

    def bin_centers(self) -> tuple[Float[Array, ' bins'], Float[Array, ' bins']]:
        """Return the bin centers of the map."""
        alt_edges, az_edges = self.bin_edges()
        alt_centers = 0.5 * (alt_edges[:-1] + alt_edges[1:])
        az_centers = 0.5 * (az_edges[:-1] + az_edges[1:])
        return alt_centers, az_centers

    @jax.jit
    def world2pixel(
        self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
    ) -> tuple[Integer[Array, ' *dims'], Integer[Array, ' *dims']]:
        r"""Convert angles in radians to Horizon map indices.

        Args:
            theta (float): Spherical $\theta$ angle = pi/2 - altitude.
            phi (float): Spherical $\phi$ angle = -azimuth.

        Returns:
            Ground map index pairs
        """
        alt_min, alt_max = self.altitude_limits
        az_min, az_max = self.azimuth_limits
        n_az, n_alt = self.shape  # Note self.shape has reverse order: [AXIS2, AXIS1]
        dalt = (alt_max - alt_min) / n_alt
        daz = (az_max - az_min) / n_az

        altitude = jnp.pi / 2 - theta
        azimuth = -phi

        pix_i = jnp.round((altitude - alt_min) / dalt - 0.5).astype(jnp.int64)
        pix_j = jnp.round(((azimuth - az_min) % (2 * jnp.pi)) / daz - 0.5).astype(jnp.int64)

        return pix_i, pix_j

altitude_limits = altitude_limits instance-attribute

azimuth_limits = azimuth_limits instance-attribute

__init__(shape, altitude_limits, azimuth_limits, stokes='IQU', dtype=np.float64)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    shape: tuple[int, ...],
    altitude_limits: tuple[Array, Array],
    azimuth_limits: tuple[Array, Array],
    stokes: ValidStokesType = 'IQU',
    dtype: DTypeLike = np.float64,
) -> None:
    super().__init__(shape, stokes, dtype)
    self.altitude_limits = altitude_limits
    self.azimuth_limits = azimuth_limits

bin_edges()

Return the bin edges of the map.

Source code in src/furax/obs/landscapes.py
def bin_edges(self) -> tuple[Float[Array, ' bins+1'], Float[Array, ' bins+1']]:
    """Return the bin edges of the map."""
    alt_min, alt_max = self.altitude_limits
    az_min, az_max = self.azimuth_limits
    n_az, n_alt = self.shape
    return jnp.linspace(alt_min, alt_max, n_alt + 1), jnp.linspace(az_min, az_max, n_az + 1)

bin_centers()

Return the bin centers of the map.

Source code in src/furax/obs/landscapes.py
def bin_centers(self) -> tuple[Float[Array, ' bins'], Float[Array, ' bins']]:
    """Return the bin centers of the map."""
    alt_edges, az_edges = self.bin_edges()
    alt_centers = 0.5 * (alt_edges[:-1] + alt_edges[1:])
    az_centers = 0.5 * (az_edges[:-1] + az_edges[1:])
    return alt_centers, az_centers

world2pixel(theta, phi)

Convert angles in radians to Horizon map indices.

Parameters:

  • theta (float) –

    Spherical \(\theta\) angle = pi/2 - altitude.

  • phi (float) –

    Spherical \(\phi\) angle = -azimuth.

Returns:

  • tuple[Integer[Array, ' *dims'], Integer[Array, ' *dims']]

    Ground map index pairs

Source code in src/furax/obs/landscapes.py
@jax.jit
def world2pixel(
    self, theta: Float[Array, ' *dims'], phi: Float[Array, ' *dims']
) -> tuple[Integer[Array, ' *dims'], Integer[Array, ' *dims']]:
    r"""Convert angles in radians to Horizon map indices.

    Args:
        theta (float): Spherical $\theta$ angle = pi/2 - altitude.
        phi (float): Spherical $\phi$ angle = -azimuth.

    Returns:
        Ground map index pairs
    """
    alt_min, alt_max = self.altitude_limits
    az_min, az_max = self.azimuth_limits
    n_az, n_alt = self.shape  # Note self.shape has reverse order: [AXIS2, AXIS1]
    dalt = (alt_max - alt_min) / n_alt
    daz = (az_max - az_min) / n_az

    altitude = jnp.pi / 2 - theta
    azimuth = -phi

    pix_i = jnp.round((altitude - alt_min) / dalt - 0.5).astype(jnp.int64)
    pix_j = jnp.round(((azimuth - az_min) % (2 * jnp.pi)) / daz - 0.5).astype(jnp.int64)

    return pix_i, pix_j

TangentialLandscape

Bases: StokesLandscape

Class representing a flat map in the local tangent plane at zenith.

Implements the gnomonic (tangential) projection: a ray from the telescope origin in direction \(\hat{v}\) intersects the horizontal plane at height \(h\) at

\[ x = h \frac{v_x}{v_z}, \quad y = h \frac{v_y}{v_z} \]

where \(v_z = \cos\theta\) is the elevation cosine (z-axis = zenith).

The map is a 2D Cartesian grid in physical units (same units as height), centered at the origin. Pixel spacing is dx along x and dy along y, so the map covers [-n_x*dx/2, n_x*dx/2] × [-n_y*dy/2, n_y*dy/2].

Attributes:

  • shape

    Array shape (n_y, n_x) in row-major order.

  • dx

    Pixel spacing along the x-axis (same units as height).

  • dy

    Pixel spacing along the y-axis (same units as height).

  • height

    Height of the tangent plane above the telescope (same units as dx/dy).

  • stokes

    Stokes components stored in the map (default 'I').

  • dtype

    Data type for map values.

Methods:

  • __init__
  • from_extent

    Create a landscape from physical extent and pixel spacing.

  • xy2pixel

    Convert physical coordinates to floating-point pixel coordinates.

  • world2pixel

    Convert ISO spherical angles to pixel coordinates via gnomonic projection.

  • quat2xy

    Convert quaternions to physical (x, y) coordinates on the tangent plane.

  • quat2pixel

    Convert quaternions to floating-point pixel coordinates.

  • xy2interp

    Bilinear interpolation weights and indices for physical coordinates.

Source code in src/furax/obs/landscapes.py
@register_static
class TangentialLandscape(StokesLandscape):
    r"""Class representing a flat map in the local tangent plane at zenith.

    Implements the gnomonic (tangential) projection: a ray from the telescope origin
    in direction $\hat{v}$ intersects the horizontal plane at height $h$ at

    $$
    x = h \frac{v_x}{v_z}, \quad y = h \frac{v_y}{v_z}
    $$

    where $v_z = \cos\theta$ is the elevation cosine (z-axis = zenith).

    The map is a 2D Cartesian grid in physical units (same units as ``height``), centered
    at the origin. Pixel spacing is ``dx`` along x and ``dy`` along y, so the map covers
    ``[-n_x*dx/2, n_x*dx/2]`` × ``[-n_y*dy/2, n_y*dy/2]``.

    Attributes:
        shape: Array shape ``(n_y, n_x)`` in row-major order.
        dx: Pixel spacing along the x-axis (same units as ``height``).
        dy: Pixel spacing along the y-axis (same units as ``height``).
        height: Height of the tangent plane above the telescope (same units as ``dx``/``dy``).
        stokes: Stokes components stored in the map (default ``'I'``).
        dtype: Data type for map values.
    """

    def __init__(
        self,
        shape: tuple[int, int],
        dx: float,
        dy: float,
        height: float,
        x0: float = 0.0,
        y0: float = 0.0,
        stokes: ValidStokesType = 'I',
        dtype: DTypeLike = np.float64,
    ) -> None:
        if stokes != 'I':
            raise NotImplementedError
        super().__init__(shape, stokes, dtype)
        self.dx = dx
        self.dy = dy
        self.height = height
        self.x0 = x0
        self.y0 = y0

    @property
    def extent(self) -> tuple[float, float]:
        """Physical size of the map ``(x_size, y_size)`` in the same units as ``dx``/``dy``."""
        n_x, n_y = self.pixel_shape
        return n_x * self.dx, n_y * self.dy

    @classmethod
    def from_extent(
        cls,
        x_size: float,
        y_size: float,
        dx: float,
        dy: float,
        height: float,
        x0: float = 0.0,
        y0: float = 0.0,
        stokes: ValidStokesType = 'I',
        dtype: DTypeLike = np.float64,
    ) -> 'TangentialLandscape':
        """Create a landscape from physical extent and pixel spacing.

        The map is centered at ``(x0, y0)`` and covers
        ``[x0 - x_size/2, x0 + x_size/2]`` × ``[y0 - y_size/2, y0 + y_size/2]``.
        The number of pixels is ``ceil(x_size / dx)`` × ``ceil(y_size / dy)``, so the
        actual covered area may be slightly larger than requested to accommodate whole
        pixels.

        Args:
            x_size: Total physical width of the map (same units as ``height``).
            y_size: Total physical height of the map (same units as ``height``).
            dx: Pixel spacing along x.
            dy: Pixel spacing along y.
            height: Height of the tangent plane above the telescope.
            x0: Physical x-coordinate of the map center (default 0).
            y0: Physical y-coordinate of the map center (default 0).
            stokes: Stokes components to store (default ``'I'``).
            dtype: Data type for map values.
        """
        n_x = int(np.ceil(x_size / dx))
        n_y = int(np.ceil(y_size / dy))
        return cls((n_y, n_x), dx, dy, height, x0, y0, stokes, dtype)

    def xy2pixel(
        self,
        x: Float[Array, ' *dims'],
        y: Float[Array, ' *dims'],
    ) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
        """Convert physical coordinates to floating-point pixel coordinates.

        The map is centered at ``(x0, y0)``. Integer pixel coordinates correspond to
        pixel centers.

        Args:
            x: Physical x-coordinates.
            y: Physical y-coordinates.

        Returns:
            Floating-point pixel coordinate pair ``(pix_x, pix_y)``.
        """
        n_x, n_y = self.pixel_shape
        pix_x = (x - self.x0) / self.dx + n_x / 2 - 0.5
        pix_y = (y - self.y0) / self.dy + n_y / 2 - 0.5
        return pix_x, pix_y

    @jax.jit
    def world2pixel(
        self,
        theta: Float[Array, ' *dims'],
        phi: Float[Array, ' *dims'],
    ) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
        r"""Convert ISO spherical angles to pixel coordinates via gnomonic projection.

        Args:
            theta: Co-latitude from zenith (0 at zenith, $\pi/2$ at horizon).
            phi: Azimuthal angle.

        Returns:
            Floating-point pixel coordinate pair ``(pix_x, pix_y)``.
        """
        sin_theta = jnp.sin(theta)
        cos_theta = jnp.cos(theta)
        x = self.height * sin_theta * jnp.cos(phi) / cos_theta
        y = self.height * sin_theta * jnp.sin(phi) / cos_theta
        return self.xy2pixel(x, y)

    def quat2xy(
        self, quat: Float[Array, '*dims 4']
    ) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
        """Convert quaternions to physical (x, y) coordinates on the tangent plane.

        Uses [`qrot_zaxis`][] to extract the pointing direction and applies the exact
        gnomonic projection. This is the primary conversion step used by
        [`AtmospherePointingOperator`][furax.obs.atmosphere.AtmospherePointingOperator] before
        adding wind displacement.

        Args:
            quat: Pointing quaternions (z-axis = zenith frame).

        Returns:
            Physical coordinate pair ``(x, y)``.
        """
        v = qrot_zaxis(quat)
        # cos(theta) = (a² + d²) - (b² + c²) = v[..., 2]
        # sin(theta) cos(phi) = 2 (ca + db) = v[..., 0]
        # sin(theta) sin(phi) = 2 (cd - ab) = v[..., 1]
        x = self.height * v[..., 0] / v[..., 2]
        y = self.height * v[..., 1] / v[..., 2]
        return x, y

    def quat2pixel(
        self, quat: Float[Array, '*dims 4']
    ) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
        """Convert quaternions to floating-point pixel coordinates.

        Overrides the base-class implementation to use [`qrot_zaxis`][] directly,
        avoiding the round-trip through ISO angles.
        """
        x, y = self.quat2xy(quat)
        return self.xy2pixel(x, y)

    def xy2interp(
        self,
        x: Float[Array, ' *dims'],
        y: Float[Array, ' *dims'],
    ) -> tuple[Integer[Array, ' *dims 4'], Float[Array, ' *dims 4']]:
        """Bilinear interpolation weights and indices for physical coordinates.

        Returns the four neighbouring pixel indices and their bilinear weights.
        Out-of-bounds neighbours are marked with index ``-1`` and weight ``0``.

        Args:
            x: Physical x-coordinates.
            y: Physical y-coordinates.

        Returns:
            ``(indices, weights)`` where both have shape ``(*dims, 4)`` and the
            trailing axis runs over the four neighbours in order
            ``(i0,j0), (i1,j0), (i0,j1), (i1,j1)``.
        """
        pix_x, pix_y = self.xy2pixel(x, y)
        xs, ys, weights = _2d_bilinear_interp(pix_x, pix_y)
        indices = self.pixel2index(xs, ys)
        return indices, weights

dx = dx instance-attribute

dy = dy instance-attribute

height = height instance-attribute

x0 = x0 instance-attribute

y0 = y0 instance-attribute

extent property

Physical size of the map (x_size, y_size) in the same units as dx/dy.

__init__(shape, dx, dy, height, x0=0.0, y0=0.0, stokes='I', dtype=np.float64)

Source code in src/furax/obs/landscapes.py
def __init__(
    self,
    shape: tuple[int, int],
    dx: float,
    dy: float,
    height: float,
    x0: float = 0.0,
    y0: float = 0.0,
    stokes: ValidStokesType = 'I',
    dtype: DTypeLike = np.float64,
) -> None:
    if stokes != 'I':
        raise NotImplementedError
    super().__init__(shape, stokes, dtype)
    self.dx = dx
    self.dy = dy
    self.height = height
    self.x0 = x0
    self.y0 = y0

from_extent(x_size, y_size, dx, dy, height, x0=0.0, y0=0.0, stokes='I', dtype=np.float64) classmethod

Create a landscape from physical extent and pixel spacing.

The map is centered at (x0, y0) and covers [x0 - x_size/2, x0 + x_size/2] × [y0 - y_size/2, y0 + y_size/2]. The number of pixels is ceil(x_size / dx) × ceil(y_size / dy), so the actual covered area may be slightly larger than requested to accommodate whole pixels.

Parameters:

  • x_size (float) –

    Total physical width of the map (same units as height).

  • y_size (float) –

    Total physical height of the map (same units as height).

  • dx (float) –

    Pixel spacing along x.

  • dy (float) –

    Pixel spacing along y.

  • height (float) –

    Height of the tangent plane above the telescope.

  • x0 (float, default: 0.0 ) –

    Physical x-coordinate of the map center (default 0).

  • y0 (float, default: 0.0 ) –

    Physical y-coordinate of the map center (default 0).

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

    Stokes components to store (default 'I').

  • dtype (DTypeLike, default: float64 ) –

    Data type for map values.

Source code in src/furax/obs/landscapes.py
@classmethod
def from_extent(
    cls,
    x_size: float,
    y_size: float,
    dx: float,
    dy: float,
    height: float,
    x0: float = 0.0,
    y0: float = 0.0,
    stokes: ValidStokesType = 'I',
    dtype: DTypeLike = np.float64,
) -> 'TangentialLandscape':
    """Create a landscape from physical extent and pixel spacing.

    The map is centered at ``(x0, y0)`` and covers
    ``[x0 - x_size/2, x0 + x_size/2]`` × ``[y0 - y_size/2, y0 + y_size/2]``.
    The number of pixels is ``ceil(x_size / dx)`` × ``ceil(y_size / dy)``, so the
    actual covered area may be slightly larger than requested to accommodate whole
    pixels.

    Args:
        x_size: Total physical width of the map (same units as ``height``).
        y_size: Total physical height of the map (same units as ``height``).
        dx: Pixel spacing along x.
        dy: Pixel spacing along y.
        height: Height of the tangent plane above the telescope.
        x0: Physical x-coordinate of the map center (default 0).
        y0: Physical y-coordinate of the map center (default 0).
        stokes: Stokes components to store (default ``'I'``).
        dtype: Data type for map values.
    """
    n_x = int(np.ceil(x_size / dx))
    n_y = int(np.ceil(y_size / dy))
    return cls((n_y, n_x), dx, dy, height, x0, y0, stokes, dtype)

xy2pixel(x, y)

Convert physical coordinates to floating-point pixel coordinates.

The map is centered at (x0, y0). Integer pixel coordinates correspond to pixel centers.

Parameters:

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

    Physical x-coordinates.

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

    Physical y-coordinates.

Returns:

  • tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]

    Floating-point pixel coordinate pair (pix_x, pix_y).

Source code in src/furax/obs/landscapes.py
def xy2pixel(
    self,
    x: Float[Array, ' *dims'],
    y: Float[Array, ' *dims'],
) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
    """Convert physical coordinates to floating-point pixel coordinates.

    The map is centered at ``(x0, y0)``. Integer pixel coordinates correspond to
    pixel centers.

    Args:
        x: Physical x-coordinates.
        y: Physical y-coordinates.

    Returns:
        Floating-point pixel coordinate pair ``(pix_x, pix_y)``.
    """
    n_x, n_y = self.pixel_shape
    pix_x = (x - self.x0) / self.dx + n_x / 2 - 0.5
    pix_y = (y - self.y0) / self.dy + n_y / 2 - 0.5
    return pix_x, pix_y

world2pixel(theta, phi)

Convert ISO spherical angles to pixel coordinates via gnomonic projection.

Parameters:

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

    Co-latitude from zenith (0 at zenith, \(\pi/2\) at horizon).

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

    Azimuthal angle.

Returns:

  • tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]

    Floating-point pixel coordinate pair (pix_x, pix_y).

Source code in src/furax/obs/landscapes.py
@jax.jit
def world2pixel(
    self,
    theta: Float[Array, ' *dims'],
    phi: Float[Array, ' *dims'],
) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
    r"""Convert ISO spherical angles to pixel coordinates via gnomonic projection.

    Args:
        theta: Co-latitude from zenith (0 at zenith, $\pi/2$ at horizon).
        phi: Azimuthal angle.

    Returns:
        Floating-point pixel coordinate pair ``(pix_x, pix_y)``.
    """
    sin_theta = jnp.sin(theta)
    cos_theta = jnp.cos(theta)
    x = self.height * sin_theta * jnp.cos(phi) / cos_theta
    y = self.height * sin_theta * jnp.sin(phi) / cos_theta
    return self.xy2pixel(x, y)

quat2xy(quat)

Convert quaternions to physical (x, y) coordinates on the tangent plane.

Uses qrot_zaxis to extract the pointing direction and applies the exact gnomonic projection. This is the primary conversion step used by AtmospherePointingOperator before adding wind displacement.

Parameters:

  • quat (Float[Array, '*dims 4']) –

    Pointing quaternions (z-axis = zenith frame).

Returns:

  • tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]

    Physical coordinate pair (x, y).

Source code in src/furax/obs/landscapes.py
def quat2xy(
    self, quat: Float[Array, '*dims 4']
) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
    """Convert quaternions to physical (x, y) coordinates on the tangent plane.

    Uses [`qrot_zaxis`][] to extract the pointing direction and applies the exact
    gnomonic projection. This is the primary conversion step used by
    [`AtmospherePointingOperator`][furax.obs.atmosphere.AtmospherePointingOperator] before
    adding wind displacement.

    Args:
        quat: Pointing quaternions (z-axis = zenith frame).

    Returns:
        Physical coordinate pair ``(x, y)``.
    """
    v = qrot_zaxis(quat)
    # cos(theta) = (a² + d²) - (b² + c²) = v[..., 2]
    # sin(theta) cos(phi) = 2 (ca + db) = v[..., 0]
    # sin(theta) sin(phi) = 2 (cd - ab) = v[..., 1]
    x = self.height * v[..., 0] / v[..., 2]
    y = self.height * v[..., 1] / v[..., 2]
    return x, y

quat2pixel(quat)

Convert quaternions to floating-point pixel coordinates.

Overrides the base-class implementation to use qrot_zaxis directly, avoiding the round-trip through ISO angles.

Source code in src/furax/obs/landscapes.py
def quat2pixel(
    self, quat: Float[Array, '*dims 4']
) -> tuple[Float[Array, ' *dims'], Float[Array, ' *dims']]:
    """Convert quaternions to floating-point pixel coordinates.

    Overrides the base-class implementation to use [`qrot_zaxis`][] directly,
    avoiding the round-trip through ISO angles.
    """
    x, y = self.quat2xy(quat)
    return self.xy2pixel(x, y)

xy2interp(x, y)

Bilinear interpolation weights and indices for physical coordinates.

Returns the four neighbouring pixel indices and their bilinear weights. Out-of-bounds neighbours are marked with index -1 and weight 0.

Parameters:

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

    Physical x-coordinates.

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

    Physical y-coordinates.

Returns:

  • Integer[Array, ' *dims 4']

    (indices, weights) where both have shape (*dims, 4) and the

  • Float[Array, ' *dims 4']

    trailing axis runs over the four neighbours in order

  • tuple[Integer[Array, ' *dims 4'], Float[Array, ' *dims 4']]

    (i0,j0), (i1,j0), (i0,j1), (i1,j1).

Source code in src/furax/obs/landscapes.py
def xy2interp(
    self,
    x: Float[Array, ' *dims'],
    y: Float[Array, ' *dims'],
) -> tuple[Integer[Array, ' *dims 4'], Float[Array, ' *dims 4']]:
    """Bilinear interpolation weights and indices for physical coordinates.

    Returns the four neighbouring pixel indices and their bilinear weights.
    Out-of-bounds neighbours are marked with index ``-1`` and weight ``0``.

    Args:
        x: Physical x-coordinates.
        y: Physical y-coordinates.

    Returns:
        ``(indices, weights)`` where both have shape ``(*dims, 4)`` and the
        trailing axis runs over the four neighbours in order
        ``(i0,j0), (i1,j0), (i0,j1), (i1,j1)``.
    """
    pix_x, pix_y = self.xy2pixel(x, y)
    xs, ys, weights = _2d_bilinear_interp(pix_x, pix_y)
    indices = self.pixel2index(xs, ys)
    return indices, weights

furax.obs.operators

Classes:

  • BeamOperator

    Beam operator with a single transfer function shared across all Stokes components.

  • BeamOperatorIQU

    Beam operator with an independent transfer function per Stokes component.

  • HWPOperator

    Operator for an ideal half-wave plate (HWP).

  • LinearPolarizerOperator

    Operator for an ideal linear polarizer.

  • QURotationOperator

    Operator that rotates Q and U Stokes parameters by angle theta.

  • AbstractSEDOperator

    Abstract base class for Spectral Energy Distribution (SED) operators.

  • CMBOperator

    Operator for Cosmic Microwave Background (CMB) spectral energy distribution.

  • DustOperator

    Operator for thermal dust spectral energy distribution.

  • NoiseDiagonalOperator

    Constructs a diagonal noise operator.

  • SynchrotronOperator

    Operator for synchrotron spectral energy distribution.

Functions:

BeamOperator dataclass

Bases: AbstractLinearOperator

Beam operator with a single transfer function shared across all Stokes components.

Input PyTree leaves are expected to have shape (nfreq, npix). They are round-tripped through a spherical harmonic transform and the beam is applied in alm space as a per-multipole, per-frequency scaling.

The operator is symmetric (self-adjoint): applying it twice is equivalent to squaring the transfer function. Use inverse to obtain the deconvolution operator (beam_fl replaced by its reciprocal).

Algebraic rule: BeamOperator @ BeamOperator reduces to a single BeamOperator whose beam_fl is the element-wise product of both transfer functions (see BeamRule).

Attributes:

  • lmax (int) –

    Maximum spherical harmonic degree.

  • beam_fl (Float[Array, ...]) –

    Beam transfer function, shape (nfreq, lmax+1) for a per-frequency beam, or (lmax+1,) for a beam shared across all frequencies (promoted to 2-D via jnp.atleast_2d before broadcasting over frequencies).

Examples:

>>> import jax.numpy as jnp
>>> from furax.obs.stokes import StokesIQU
>>> from furax.obs.operators import BeamOperator
>>> nside, lmax, nfreq = 16, 31, 2
>>> npix = 12 * nside ** 2
>>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
>>> beam_fl = jnp.ones((nfreq, lmax + 1))
>>> op = BeamOperator(lmax=lmax, beam_fl=beam_fl, in_structure=structure)

Methods:

  • mv

    Apply the beam to input sky maps.

  • inverse

    Return a BeamOperator with the reciprocal transfer function.

Source code in src/furax/obs/operators/_beam_operator.py
@symmetric
class BeamOperator(AbstractLinearOperator):
    """Beam operator with a single transfer function shared across all Stokes components.

    Input PyTree leaves are expected to have shape ``(nfreq, npix)``.  They are
    round-tripped through a spherical harmonic transform and the beam is applied
    in alm space as a per-multipole, per-frequency scaling.

    The operator is symmetric (self-adjoint): applying it twice is equivalent to
    squaring the transfer function.  Use [`inverse`][] to obtain the deconvolution
    operator (``beam_fl`` replaced by its reciprocal).

    Algebraic rule: ``BeamOperator @ BeamOperator`` reduces to a single
    [`BeamOperator`][] whose ``beam_fl`` is the element-wise product of both
    transfer functions (see `BeamRule`).

    Attributes:
        lmax: Maximum spherical harmonic degree.
        beam_fl: Beam transfer function, shape ``(nfreq, lmax+1)`` for a
            per-frequency beam, or ``(lmax+1,)`` for a beam shared across all
            frequencies (promoted to 2-D via `jnp.atleast_2d` before
            broadcasting over frequencies).

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.obs.stokes import StokesIQU
        >>> from furax.obs.operators import BeamOperator
        >>> nside, lmax, nfreq = 16, 31, 2
        >>> npix = 12 * nside ** 2
        >>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
        >>> beam_fl = jnp.ones((nfreq, lmax + 1))
        >>> op = BeamOperator(lmax=lmax, beam_fl=beam_fl, in_structure=structure)
    """

    lmax: int
    beam_fl: Float[Array, '...']

    def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
        """Apply the beam to input sky maps.

        Args:
            x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
                for a single-frequency input.

        Returns:
            Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
        """
        first_leaf = jax.tree_util.tree_leaves(x)[0]
        input_is_1d = first_leaf.ndim == 1
        first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
        nside = jhp.npix2nside(first_leaf.shape[-1])

        map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
        alm = map2alm.mv(x)

        # Apply the per-multipole transfer function, broadcasting it over every leading axis of the
        # alm leaf (frequency and, for a Stokes-backed map, the leading Stokes axis).
        def apply_beam(alm_leaf: jax.Array) -> jax.Array:
            fl = jnp.broadcast_to(self.beam_fl, alm_leaf.shape[:-2] + (self.lmax + 1,))
            return jnp.asarray(jhp.almxfl(alm_leaf, fl, healpy_ordering=False))

        alm_beam = jax.tree.map(apply_beam, alm)

        out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
        if input_is_1d:
            out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
        return out

    def inverse(self) -> 'BeamOperator':
        """Return a [`BeamOperator`][] with the reciprocal transfer function.

        Returns:
            A new [`BeamOperator`][] whose ``beam_fl`` equals ``1 / self.beam_fl``.
        """
        return BeamOperator(
            lmax=self.lmax, beam_fl=1.0 / self.beam_fl, in_structure=self.in_structure
        )

lmax instance-attribute

beam_fl instance-attribute

mv(x)

Apply the beam to input sky maps.

Parameters:

  • x (PyTree[Inexact[Array, 'nfreq npix']]) –

    Input PyTree whose leaves have shape (nfreq, npix) or (npix,) for a single-frequency input.

Returns:

  • PyTree[Inexact[Array, 'nfreq npix']]

    Beam-smoothed PyTree with the same structure and leaf shapes as x.

Source code in src/furax/obs/operators/_beam_operator.py
def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
    """Apply the beam to input sky maps.

    Args:
        x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
            for a single-frequency input.

    Returns:
        Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
    """
    first_leaf = jax.tree_util.tree_leaves(x)[0]
    input_is_1d = first_leaf.ndim == 1
    first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
    nside = jhp.npix2nside(first_leaf.shape[-1])

    map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
    alm = map2alm.mv(x)

    # Apply the per-multipole transfer function, broadcasting it over every leading axis of the
    # alm leaf (frequency and, for a Stokes-backed map, the leading Stokes axis).
    def apply_beam(alm_leaf: jax.Array) -> jax.Array:
        fl = jnp.broadcast_to(self.beam_fl, alm_leaf.shape[:-2] + (self.lmax + 1,))
        return jnp.asarray(jhp.almxfl(alm_leaf, fl, healpy_ordering=False))

    alm_beam = jax.tree.map(apply_beam, alm)

    out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
    if input_is_1d:
        out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
    return out

inverse()

Return a BeamOperator with the reciprocal transfer function.

Returns:

Source code in src/furax/obs/operators/_beam_operator.py
def inverse(self) -> 'BeamOperator':
    """Return a [`BeamOperator`][] with the reciprocal transfer function.

    Returns:
        A new [`BeamOperator`][] whose ``beam_fl`` equals ``1 / self.beam_fl``.
    """
    return BeamOperator(
        lmax=self.lmax, beam_fl=1.0 / self.beam_fl, in_structure=self.in_structure
    )

BeamOperatorIQU dataclass

Bases: AbstractLinearOperator

Beam operator with an independent transfer function per Stokes component.

Input PyTree leaves are expected to have shape (nfreq, npix). Each leaf is smoothed with its own transfer function taken from the corresponding leaf of beam_fl, enabling different beams for I, Q, and U (or any other Stokes combination).

The operator is symmetric (self-adjoint). Use inverse to obtain the deconvolution operator (each beam_fl leaf replaced by its reciprocal).

Algebraic rule: BeamOperatorIQU @ BeamOperatorIQU reduces to a single operator whose per-leaf transfer functions are the element-wise products of both (see BeamIQURule).

Attributes:

  • lmax (int) –

    Maximum spherical harmonic degree.

  • beam_fl (PyTree[Float[Array, 'nfreq lp1']]) –

    PyTree with the same structure as the input maps; each leaf has shape (nfreq, lmax+1) and carries the per-frequency beam transfer function for that Stokes component.

Examples:

>>> import jax.numpy as jnp
>>> from furax.obs.stokes import StokesIQU
>>> from furax.obs.operators import BeamOperatorIQU
>>> nside, lmax, nfreq = 16, 31, 2
>>> npix = 12 * nside ** 2
>>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
>>> fl = jnp.ones((nfreq, lmax + 1))
>>> beam_fl = StokesIQU(i=fl, q=fl, u=fl)
>>> op = BeamOperatorIQU(lmax=lmax, beam_fl=beam_fl, in_structure=structure)

Methods:

  • mv

    Apply per-Stokes beams to input sky maps.

  • inverse

    Return a BeamOperatorIQU with per-Stokes reciprocal transfer functions.

Source code in src/furax/obs/operators/_beam_operator.py
@symmetric
class BeamOperatorIQU(AbstractLinearOperator):
    """Beam operator with an independent transfer function per Stokes component.

    Input PyTree leaves are expected to have shape ``(nfreq, npix)``.  Each leaf
    is smoothed with its own transfer function taken from the corresponding leaf
    of ``beam_fl``, enabling different beams for I, Q, and U (or any other
    Stokes combination).

    The operator is symmetric (self-adjoint). Use [`inverse`][] to obtain the
    deconvolution operator (each ``beam_fl`` leaf replaced by its reciprocal).

    Algebraic rule: ``BeamOperatorIQU @ BeamOperatorIQU`` reduces to a single
    operator whose per-leaf transfer functions are the element-wise products of
    both (see `BeamIQURule`).

    Attributes:
        lmax: Maximum spherical harmonic degree.
        beam_fl: PyTree with the same structure as the input maps; each leaf has
            shape ``(nfreq, lmax+1)`` and carries the per-frequency beam transfer
            function for that Stokes component.

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.obs.stokes import StokesIQU
        >>> from furax.obs.operators import BeamOperatorIQU
        >>> nside, lmax, nfreq = 16, 31, 2
        >>> npix = 12 * nside ** 2
        >>> structure = StokesIQU.structure_for((nfreq, npix), jnp.float64)
        >>> fl = jnp.ones((nfreq, lmax + 1))
        >>> beam_fl = StokesIQU(i=fl, q=fl, u=fl)
        >>> op = BeamOperatorIQU(lmax=lmax, beam_fl=beam_fl, in_structure=structure)
    """

    lmax: int
    beam_fl: PyTree[Float[Array, 'nfreq lp1']]

    def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
        """Apply per-Stokes beams to input sky maps.

        Args:
            x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
                for a single-frequency input. The PyTree structure must match that
                of ``self.beam_fl``.

        Returns:
            Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
        """
        first_leaf = jax.tree_util.tree_leaves(x)[0]
        input_is_1d = first_leaf.ndim == 1
        first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
        nside = jhp.npix2nside(first_leaf.shape[-1])

        map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
        alm = map2alm.mv(x)

        alm_beam = jax.tree.map(
            lambda alm_leaf, fl: jhp.almxfl(alm_leaf, fl, healpy_ordering=False),
            alm,
            self.beam_fl,
        )
        out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
        if input_is_1d:
            out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
        return out

    def inverse(self) -> 'BeamOperatorIQU':
        """Return a [`BeamOperatorIQU`][] with per-Stokes reciprocal transfer functions.

        Returns:
            A new [`BeamOperatorIQU`][] whose ``beam_fl`` leaves equal
            ``1 / leaf`` for each leaf in ``self.beam_fl``.
        """
        inv_beam = jax.tree.map(lambda fl: 1.0 / fl, self.beam_fl)
        return BeamOperatorIQU(lmax=self.lmax, beam_fl=inv_beam, in_structure=self.in_structure)

lmax instance-attribute

beam_fl instance-attribute

mv(x)

Apply per-Stokes beams to input sky maps.

Parameters:

  • x (PyTree[Inexact[Array, 'nfreq npix']]) –

    Input PyTree whose leaves have shape (nfreq, npix) or (npix,) for a single-frequency input. The PyTree structure must match that of self.beam_fl.

Returns:

  • PyTree[Inexact[Array, 'nfreq npix']]

    Beam-smoothed PyTree with the same structure and leaf shapes as x.

Source code in src/furax/obs/operators/_beam_operator.py
def mv(self, x: PyTree[Inexact[Array, 'nfreq npix']]) -> PyTree[Inexact[Array, 'nfreq npix']]:
    """Apply per-Stokes beams to input sky maps.

    Args:
        x: Input PyTree whose leaves have shape ``(nfreq, npix)`` or ``(npix,)``
            for a single-frequency input. The PyTree structure must match that
            of ``self.beam_fl``.

    Returns:
        Beam-smoothed PyTree with the same structure and leaf shapes as *x*.
    """
    first_leaf = jax.tree_util.tree_leaves(x)[0]
    input_is_1d = first_leaf.ndim == 1
    first_leaf = jnp.atleast_2d(first_leaf)  # (nfreq, npix)
    nside = jhp.npix2nside(first_leaf.shape[-1])

    map2alm = Map2Alm(lmax=self.lmax, nside=nside, in_structure=self.in_structure)
    alm = map2alm.mv(x)

    alm_beam = jax.tree.map(
        lambda alm_leaf, fl: jhp.almxfl(alm_leaf, fl, healpy_ordering=False),
        alm,
        self.beam_fl,
    )
    out = Alm2Map(lmax=self.lmax, nside=nside, in_structure=map2alm.out_structure).mv(alm_beam)
    if input_is_1d:
        out = jax.tree.map(lambda leaf: jnp.squeeze(leaf, axis=0), out)
    return out

inverse()

Return a BeamOperatorIQU with per-Stokes reciprocal transfer functions.

Returns:

Source code in src/furax/obs/operators/_beam_operator.py
def inverse(self) -> 'BeamOperatorIQU':
    """Return a [`BeamOperatorIQU`][] with per-Stokes reciprocal transfer functions.

    Returns:
        A new [`BeamOperatorIQU`][] whose ``beam_fl`` leaves equal
        ``1 / leaf`` for each leaf in ``self.beam_fl``.
    """
    inv_beam = jax.tree.map(lambda fl: 1.0 / fl, self.beam_fl)
    return BeamOperatorIQU(lmax=self.lmax, beam_fl=inv_beam, in_structure=self.in_structure)

HWPOperator dataclass

Bases: AbstractLinearOperator

Operator for an ideal half-wave plate (HWP).

A HWP flips the sign of the U (and V) Stokes parameters while leaving I and Q unchanged. This models the Mueller matrix diag(1, 1, -1, -1).

The operator is diagonal and symmetric. For a rotating HWP at angle theta, use HWPOperator.create(..., angles=theta) which computes R(-theta) @ HWP @ R(theta).

Algebraic rule: R(theta) @ HWP = HWP @ R(-theta).

Methods:

Source code in src/furax/obs/operators/_hwp.py
@diagonal
class HWPOperator(AbstractLinearOperator):
    """Operator for an ideal half-wave plate (HWP).

    A HWP flips the sign of the U (and V) Stokes parameters while leaving
    I and Q unchanged. This models the Mueller matrix diag(1, 1, -1, -1).

    The operator is diagonal and symmetric. For a rotating HWP at angle theta,
    use ``HWPOperator.create(..., angles=theta)`` which computes R(-theta) @ HWP @ R(theta).

    Algebraic rule: R(theta) @ HWP = HWP @ R(-theta).
    """

    @classmethod
    def create(
        cls,
        shape: tuple[int, ...],
        dtype: DTypeLike = np.float64,
        stokes: ValidStokesType = 'IQU',
        *,
        angles: Float[Array, '...'] | None = None,
    ) -> AbstractLinearOperator:
        in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
        hwp = cls(in_structure=in_structure)
        if angles is None:
            return hwp
        rot = QURotationOperator(angles=angles, in_structure=in_structure)
        rotated_hwp: AbstractLinearOperator = rot.T @ hwp @ rot
        return rotated_hwp

    def mv(self, x: StokesType) -> Stokes:
        if isinstance(x, StokesI):
            return x
        if isinstance(x, StokesQU):
            return StokesQU(x.q, -x.u)
        if isinstance(x, StokesIQU):
            return StokesIQU(x.i, x.q, -x.u)
        if isinstance(x, StokesIQUV):
            return StokesIQUV(x.i, x.q, -x.u, -x.v)
        raise NotImplementedError

create(shape, dtype=np.float64, stokes='IQU', *, angles=None) classmethod

Source code in src/furax/obs/operators/_hwp.py
@classmethod
def create(
    cls,
    shape: tuple[int, ...],
    dtype: DTypeLike = np.float64,
    stokes: ValidStokesType = 'IQU',
    *,
    angles: Float[Array, '...'] | None = None,
) -> AbstractLinearOperator:
    in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
    hwp = cls(in_structure=in_structure)
    if angles is None:
        return hwp
    rot = QURotationOperator(angles=angles, in_structure=in_structure)
    rotated_hwp: AbstractLinearOperator = rot.T @ hwp @ rot
    return rotated_hwp

mv(x)

Source code in src/furax/obs/operators/_hwp.py
def mv(self, x: StokesType) -> Stokes:
    if isinstance(x, StokesI):
        return x
    if isinstance(x, StokesQU):
        return StokesQU(x.q, -x.u)
    if isinstance(x, StokesIQU):
        return StokesIQU(x.i, x.q, -x.u)
    if isinstance(x, StokesIQUV):
        return StokesIQUV(x.i, x.q, -x.u, -x.v)
    raise NotImplementedError

LinearPolarizerOperator dataclass

Bases: AbstractLinearOperator

Operator for an ideal linear polarizer.

Extracts the intensity seen by a linear polarizer aligned with the x-axis: d = 0.5 * (I + Q). For a polarizer at angle psi, use LinearPolarizerOperator.create(..., angles=psi).

This implements: d = 0.5 * (I + Qcos(2psi) + Usin(2psi)).

Algebraic rule: LinearPolarizer @ HWP = LinearPolarizer.

Methods:

Source code in src/furax/obs/operators/_polarizers.py
class LinearPolarizerOperator(AbstractLinearOperator):
    """Operator for an ideal linear polarizer.

    Extracts the intensity seen by a linear polarizer aligned with the x-axis:
    d = 0.5 * (I + Q). For a polarizer at angle psi, use
    ``LinearPolarizerOperator.create(..., angles=psi)``.

    This implements: d = 0.5 * (I + Q*cos(2*psi) + U*sin(2*psi)).

    Algebraic rule: LinearPolarizer @ HWP = LinearPolarizer.
    """

    @classmethod
    def create(
        cls,
        shape: tuple[int, ...],
        dtype: DTypeLike = np.float64,
        stokes: ValidStokesType = 'IQU',
        *,
        angles: Float[Array, '...'] | None = None,
    ) -> AbstractLinearOperator:
        in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
        polarizer = cls(in_structure=in_structure)
        if angles is None:
            return polarizer
        rot = QURotationOperator(angles=angles, in_structure=in_structure)
        rotated_polarizer: AbstractLinearOperator = polarizer @ rot
        return rotated_polarizer

    def mv(self, x: StokesType) -> Float[Array, '...']:
        if isinstance(x, StokesI):
            return 0.5 * x.i
        if isinstance(x, StokesQU):
            return 0.5 * x.q
        return 0.5 * (x.i + x.q)

create(shape, dtype=np.float64, stokes='IQU', *, angles=None) classmethod

Source code in src/furax/obs/operators/_polarizers.py
@classmethod
def create(
    cls,
    shape: tuple[int, ...],
    dtype: DTypeLike = np.float64,
    stokes: ValidStokesType = 'IQU',
    *,
    angles: Float[Array, '...'] | None = None,
) -> AbstractLinearOperator:
    in_structure = Stokes.class_for(stokes).structure_for(shape, dtype)
    polarizer = cls(in_structure=in_structure)
    if angles is None:
        return polarizer
    rot = QURotationOperator(angles=angles, in_structure=in_structure)
    rotated_polarizer: AbstractLinearOperator = polarizer @ rot
    return rotated_polarizer

mv(x)

Source code in src/furax/obs/operators/_polarizers.py
def mv(self, x: StokesType) -> Float[Array, '...']:
    if isinstance(x, StokesI):
        return 0.5 * x.i
    if isinstance(x, StokesQU):
        return 0.5 * x.q
    return 0.5 * (x.i + x.q)

QURotationOperator dataclass

Bases: AbstractLinearOperator

Operator that rotates Q and U Stokes parameters by angle theta.

Applies the rotation matrix R(theta) which transforms (Q, U) as: Q' = Qcos(2theta) + Usin(2theta) U' = -Qsin(2theta) + Ucos(2theta)

I and V components are unchanged. The operator is orthogonal: R.T = R.I = R(-theta).

Consecutive rotations combine: R(a) @ R(b) = R(a+b).

Attributes:

  • angles (Float[Array, ...]) –

    Rotation angles in radians.

  • atomic (bool) –

    If True, prevents this operator from being merged with adjacent QU rotation operators during algebraic reduction.

Methods:

Source code in src/furax/obs/operators/_qu_rotations.py
@orthogonal
class QURotationOperator(AbstractLinearOperator):
    """Operator that rotates Q and U Stokes parameters by angle theta.

    Applies the rotation matrix R(theta) which transforms (Q, U) as:
        Q' = Q*cos(2*theta) + U*sin(2*theta)
        U' = -Q*sin(2*theta) + U*cos(2*theta)

    I and V components are unchanged. The operator is orthogonal:
    R.T = R.I = R(-theta).

    Consecutive rotations combine: R(a) @ R(b) = R(a+b).

    Attributes:
        angles: Rotation angles in radians.
        atomic: If True, prevents this operator from being merged with adjacent
            QU rotation operators during algebraic reduction.
    """

    angles: Float[Array, '...']
    atomic: bool = field(kw_only=True, default=False, metadata={'static': True})

    @classmethod
    def create(
        cls,
        shape: tuple[int, ...],
        dtype: DTypeLike = np.float64,
        stokes: ValidStokesType = 'IQU',
        *,
        angles: Float[Array, '...'],
        atomic: bool = False,
    ) -> AbstractLinearOperator:
        structure = Stokes.class_for(stokes).structure_for(shape, dtype)
        return cls(angles=angles, atomic=atomic, in_structure=structure)

    def mv(self, x: StokesType) -> StokesType:
        return rotate_qu(x, self.angles)  # type: ignore[no-any-return]

    def transpose(self) -> AbstractLinearOperator:
        return QURotationTransposeOperator(operator=self)

angles instance-attribute

atomic = field(kw_only=True, default=False, metadata={'static': True}) class-attribute instance-attribute

create(shape, dtype=np.float64, stokes='IQU', *, angles, atomic=False) classmethod

Source code in src/furax/obs/operators/_qu_rotations.py
@classmethod
def create(
    cls,
    shape: tuple[int, ...],
    dtype: DTypeLike = np.float64,
    stokes: ValidStokesType = 'IQU',
    *,
    angles: Float[Array, '...'],
    atomic: bool = False,
) -> AbstractLinearOperator:
    structure = Stokes.class_for(stokes).structure_for(shape, dtype)
    return cls(angles=angles, atomic=atomic, in_structure=structure)

mv(x)

Source code in src/furax/obs/operators/_qu_rotations.py
def mv(self, x: StokesType) -> StokesType:
    return rotate_qu(x, self.angles)  # type: ignore[no-any-return]

transpose()

Source code in src/furax/obs/operators/_qu_rotations.py
def transpose(self) -> AbstractLinearOperator:
    return QURotationTransposeOperator(operator=self)

AbstractSEDOperator

Bases: AbstractLinearOperator

Abstract base class for Spectral Energy Distribution (SED) operators.

SED operators model how astrophysical components emit radiation across frequencies. They broadcast sky maps to multiple frequency channels.

Subclasses must implement the sed() method to define the spectral energy distribution.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Array of observation frequencies.

Methods:

  • __init__
  • mv
  • sed

    Define the spectral energy distribution transformation.

Source code in src/furax/obs/operators/_seds.py
class AbstractSEDOperator(AbstractLinearOperator):
    """Abstract base class for Spectral Energy Distribution (SED) operators.

    SED operators model how astrophysical components emit radiation across
    frequencies. They broadcast sky maps to multiple frequency channels.

    Subclasses must implement the ``sed()`` method to define the spectral
    energy distribution.

    Attributes:
        frequencies: Array of observation frequencies.
    """

    frequencies: Float[Array, ' a']

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        input_shape = self._get_input_shape(in_structure)
        n_freq = len(frequencies)
        # The operator broadcasts a map (no frequency axis) to a multi-frequency output: the new
        # frequency axis lands just before the trailing spatial axis, with any leading batch axes
        # (e.g. the leading Stokes axis of a single-array Stokes map) broadcasting through.
        n_batch = len(input_shape) - 1
        frequencies = frequencies.reshape((1,) * n_batch + (n_freq, 1))
        object.__setattr__(self, 'frequencies', frequencies)
        object.__setattr__(self, 'in_structure', in_structure)
        # sanity-check shapes at construction time
        _ = jax.eval_shape(self.mv, in_structure)

    def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
        # Insert the frequency axis before the trailing spatial axis, unless already present
        # (e.g. a genuine multi-frequency map passed in directly).
        def func(leaf: Inexact[Array, '...']) -> Inexact[Array, '...']:
            if leaf.ndim < self.frequencies.ndim:
                leaf = jnp.expand_dims(leaf, axis=-2)
            return self.sed() * leaf

        return jax.tree.map(func, x)

    @staticmethod
    def _get_input_shape(in_structure: PyTree[jax.ShapeDtypeStruct]) -> tuple[int, ...]:
        """Determine the shape of the input leaves in the PyTree.

        Args:
            in_structure (PyTree): The PyTree structure.

        Returns:
            tuple[int, ...]: The common shape of the leaves.

        Raises:
            ValueError: If the shapes of the leaves are not consistent.
        """
        input_shapes = set(leaf.shape for leaf in jax.tree.leaves(in_structure))
        if len(input_shapes) != 1:
            raise ValueError(f'the leaves of the input do not have the same shape: {in_structure}')
        return input_shapes.pop()  # type: ignore[no-any-return]

    def _broadcast_over_maps(self, x: Any) -> Float[Array, '...']:
        """Reshape a per-frequency (or scalar) array to broadcast like `frequencies`.

        The frequency axis is placed just before the trailing spatial axis, with batch axes
        broadcasting.
        """
        arr = jnp.asarray(x)
        if arr.ndim == 0:
            return arr.reshape((1,) * self.frequencies.ndim)
        return arr.reshape(self.frequencies.shape)

    @abstractmethod
    def sed(self) -> Float[Array, '...']:
        """Define the spectral energy distribution transformation.

        Returns:
            Float[Array, '...']: The transformed SED.
        """
        ...

    @staticmethod
    def _get_at(
        values: Float[Array, '...'], indices: Int[Array, '...'] | None
    ) -> Float[Array, '...']:
        """Retrieve values at specified indices, or return all values if indices are None.

        Args:
            values (Array): Input array.
            indices (Array | None): Indices to retrieve values from.

        Returns:
            Array: Subset of values or the entire array.
        """
        if indices is None:
            return values
        return values[..., indices]

frequencies instance-attribute

__init__(frequencies, *, in_structure)

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    input_shape = self._get_input_shape(in_structure)
    n_freq = len(frequencies)
    # The operator broadcasts a map (no frequency axis) to a multi-frequency output: the new
    # frequency axis lands just before the trailing spatial axis, with any leading batch axes
    # (e.g. the leading Stokes axis of a single-array Stokes map) broadcasting through.
    n_batch = len(input_shape) - 1
    frequencies = frequencies.reshape((1,) * n_batch + (n_freq, 1))
    object.__setattr__(self, 'frequencies', frequencies)
    object.__setattr__(self, 'in_structure', in_structure)
    # sanity-check shapes at construction time
    _ = jax.eval_shape(self.mv, in_structure)

mv(x)

Source code in src/furax/obs/operators/_seds.py
def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
    # Insert the frequency axis before the trailing spatial axis, unless already present
    # (e.g. a genuine multi-frequency map passed in directly).
    def func(leaf: Inexact[Array, '...']) -> Inexact[Array, '...']:
        if leaf.ndim < self.frequencies.ndim:
            leaf = jnp.expand_dims(leaf, axis=-2)
        return self.sed() * leaf

    return jax.tree.map(func, x)

sed() abstractmethod

Define the spectral energy distribution transformation.

Returns:

  • Float[Array, ...]

    Float[Array, '...']: The transformed SED.

Source code in src/furax/obs/operators/_seds.py
@abstractmethod
def sed(self) -> Float[Array, '...']:
    """Define the spectral energy distribution transformation.

    Returns:
        Float[Array, '...']: The transformed SED.
    """
    ...

CMBOperator

Bases: AbstractSEDOperator

Operator for Cosmic Microwave Background (CMB) spectral energy distribution.

The CMB has a blackbody spectrum at T_CMB ~ 2.725 K. In K_CMB units, the SED is constant (unity) across frequencies. In K_RJ units, a frequency-dependent conversion factor is applied.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Observation frequencies [GHz].

  • units (str) –

    Output units ('K_CMB' or 'K_RJ').

  • factor (Float[Array, ...] | float) –

    Unit conversion factor.

Examples:

>>> nu = jnp.array([30, 40, 100])  # GHz
>>> cmb_op = CMBOperator(frequencies=nu, in_structure=landscape.structure)
>>> tod = cmb_op(sky_map)  # Broadcasts CMB map to all frequencies

Methods:

  • __init__
  • sed

    Compute the spectral energy distribution for the CMB.

Source code in src/furax/obs/operators/_seds.py
class CMBOperator(AbstractSEDOperator):
    """Operator for Cosmic Microwave Background (CMB) spectral energy distribution.

    The CMB has a blackbody spectrum at T_CMB ~ 2.725 K. In K_CMB units, the
    SED is constant (unity) across frequencies. In K_RJ units, a frequency-dependent
    conversion factor is applied.

    Attributes:
        frequencies: Observation frequencies [GHz].
        units: Output units ('K_CMB' or 'K_RJ').
        factor: Unit conversion factor.

    Examples:
        >>> nu = jnp.array([30, 40, 100])  # GHz
        >>> cmb_op = CMBOperator(frequencies=nu, in_structure=landscape.structure)
        >>> tod = cmb_op(sky_map)  # Broadcasts CMB map to all frequencies
    """

    factor: Float[Array, '...'] | float
    units: str = field(metadata={'static': True})

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct],
        units: str = 'K_CMB',
    ) -> None:
        factor: Float[Array, ...] | float
        if units == 'K_CMB':
            factor = 1.0
        elif units == 'K_RJ':
            factor = K_RK_2_K_CMB(frequencies)
        else:
            raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
        object.__setattr__(self, 'factor', factor)
        object.__setattr__(self, 'units', units)
        super().__init__(frequencies, in_structure=in_structure)

    def sed(self) -> Float[Array, '...']:
        """Compute the spectral energy distribution for the CMB.

        Returns:
            Float[Array, '...']: The SED for the CMB.
        """
        return jnp.ones_like(self.frequencies) / self._broadcast_over_maps(self.factor)

factor instance-attribute

units = field(metadata={'static': True}) class-attribute instance-attribute

__init__(frequencies, *, in_structure, units='K_CMB')

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct],
    units: str = 'K_CMB',
) -> None:
    factor: Float[Array, ...] | float
    if units == 'K_CMB':
        factor = 1.0
    elif units == 'K_RJ':
        factor = K_RK_2_K_CMB(frequencies)
    else:
        raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
    object.__setattr__(self, 'factor', factor)
    object.__setattr__(self, 'units', units)
    super().__init__(frequencies, in_structure=in_structure)

sed()

Compute the spectral energy distribution for the CMB.

Returns:

  • Float[Array, ...]

    Float[Array, '...']: The SED for the CMB.

Source code in src/furax/obs/operators/_seds.py
def sed(self) -> Float[Array, '...']:
    """Compute the spectral energy distribution for the CMB.

    Returns:
        Float[Array, '...']: The SED for the CMB.
    """
    return jnp.ones_like(self.frequencies) / self._broadcast_over_maps(self.factor)

DustOperator

Bases: AbstractSEDOperator

Operator for thermal dust spectral energy distribution.

Models dust emission as a modified blackbody: a power law times a Planck function. The SED is: (nu/nu0)^(1+beta) * B(nu,T)/B(nu0,T), where B is the Planck function.

Supports spatially varying spectral parameters via patch indices.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Observation frequencies [GHz].

  • frequency0 (float) –

    Reference frequency [GHz].

  • temperature (Float[Array, ...]) –

    Dust temperature [K].

  • beta (Float[Array, ...]) –

    Spectral index (typically ~1.5).

  • units (str) –

    Output units ('K_CMB' or 'K_RJ').

Examples:

>>> nu = jnp.array([100, 143, 217, 353])  # GHz
>>> dust_op = DustOperator(
...     frequencies=nu, frequency0=353, beta=1.54, temperature=20.0,
...     in_structure=landscape.structure
... )
>>> tod = dust_op(dust_map)

Methods:

Source code in src/furax/obs/operators/_seds.py
class DustOperator(AbstractSEDOperator):
    """Operator for thermal dust spectral energy distribution.

    Models dust emission as a modified blackbody: a power law times a Planck
    function. The SED is: (nu/nu0)^(1+beta) * B(nu,T)/B(nu0,T), where B is
    the Planck function.

    Supports spatially varying spectral parameters via patch indices.

    Attributes:
        frequencies: Observation frequencies [GHz].
        frequency0: Reference frequency [GHz].
        temperature: Dust temperature [K].
        beta: Spectral index (typically ~1.5).
        units: Output units ('K_CMB' or 'K_RJ').

    Examples:
        >>> nu = jnp.array([100, 143, 217, 353])  # GHz
        >>> dust_op = DustOperator(
        ...     frequencies=nu, frequency0=353, beta=1.54, temperature=20.0,
        ...     in_structure=landscape.structure
        ... )
        >>> tod = dust_op(dust_map)
    """

    temperature: Float[Array, '...']
    temperature_patch_indices: Int[Array, '...'] | None
    beta: Float[Array, '...']
    beta_patch_indices: Int[Array, '...'] | None
    factor: Float[Array, '...'] | float
    units: str = field(metadata={'static': True})
    frequency0: float = field(metadata={'static': True})

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        frequency0: float = 100,
        temperature: float | Float[Array, '...'],
        units: str = 'K_CMB',
        temperature_patch_indices: Int[Array, '...'] | None = None,
        beta: float | Float[Array, '...'],
        beta_patch_indices: Int[Array, '...'] | None = None,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        factor: Float[Array, ...] | float
        if units == 'K_CMB':
            factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
        elif units == 'K_RJ':
            factor = 1.0
        else:
            raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
        object.__setattr__(self, 'temperature', jnp.asarray(temperature))
        object.__setattr__(self, 'temperature_patch_indices', temperature_patch_indices)
        object.__setattr__(self, 'beta', jnp.asarray(beta))
        object.__setattr__(self, 'beta_patch_indices', beta_patch_indices)
        object.__setattr__(self, 'units', units)
        object.__setattr__(self, 'frequency0', frequency0)
        object.__setattr__(self, 'factor', factor)
        super().__init__(frequencies, in_structure=in_structure)

    def sed(self) -> Float[Array, '...']:
        t = self._get_at(
            jnp.expm1(self.frequency0 / self.temperature * _H_OVER_K_GHZ)
            / jnp.expm1(self.frequencies / self.temperature * _H_OVER_K_GHZ),
            self.temperature_patch_indices,
        )
        b = self._get_at(
            (self.frequencies / self.frequency0) ** (1 + self.beta), self.beta_patch_indices
        )
        sed = (t * b) * self._broadcast_over_maps(self.factor)
        return sed

temperature instance-attribute

temperature_patch_indices instance-attribute

beta instance-attribute

beta_patch_indices instance-attribute

factor instance-attribute

units = field(metadata={'static': True}) class-attribute instance-attribute

frequency0 = field(metadata={'static': True}) class-attribute instance-attribute

__init__(frequencies, *, frequency0=100, temperature, units='K_CMB', temperature_patch_indices=None, beta, beta_patch_indices=None, in_structure)

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    frequency0: float = 100,
    temperature: float | Float[Array, '...'],
    units: str = 'K_CMB',
    temperature_patch_indices: Int[Array, '...'] | None = None,
    beta: float | Float[Array, '...'],
    beta_patch_indices: Int[Array, '...'] | None = None,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    factor: Float[Array, ...] | float
    if units == 'K_CMB':
        factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
    elif units == 'K_RJ':
        factor = 1.0
    else:
        raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
    object.__setattr__(self, 'temperature', jnp.asarray(temperature))
    object.__setattr__(self, 'temperature_patch_indices', temperature_patch_indices)
    object.__setattr__(self, 'beta', jnp.asarray(beta))
    object.__setattr__(self, 'beta_patch_indices', beta_patch_indices)
    object.__setattr__(self, 'units', units)
    object.__setattr__(self, 'frequency0', frequency0)
    object.__setattr__(self, 'factor', factor)
    super().__init__(frequencies, in_structure=in_structure)

sed()

Source code in src/furax/obs/operators/_seds.py
def sed(self) -> Float[Array, '...']:
    t = self._get_at(
        jnp.expm1(self.frequency0 / self.temperature * _H_OVER_K_GHZ)
        / jnp.expm1(self.frequencies / self.temperature * _H_OVER_K_GHZ),
        self.temperature_patch_indices,
    )
    b = self._get_at(
        (self.frequencies / self.frequency0) ** (1 + self.beta), self.beta_patch_indices
    )
    sed = (t * b) * self._broadcast_over_maps(self.factor)
    return sed

NoiseDiagonalOperator dataclass

Bases: AbstractLinearOperator

Constructs a diagonal noise operator.

This operator applies a noise vector (in a PyTree structure) in an element‐wise multiplication to an input data PyTree.

Attributes:

  • vector (PyTree[Inexact[Array, ...]]) –

    PyTree of arrays representing the noise values.

  • in_structure (PyTree[ShapeDtypeStruct]) –

    Input structure (PyTree[jax.ShapeDtypeStruct]) specifying the shape and dtype.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax.obs.landscapes import FrequencyLandscape
>>> from furax.obs.operators import NoiseDiagonalOperator
>>>
>>> landscape = FrequencyLandscape(nside=64, frequencies=jnp.linspace(30, 300, 10))
>>> noise_sample = landscape.normal(jax.random.key(0))  # small n
>>> d = landscape.normal(jax.random.key(0))  # d
>>> N = NoiseDiagonalOperator(noise_sample, in_structure=d.structure)
>>> N.I(d).structure
StokesIQU(ShapeDtypeStruct(shape=(3, 10, 49152), dtype=float64))

Methods:

Source code in src/furax/obs/operators/_seds.py
@deprecated('Should use a DiagonalOperator')
@diagonal
class NoiseDiagonalOperator(AbstractLinearOperator):
    """Constructs a diagonal noise operator.

    This operator applies a noise vector (in a PyTree structure) in an element‐wise
    multiplication to an input data PyTree.

    Attributes:
        vector: PyTree of arrays representing the noise values.
        in_structure: Input structure (PyTree[jax.ShapeDtypeStruct]) specifying the shape and dtype.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax.obs.landscapes import FrequencyLandscape
        >>> from furax.obs.operators import NoiseDiagonalOperator
        >>>
        >>> landscape = FrequencyLandscape(nside=64, frequencies=jnp.linspace(30, 300, 10))
        >>> noise_sample = landscape.normal(jax.random.key(0))  # small n
        >>> d = landscape.normal(jax.random.key(0))  # d
        >>> N = NoiseDiagonalOperator(noise_sample, in_structure=d.structure)
        >>> N.I(d).structure
        StokesIQU(ShapeDtypeStruct(shape=(3, 10, 49152), dtype=float64))
    """

    vector: PyTree[Inexact[Array, '...']]

    def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
        return jax.tree.map(lambda v, leaf: v * leaf, self.vector, x)

    def inverse(self) -> AbstractLinearOperator:
        return NoiseDiagonalOperator(vector=1 / self.vector, in_structure=self.in_structure)

    def as_matrix(self) -> Any:
        return jax.tree.map(lambda x: jnp.diag(x.flatten()), self.vector)

vector instance-attribute

mv(x)

Source code in src/furax/obs/operators/_seds.py
def mv(self, x: PyTree[Inexact[Array, '...']]) -> PyTree[Inexact[Array, '...']]:
    return jax.tree.map(lambda v, leaf: v * leaf, self.vector, x)

inverse()

Source code in src/furax/obs/operators/_seds.py
def inverse(self) -> AbstractLinearOperator:
    return NoiseDiagonalOperator(vector=1 / self.vector, in_structure=self.in_structure)

as_matrix()

Source code in src/furax/obs/operators/_seds.py
def as_matrix(self) -> Any:
    return jax.tree.map(lambda x: jnp.diag(x.flatten()), self.vector)

SynchrotronOperator

Bases: AbstractSEDOperator

Operator for synchrotron spectral energy distribution.

Models synchrotron emission as a power law: (nu/nu0)^beta, with optional spectral index running: (nu/nu0)^(beta + running * log(nu/nu_pivot)).

Supports spatially varying spectral parameters via patch indices.

Attributes:

  • frequencies (Float[Array, ' a']) –

    Observation frequencies [GHz].

  • frequency0 (float) –

    Reference frequency [GHz].

  • beta_pl (Float[Array, ...]) –

    Power-law spectral index (typically ~ -3).

  • nu_pivot (float) –

    Pivot frequency for running [GHz].

  • running (float) –

    Running of the spectral index.

  • units (str) –

    Output units ('K_CMB' or 'K_RJ').

Examples:

>>> nu = jnp.array([30, 44, 70])  # GHz
>>> sync_op = SynchrotronOperator(
...     frequencies=nu, frequency0=30, beta_pl=-3.0,
...     in_structure=landscape.structure
... )
>>> tod = sync_op(synchrotron_map)

Methods:

Source code in src/furax/obs/operators/_seds.py
class SynchrotronOperator(AbstractSEDOperator):
    """Operator for synchrotron spectral energy distribution.

    Models synchrotron emission as a power law: (nu/nu0)^beta, with optional
    spectral index running: (nu/nu0)^(beta + running * log(nu/nu_pivot)).

    Supports spatially varying spectral parameters via patch indices.

    Attributes:
        frequencies: Observation frequencies [GHz].
        frequency0: Reference frequency [GHz].
        beta_pl: Power-law spectral index (typically ~ -3).
        nu_pivot: Pivot frequency for running [GHz].
        running: Running of the spectral index.
        units: Output units ('K_CMB' or 'K_RJ').

    Examples:
        >>> nu = jnp.array([30, 44, 70])  # GHz
        >>> sync_op = SynchrotronOperator(
        ...     frequencies=nu, frequency0=30, beta_pl=-3.0,
        ...     in_structure=landscape.structure
        ... )
        >>> tod = sync_op(synchrotron_map)
    """

    beta_pl: Float[Array, '...']
    beta_pl_patch_indices: Int[Array, '...'] | None
    nu_pivot: float = field(metadata={'static': True})
    running: float = field(metadata={'static': True})
    units: str = field(metadata={'static': True})
    frequency0: float = field(metadata={'static': True})
    factor: Float[Array, '...'] | float

    def __init__(
        self,
        frequencies: Float[Array, '...'],
        *,
        frequency0: float = 100,
        nu_pivot: float = 1.0,
        running: float = 0.0,
        units: str = 'K_CMB',
        beta_pl: float | Float[Array, '...'],
        beta_pl_patch_indices: Int[Array, '...'] | None = None,
        in_structure: PyTree[jax.ShapeDtypeStruct],
    ) -> None:
        factor: Float[Array, ...] | float
        if units == 'K_CMB':
            factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
        elif units == 'K_RJ':
            factor = 1.0
        else:
            raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
        object.__setattr__(self, 'beta_pl', jnp.asarray(beta_pl))
        object.__setattr__(self, 'beta_pl_patch_indices', beta_pl_patch_indices)
        object.__setattr__(self, 'nu_pivot', nu_pivot)
        object.__setattr__(self, 'running', running)
        object.__setattr__(self, 'units', units)
        object.__setattr__(self, 'frequency0', frequency0)
        object.__setattr__(self, 'factor', factor)
        super().__init__(frequencies, in_structure=in_structure)

    def sed(self) -> Float[Array, '...']:
        sed = self._get_at(
            (
                (self.frequencies / self.frequency0)
                ** (self.beta_pl + self.running * jnp.log(self.frequencies / self.nu_pivot))
            ),
            self.beta_pl_patch_indices,
        )

        sed = self._get_at(
            (self.frequencies / self.frequency0) ** self.beta_pl, self.beta_pl_patch_indices
        )
        sed *= self._broadcast_over_maps(self.factor)

        return sed

beta_pl instance-attribute

beta_pl_patch_indices instance-attribute

nu_pivot = field(metadata={'static': True}) class-attribute instance-attribute

running = field(metadata={'static': True}) class-attribute instance-attribute

units = field(metadata={'static': True}) class-attribute instance-attribute

frequency0 = field(metadata={'static': True}) class-attribute instance-attribute

factor instance-attribute

__init__(frequencies, *, frequency0=100, nu_pivot=1.0, running=0.0, units='K_CMB', beta_pl, beta_pl_patch_indices=None, in_structure)

Source code in src/furax/obs/operators/_seds.py
def __init__(
    self,
    frequencies: Float[Array, '...'],
    *,
    frequency0: float = 100,
    nu_pivot: float = 1.0,
    running: float = 0.0,
    units: str = 'K_CMB',
    beta_pl: float | Float[Array, '...'],
    beta_pl_patch_indices: Int[Array, '...'] | None = None,
    in_structure: PyTree[jax.ShapeDtypeStruct],
) -> None:
    factor: Float[Array, ...] | float
    if units == 'K_CMB':
        factor = K_RK_2_K_CMB(frequencies) / K_RK_2_K_CMB(frequency0)
    elif units == 'K_RJ':
        factor = 1.0
    else:
        raise ValueError(f"Unknown units: {units}. Expected 'K_CMB' or 'K_RJ'.")
    object.__setattr__(self, 'beta_pl', jnp.asarray(beta_pl))
    object.__setattr__(self, 'beta_pl_patch_indices', beta_pl_patch_indices)
    object.__setattr__(self, 'nu_pivot', nu_pivot)
    object.__setattr__(self, 'running', running)
    object.__setattr__(self, 'units', units)
    object.__setattr__(self, 'frequency0', frequency0)
    object.__setattr__(self, 'factor', factor)
    super().__init__(frequencies, in_structure=in_structure)

sed()

Source code in src/furax/obs/operators/_seds.py
def sed(self) -> Float[Array, '...']:
    sed = self._get_at(
        (
            (self.frequencies / self.frequency0)
            ** (self.beta_pl + self.running * jnp.log(self.frequencies / self.nu_pivot))
        ),
        self.beta_pl_patch_indices,
    )

    sed = self._get_at(
        (self.frequencies / self.frequency0) ** self.beta_pl, self.beta_pl_patch_indices
    )
    sed *= self._broadcast_over_maps(self.factor)

    return sed

MixingMatrixOperator(**blocks)

Constructs a mixing matrix operator from a set of SED operators.

This function combines multiple spectral energy distribution (SED) operators into a single block row operator for use in linear models.

Parameters:

  • **blocks (AbstractSEDOperator, default: {} ) –

    Named SED operators to combine into the mixing matrix.

Returns:

  • BlockRowOperator ( AbstractLinearOperator ) –

    A reduced block row operator representing the mixing matrix.

Examples:

>>> from furax.obs import CMBOperator, DustOperator,             SynchrotronOperator, MixingMatrixOperator
>>> nu = jnp.array([30, 40, 100])  # Frequencies in GHz
>>> in_structure = ...  # Define input structure (e.g., using HealpixLandscape)
>>> sky_map = ...  # Define sky map
>>> cmb = CMBOperator(nu, in_structure=in_structure)
>>> dust = DustOperator(
...     nu,
...     frequency0=150.0,
...     temperature=20.0,
...     beta=1.54,
...     in_structure=in_structure
... )
>>> synchrotron = SynchrotronOperator(
...     nu,
...     frequency0=20.0,
...     beta_pl=-3.0,
...     in_structure=in_structure
... )
>>> A = MixingMatrixOperator(cmb=cmb, dust=dust, synchrotron=synchrotron)
>>> d = A(sky_map)
Source code in src/furax/obs/operators/_seds.py
def MixingMatrixOperator(**blocks: AbstractSEDOperator) -> AbstractLinearOperator:
    """Constructs a mixing matrix operator from a set of SED operators.

    This function combines multiple spectral energy distribution (SED) operators
    into a single block row operator for use in linear models.

    Args:
        **blocks: Named SED operators to combine into the mixing matrix.

    Returns:
        BlockRowOperator: A reduced block row operator representing the mixing matrix.

    Examples:
        >>> from furax.obs import CMBOperator, DustOperator,\
             SynchrotronOperator, MixingMatrixOperator
        >>> nu = jnp.array([30, 40, 100])  # Frequencies in GHz
        >>> in_structure = ...  # Define input structure (e.g., using HealpixLandscape)
        >>> sky_map = ...  # Define sky map
        >>> cmb = CMBOperator(nu, in_structure=in_structure)
        >>> dust = DustOperator(
        ...     nu,
        ...     frequency0=150.0,
        ...     temperature=20.0,
        ...     beta=1.54,
        ...     in_structure=in_structure
        ... )
        >>> synchrotron = SynchrotronOperator(
        ...     nu,
        ...     frequency0=20.0,
        ...     beta_pl=-3.0,
        ...     in_structure=in_structure
        ... )
        >>> A = MixingMatrixOperator(cmb=cmb, dust=dust, synchrotron=synchrotron)
        >>> d = A(sky_map)
    """
    return BlockRowOperator(blocks).reduce()

furax.obs.pointing

Classes:

  • PointingOperator

    Operator that projects sky maps to time-ordered data (TOD) using quaternion pointing.

PointingOperator dataclass

Bases: AbstractLinearOperator

Operator that projects sky maps to time-ordered data (TOD) using quaternion pointing.

Equivalent to: QURotation @ Index @ Ravel, but computed on-the-fly to save memory. For each detector and time sample, it: 1. Computes the sky pixel from boresight and detector quaternions 2. Samples the sky map at that pixel 3. Rotates Stokes QU by the polarization angle

The transpose accumulates TOD into a sky map (binning).

Attributes:

  • landscape (StokesLandscape) –

    The sky pixelization (HEALPix landscape).

  • qbore (Float[Array, 'samp 4']) –

    Boresight quaternions, shape (n_samples, 4).

  • qdet (Float[Array, 'det 4']) –

    Detector quaternions, shape (n_detectors, 4).

  • batch_size (int) –

    Detector batch size (memory/speed tradeoff; see jax.lax.map documentation).

Methods:

  • create
  • mv

    Performs the 'un-pointing' operation, i.e. map->tod.

  • as_stokes_i

    Return a copy of this operator restricted to StokesI.

  • as_expanded_operator

    Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

  • transpose
Source code in src/furax/obs/pointing.py
class PointingOperator(AbstractLinearOperator):
    """Operator that projects sky maps to time-ordered data (TOD) using quaternion pointing.

    Equivalent to: QURotation @ Index @ Ravel, but computed on-the-fly to save memory.
    For each detector and time sample, it:
    1. Computes the sky pixel from boresight and detector quaternions
    2. Samples the sky map at that pixel
    3. Rotates Stokes QU by the polarization angle

    The transpose accumulates TOD into a sky map (binning).

    Attributes:
        landscape: The sky pixelization (HEALPix landscape).
        qbore: Boresight quaternions, shape (n_samples, 4).
        qdet: Detector quaternions, shape (n_detectors, 4).
        batch_size: Detector batch size (memory/speed tradeoff; see jax.lax.map documentation).
    """

    landscape: StokesLandscape
    qbore: Float[Array, 'samp 4']
    qdet: Float[Array, 'det 4']
    batch_size: int = field(metadata={'static': True})
    interpolate: bool = field(metadata={'static': True})
    _out_structure: PyTree[jax.ShapeDtypeStruct] = field(metadata={'static': True})

    @classmethod
    def create(
        cls,
        landscape: StokesLandscape,
        boresight_quaternions: Float[Array, 'samp 4'],
        detector_quaternions: Float[Array, 'det 4'],
        *,
        batch_size: int = 32,
        frame: Literal['boresight', 'detector'] = 'boresight',
        interpolate: bool = False,
    ) -> 'PointingOperator':
        # Explicitly determine the output structure
        ndet = detector_quaternions.shape[0]
        nsamp = boresight_quaternions.shape[0]
        out_structure = Stokes.class_for(landscape.stokes).structure_for(
            (ndet, nsamp), dtype=landscape.dtype
        )

        # In boresight frame, strip the z-rotation (gamma) from each detector quaternion.
        # This absorbs the frame correction into qdet so that _get_cos_sin_angles always
        # works the same way, regardless of frame. Pixel indices are unaffected because
        # a z-rotation does not change the direction of the boresight (z) axis.
        #
        # NB: the xieta parametrization is incomplete and cannot describe all rotations.
        # Thus converting to xieta and back (with gamma=0) may not work in full generality.
        # This approach is more general and just as efficient.
        if frame == 'boresight':
            gamma = to_gamma_angles(detector_quaternions)
            q_z_neg = euler(2, -gamma)  # z-rotation by -gamma
            detector_quaternions = qmul(detector_quaternions, q_z_neg)

        return cls(
            landscape,
            qbore=boresight_quaternions,
            qdet=detector_quaternions,
            batch_size=batch_size,
            interpolate=interpolate,
            in_structure=landscape.structure,
            _out_structure=out_structure,
        )

    @jit
    def mv(self, x: StokesType) -> StokesType:
        """Performs the 'un-pointing' operation, i.e. map->tod."""
        x_flat = x.ravel()

        def mv_inner(qdet: Float[Array, ' 4']) -> StokesType:
            # Expand one detector's quaternion from boresight and offset: (samp, 4)
            qdet_full = qmul(self.qbore, qdet)

            tod = self._sample(x_flat, qdet_full)
            tod = self._modulate(tod, qdet_full)

            if isinstance(tod, StokesI):
                # no rotation needed
                return tod

            # Return the rotated Stokes parameters
            cos_angles, sin_angles = to_polarization_angle_cos_sin(qdet_full)
            return rotate_qu_cs(tod, cos_angles, sin_angles)  # type: ignore[no-any-return]

        tod_out: StokesType = lax.map(mv_inner, self.qdet, batch_size=self.batch_size)
        # lax.map stacks the new detector axis at position 0
        # so move it back: (det, n_stokes, samp) -> (n_stokes, det, samp).
        return type(tod_out).from_array(jnp.moveaxis(tod_out.data, 0, 1))

    def as_stokes_i(self, *, interpolate: bool | None = None) -> 'PointingOperator':
        """Return a copy of this operator restricted to StokesI.

        Args:
            interpolate: Override the interpolation flag.  If ``None`` (default),
                the flag is inherited from ``self.interpolate``.
        """
        effective_interpolate = self.interpolate if interpolate is None else interpolate
        if self.landscape.stokes == 'I' and effective_interpolate == self.interpolate:
            return self
        landscape = copy.copy(self.landscape)
        landscape.stokes = 'I'
        ndet, nsamp = self.qdet.shape[0], self.qbore.shape[0]
        out_structure = StokesI.structure_for((ndet, nsamp), dtype=landscape.dtype)
        return PointingOperator(
            landscape,
            qbore=self.qbore,
            qdet=self.qdet,
            batch_size=self.batch_size,
            interpolate=effective_interpolate,
            in_structure=landscape.structure,
            _out_structure=out_structure,
        )

    def as_expanded_operator(self) -> AbstractLinearOperator:
        """Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

        Equivalent to mv() but as an explicit composition, useful for testing.
        """
        if self.interpolate:
            raise NotImplementedError('as_expanded_operator does not support interpolate=True')
        qdet_full = qmul(self.qbore, self.qdet[:, None, :])
        indices = self._quat2index(qdet_full)
        # Ravel the spatial axes only; the Stokes container's backing array carries a leading
        # Stokes axis (axis 0) that must survive, so ravel axes 1..-1 and index the pixel axis last.
        ravel_op = RavelOperator(1, -1, in_structure=self.landscape.structure)
        index_op = IndexOperator((..., indices), in_structure=ravel_op.out_structure)
        pa = to_polarization_angle(qdet_full)
        qu_rot_op = QURotationOperator(angles=pa, in_structure=index_op.out_structure)
        return qu_rot_op @ index_op @ ravel_op

    @property
    def out_structure(self) -> PyTree[jax.ShapeDtypeStruct]:
        return self._out_structure

    def _quat2index(self, qdet_full: Float[Array, '*dims 4']) -> Array:
        """Convert full detector quaternions to flat pixel indices.

        Override in subclasses to change the pointing-to-index mapping.
        """
        return self.landscape.quat2index(qdet_full)

    def _quat2interp(self, qdet_full: Float[Array, '*dims 4']) -> tuple[Array, Array]:
        """Convert full detector quaternions to (indices, weights) for interpolation.

        Override in subclasses to change the pointing-to-index mapping.
        """
        return self.landscape.quat2interp(qdet_full)

    def _modulate(self, tod: StokesType, qdet_full: Float[Array, '*dims 4']) -> StokesType:
        """Hook applied to the sampled TOD (identity in the base class).

        Subclasses override this to inject a per-sample diagonal weighting. Because the
        weighting is a symmetric diagonal, the same hook is applied in mv (after sampling)
        and in the transpose (before binning), keeping the adjoint exact.
        """
        return tod

    def _sample(self, x_flat: StokesType, qdet_full: Float[Array, '*dims 4']) -> StokesType:
        """Sample the flat map at positions given by qdet_full."""
        if not self.interpolate:
            return x_flat[self._quat2index(qdet_full)]

        indices, weights = self._quat2interp(qdet_full)
        # Zero out contributions from out-of-bounds pixels (index == -1)
        # pixel index 0 is guaranteed to exist, and weight is zeroed simultaneously
        valid = indices >= 0
        indices = jnp.where(valid, indices, 0)
        weights = jnp.where(valid, weights, 0.0)
        weight_sum = weights.sum(axis=-1, keepdims=True)
        unit_weights = weights / jnp.where(weight_sum > 0, weight_sum, 1.0)
        # leading Stokes axis: index the (trailing) pixel axis and sum over the neighbour axis (-1);
        # the weights broadcast over the leading Stokes axis for free.
        sampled = jnp.sum(x_flat.data[:, indices] * unit_weights, axis=-1)
        return type(x_flat).from_array(sampled)

    def _bin(self, tod_batch: StokesType, qdet_full: Float[Array, '*dims 4']) -> StokesType:
        """Scatter-add a batch of TOD into a sky map."""
        sky_shape = self.landscape.shape
        n_pixels = int(np.prod(sky_shape))
        # scatter-add per pixel while keeping the leading Stokes axis of the backing array.
        arr = tod_batch.data  # (n_stokes, *det_sample)
        n_stokes = arr.shape[0]
        zeros = jnp.zeros((n_stokes, n_pixels), self.landscape.dtype)

        if not self.interpolate:
            flat_pixels = self._quat2index(qdet_full).ravel()
            binned = zeros.at[:, flat_pixels].add(arr.reshape(n_stokes, -1))
            return type(tod_batch).from_array(binned.reshape(n_stokes, *sky_shape))

        indices, weights = self._quat2interp(qdet_full)
        valid = indices >= 0
        safe_indices = jnp.where(valid, indices, 0)
        valid_weights = jnp.where(valid, weights, jnp.zeros_like(weights))
        weight_sum = valid_weights.sum(axis=-1, keepdims=True)
        valid_weights = valid_weights / jnp.where(weight_sum > 0, weight_sum, 1.0)
        flat_indices = safe_indices.ravel()
        # (n_stokes, *det_sample, n_nb): spread each sample over its neighbours (weights broadcast
        # over the leading Stokes axis for free).
        contrib = arr[..., None] * valid_weights
        binned = zeros.at[:, flat_indices].add(contrib.reshape(n_stokes, -1))
        return type(tod_batch).from_array(binned.reshape(n_stokes, *sky_shape))

    def transpose(self) -> AbstractLinearOperator:
        return PointingTransposeOperator(operator=self)

landscape instance-attribute

qbore instance-attribute

qdet instance-attribute

batch_size = field(metadata={'static': True}) class-attribute instance-attribute

interpolate = field(metadata={'static': True}) class-attribute instance-attribute

out_structure property

create(landscape, boresight_quaternions, detector_quaternions, *, batch_size=32, frame='boresight', interpolate=False) classmethod

Source code in src/furax/obs/pointing.py
@classmethod
def create(
    cls,
    landscape: StokesLandscape,
    boresight_quaternions: Float[Array, 'samp 4'],
    detector_quaternions: Float[Array, 'det 4'],
    *,
    batch_size: int = 32,
    frame: Literal['boresight', 'detector'] = 'boresight',
    interpolate: bool = False,
) -> 'PointingOperator':
    # Explicitly determine the output structure
    ndet = detector_quaternions.shape[0]
    nsamp = boresight_quaternions.shape[0]
    out_structure = Stokes.class_for(landscape.stokes).structure_for(
        (ndet, nsamp), dtype=landscape.dtype
    )

    # In boresight frame, strip the z-rotation (gamma) from each detector quaternion.
    # This absorbs the frame correction into qdet so that _get_cos_sin_angles always
    # works the same way, regardless of frame. Pixel indices are unaffected because
    # a z-rotation does not change the direction of the boresight (z) axis.
    #
    # NB: the xieta parametrization is incomplete and cannot describe all rotations.
    # Thus converting to xieta and back (with gamma=0) may not work in full generality.
    # This approach is more general and just as efficient.
    if frame == 'boresight':
        gamma = to_gamma_angles(detector_quaternions)
        q_z_neg = euler(2, -gamma)  # z-rotation by -gamma
        detector_quaternions = qmul(detector_quaternions, q_z_neg)

    return cls(
        landscape,
        qbore=boresight_quaternions,
        qdet=detector_quaternions,
        batch_size=batch_size,
        interpolate=interpolate,
        in_structure=landscape.structure,
        _out_structure=out_structure,
    )

mv(x)

Performs the 'un-pointing' operation, i.e. map->tod.

Source code in src/furax/obs/pointing.py
@jit
def mv(self, x: StokesType) -> StokesType:
    """Performs the 'un-pointing' operation, i.e. map->tod."""
    x_flat = x.ravel()

    def mv_inner(qdet: Float[Array, ' 4']) -> StokesType:
        # Expand one detector's quaternion from boresight and offset: (samp, 4)
        qdet_full = qmul(self.qbore, qdet)

        tod = self._sample(x_flat, qdet_full)
        tod = self._modulate(tod, qdet_full)

        if isinstance(tod, StokesI):
            # no rotation needed
            return tod

        # Return the rotated Stokes parameters
        cos_angles, sin_angles = to_polarization_angle_cos_sin(qdet_full)
        return rotate_qu_cs(tod, cos_angles, sin_angles)  # type: ignore[no-any-return]

    tod_out: StokesType = lax.map(mv_inner, self.qdet, batch_size=self.batch_size)
    # lax.map stacks the new detector axis at position 0
    # so move it back: (det, n_stokes, samp) -> (n_stokes, det, samp).
    return type(tod_out).from_array(jnp.moveaxis(tod_out.data, 0, 1))

as_stokes_i(*, interpolate=None)

Return a copy of this operator restricted to StokesI.

Parameters:

  • interpolate (bool | None, default: None ) –

    Override the interpolation flag. If None (default), the flag is inherited from self.interpolate.

Source code in src/furax/obs/pointing.py
def as_stokes_i(self, *, interpolate: bool | None = None) -> 'PointingOperator':
    """Return a copy of this operator restricted to StokesI.

    Args:
        interpolate: Override the interpolation flag.  If ``None`` (default),
            the flag is inherited from ``self.interpolate``.
    """
    effective_interpolate = self.interpolate if interpolate is None else interpolate
    if self.landscape.stokes == 'I' and effective_interpolate == self.interpolate:
        return self
    landscape = copy.copy(self.landscape)
    landscape.stokes = 'I'
    ndet, nsamp = self.qdet.shape[0], self.qbore.shape[0]
    out_structure = StokesI.structure_for((ndet, nsamp), dtype=landscape.dtype)
    return PointingOperator(
        landscape,
        qbore=self.qbore,
        qdet=self.qdet,
        batch_size=self.batch_size,
        interpolate=effective_interpolate,
        in_structure=landscape.structure,
        _out_structure=out_structure,
    )

as_expanded_operator()

Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

Equivalent to mv() but as an explicit composition, useful for testing.

Source code in src/furax/obs/pointing.py
def as_expanded_operator(self) -> AbstractLinearOperator:
    """Return the equivalent QURotationOperator @ IndexOperator @ RavelOperator.

    Equivalent to mv() but as an explicit composition, useful for testing.
    """
    if self.interpolate:
        raise NotImplementedError('as_expanded_operator does not support interpolate=True')
    qdet_full = qmul(self.qbore, self.qdet[:, None, :])
    indices = self._quat2index(qdet_full)
    # Ravel the spatial axes only; the Stokes container's backing array carries a leading
    # Stokes axis (axis 0) that must survive, so ravel axes 1..-1 and index the pixel axis last.
    ravel_op = RavelOperator(1, -1, in_structure=self.landscape.structure)
    index_op = IndexOperator((..., indices), in_structure=ravel_op.out_structure)
    pa = to_polarization_angle(qdet_full)
    qu_rot_op = QURotationOperator(angles=pa, in_structure=index_op.out_structure)
    return qu_rot_op @ index_op @ ravel_op

transpose()

Source code in src/furax/obs/pointing.py
def transpose(self) -> AbstractLinearOperator:
    return PointingTransposeOperator(operator=self)

furax.obs.stokes

Classes:

Attributes:

ValidStokesType = Literal['I', 'QU', 'IQU', 'IQUV'] module-attribute

Stokes dataclass

Bases: ABC

Stokes container backed by a single dense array with the components on the leading axis.

Concrete subclasses (StokesI, StokesQU, StokesIQU, StokesIQUV) each fix which Stokes components (a subset of I, Q, U, V) they hold. The components are stacked on axis 0 of a single array field, e.g. a StokesIQU over a (npix,) sky backs a (3, npix) array. Instances are registered as pytrees with the array as the only leaf.

Examples:

Construct from separate components, positionally or by keyword. Components are promoted to a common dtype and stacked, producing a jax-device array:

>>> import jax.numpy as jnp
>>> from furax.obs.stokes import Stokes, StokesIQU
>>> x = StokesIQU(jnp.ones(4), jnp.zeros(4), jnp.zeros(4))
>>> y = StokesIQU(i=jnp.ones(4), q=jnp.zeros(4), u=jnp.zeros(4))

from_stokes infers the concrete subclass from the number of components given:

>>> z = Stokes.from_stokes(jnp.ones(4), jnp.zeros(4), jnp.zeros(4))
>>> type(z) is StokesIQU
True

from_array wraps an already-stacked array (leading axis = number of components). A plain numpy.ndarray is kept as-is, with no transfer to a jax device -- this is how to build a host-only instance, e.g. inside a numpy-only I/O callback:

>>> import numpy as np
>>> host_array = np.zeros((3, 4))  # (n_stokes, npix)
>>> host_only = StokesIQU.from_array(host_array)
>>> isinstance(host_only.data, np.ndarray)
True

Methods:

Attributes:

Source code in src/furax/obs/stokes.py
@dataclass
class Stokes(ABC):
    """Stokes container backed by a single dense array with the components on the leading axis.

    Concrete subclasses (`StokesI`, `StokesQU`, `StokesIQU`, `StokesIQUV`) each fix which Stokes
    components (a subset of I, Q, U, V) they hold. The components are stacked on axis 0 of a single
    array field, e.g. a ``StokesIQU`` over a ``(npix,)`` sky backs a ``(3, npix)`` array. Instances
    are registered as pytrees with the array as the only leaf.

    Examples:
        Construct from separate components, positionally or by keyword. Components are promoted
        to a common dtype and stacked, producing a jax-device array:

        >>> import jax.numpy as jnp
        >>> from furax.obs.stokes import Stokes, StokesIQU
        >>> x = StokesIQU(jnp.ones(4), jnp.zeros(4), jnp.zeros(4))
        >>> y = StokesIQU(i=jnp.ones(4), q=jnp.zeros(4), u=jnp.zeros(4))

        ``from_stokes`` infers the concrete subclass from the number of components given:

        >>> z = Stokes.from_stokes(jnp.ones(4), jnp.zeros(4), jnp.zeros(4))
        >>> type(z) is StokesIQU
        True

        ``from_array`` wraps an already-stacked array (leading axis = number of components).
        A plain ``numpy.ndarray`` is kept as-is, with no transfer to a jax device -- this is how
        to build a host-only instance, e.g. inside a numpy-only I/O callback:

        >>> import numpy as np
        >>> host_array = np.zeros((3, 4))  # (n_stokes, npix)
        >>> host_only = StokesIQU.from_array(host_array)
        >>> isinstance(host_only.data, np.ndarray)
        True
    """

    stokes: ClassVar[ValidStokesType]
    data: Array

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        letters = self.stokes
        if args and kwargs:
            raise TypeError('Stokes components must be given positionally or by keyword, not both.')
        if kwargs:
            expected = {letter.lower() for letter in letters}
            if set(kwargs) != expected:
                msg = f'Expected Stokes components {sorted(expected)}, got {sorted(kwargs)}.'
                raise TypeError(msg)
            components = tuple(kwargs[letter.lower()] for letter in letters)
        else:
            components = args
        if len(components) != len(letters):
            msg = (
                f'{type(self).__name__} expects {len(letters)} component(s), got {len(components)}.'
            )
            raise TypeError(msg)
        # Stack components on device; host (numpy) construction goes through `from_array`
        self.data = jnp.stack(components, axis=0)

    @classmethod
    def from_array(cls, array: ArrayLike | jax.ShapeDtypeStruct) -> Self:
        """Wrap a backing array of shape ``(len(stokes), *shape)``.

        A plain ``numpy.ndarray`` is kept as-is (no device transfer); anything else is
        coerced through ``jnp.asarray``.
        """
        if not isinstance(array, (jax.ShapeDtypeStruct, np.ndarray)):
            array = jnp.asarray(array)
        if array.shape[0] != len(cls.stokes):
            msg = f'{cls.__name__} expects a leading axis of {len(cls.stokes)}, got shape {array.shape}.'
            raise ValueError(msg)
        # Bypass __init__ to avoid device transfer
        instance = object.__new__(cls)
        instance.data = array  # type: ignore[assignment]
        return instance

    def __pdoc__(self, **kwargs: Any) -> wl.AbstractDoc:
        inner = wl.pdoc(self.data, **kwargs)
        return wl.ConcatDoc(wl.TextDoc(f'{type(self).__name__}('), inner, wl.TextDoc(')'))

    def __repr__(self) -> str:
        return wl.pformat(self, width=80)  # type: ignore[no-any-return]

    # ---- component access -----------------------------------------------------------------------
    def _component(self, letter: str) -> Array:
        idx = self.stokes.find(letter)
        if idx < 0:
            raise AttributeError(f'{type(self).__name__} has no Stokes component {letter!r}.')
        return self.data[idx]

    @property
    def i(self) -> Array:
        return self._component('I')

    @property
    def q(self) -> Array:
        return self._component('Q')

    @property
    def u(self) -> Array:
        return self._component('U')

    @property
    def v(self) -> Array:
        return self._component('V')

    @property
    def shape(self) -> tuple[int, ...]:
        """Returns the common shape of the Stokes components."""
        return self.data.shape[1:]

    @property
    def dtype(self) -> np.dtype:
        return self.data.dtype

    @property
    def structure(self) -> PyTree[jax.ShapeDtypeStruct]:
        return self.structure_for(self.shape, self.dtype)

    def __getitem__(self, index: Integer[Array, '...']) -> Self:
        # ``index`` addresses the data axes (from the first); the leading Stokes axis is preserved.
        idx = index if isinstance(index, tuple) else (index,)
        return self.from_array(self.data[(slice(None), *idx)])

    def __eq__(self, other: object) -> Any:
        # Same-type comparison of the backing array.
        # For ``ShapeDtypeStruct`` backings (structures) this returns a bool (shape/dtype match).
        # For concrete arrays it returns the elementwise boolean array.
        if not isinstance(other, Stokes) or self.stokes != other.stokes:
            return NotImplemented
        return self.data == other.data

    def __matmul__(self, other: Any) -> Any:
        """Returns the scalar product between Stokes maps."""
        if not isinstance(other, type(self)):
            return NotImplemented
        return jnp.sum(jnp.conj(self.data) * other.data)

    def __abs__(self) -> Self:
        return self.from_array(jnp.abs(self.data))

    def __pos__(self) -> Self:
        return self

    def __neg__(self) -> Self:
        return self.from_array(-self.data)

    def _operand(self, other: Any) -> Any:
        if isinstance(other, Stokes):
            return other.data if type(other) is type(self) else NotImplemented

        try:
            return jnp.asarray(other)
        except TypeError:
            return NotImplemented

    def _binop(self, other: Any, fn: Callable[[Any, Any], Any], reflected: bool = False) -> Self:
        rhs = self._operand(other)
        if rhs is NotImplemented:
            # mypy's exemption for returning NotImplemented only applies inside a method
            # literally named as a dunder (e.g. __add__), not this shared helper.
            return NotImplemented  # type: ignore[no-any-return]
        return self.from_array(fn(rhs, self.data) if reflected else fn(self.data, rhs))

    def __add__(self, other: Any) -> Self:
        return self._binop(other, operator.add)

    def __sub__(self, other: Any) -> Self:
        return self._binop(other, operator.sub)

    def __mul__(self, other: Any) -> Self:
        return self._binop(other, operator.mul)

    def __truediv__(self, other: Any) -> Self:
        return self._binop(other, operator.truediv)

    def __pow__(self, other: Any) -> Self:
        if isinstance(other, _Metaω):
            return NotImplemented
        return self._binop(other, operator.pow)

    def __radd__(self, other: Any) -> Self:
        return self._binop(other, operator.add, reflected=True)

    def __rsub__(self, other: Any) -> Self:
        return self._binop(other, operator.sub, reflected=True)

    def __rmul__(self, other: Any) -> Self:
        return self._binop(other, operator.mul, reflected=True)

    def __rtruediv__(self, other: Any) -> Self:
        return self._binop(other, operator.truediv, reflected=True)

    def __rpow__(self, other: Any) -> Self:
        return self._binop(other, operator.pow, reflected=True)

    def ravel(self) -> Self:
        """Ravels the batch axes of each Stokes component."""
        return self.from_array(self.data.reshape(self.data.shape[0], -1))

    def reshape(self, shape: tuple[int, ...]) -> Self:
        """Reshape the batch axes of each Stokes component."""
        return self.from_array(self.data.reshape(self.data.shape[0], *shape))

    def rotate_qu(self, cos_2angles: Float[Array, '...'], sin_2angles: Float[Array, '...']) -> Self:
        """Rotate the Q, U components by an angle whose double-angle cos/sin are given.

        ``Q' = Q cos2a + U sin2a``, ``U' = -Q sin2a + U cos2a``; I and V are unchanged. Q and U are
        adjacent rows on the leading Stokes axis, so this updates just those two rows in place.
        A type without Q (``StokesI``) is returned unchanged.
        """
        qi = self.stokes.find('Q')
        if qi < 0:
            return self
        q, u = self.data[qi], self.data[qi + 1]
        rotated = jnp.stack([q * cos_2angles + u * sin_2angles, -q * sin_2angles + u * cos_2angles])
        return self.from_array(self.data.at[qi : qi + 2].set(rotated))

    @classmethod
    @overload
    def class_for(cls, stokes: Literal['I']) -> type['StokesI']: ...

    @classmethod
    @overload
    def class_for(cls, stokes: Literal['QU']) -> type['StokesQU']: ...

    @classmethod
    @overload
    def class_for(cls, stokes: Literal['IQU']) -> type['StokesIQU']: ...

    @classmethod
    @overload
    def class_for(cls, stokes: Literal['IQUV']) -> type['StokesIQUV']: ...

    @classmethod
    def class_for(cls, stokes: str) -> type['StokesType']:
        """Returns the StokesPyTree subclass associated to the specified Stokes types."""
        if stokes not in get_args(ValidStokesType):
            raise ValueError(f'Invalid Stokes parameters: {stokes!r}')
        requested_cls = {
            'I': StokesI,
            'QU': StokesQU,
            'IQU': StokesIQU,
            'IQUV': StokesIQUV,
        }[stokes]
        return cast(type[StokesType], requested_cls)

    @classmethod
    def structure_for(cls, shape: tuple[int, ...], dtype: DTypeLike = np.float64) -> Self:
        return cls.from_array(jax.ShapeDtypeStruct((len(cls.stokes), *shape), dtype))

    @classmethod
    @overload
    def from_stokes(cls, i: ArrayLike) -> 'StokesI': ...

    @classmethod
    @overload
    def from_stokes(cls, q: ArrayLike, u: ArrayLike) -> 'StokesQU': ...

    @classmethod
    @overload
    def from_stokes(cls, i: ArrayLike, q: ArrayLike, u: ArrayLike) -> 'StokesIQU': ...

    @classmethod
    @overload
    def from_stokes(
        cls, i: ArrayLike, q: ArrayLike, u: ArrayLike, v: ArrayLike
    ) -> 'StokesIQUV': ...

    @classmethod
    def from_stokes(
        cls,
        *args: Any,
        **keywords: Any,
    ) -> 'Stokes':
        """Returns a StokesPyTree according to the specified Stokes vectors.

        Examples:
            >>> tod_i = Stokes.from_stokes(i)
            >>> tod_qu = Stokes.from_stokes(q, u)
            >>> tod_iqu = Stokes.from_stokes(i, q, u)
            >>> tod_iquv = Stokes.from_stokes(i, q, u, v)
        """
        if args and keywords:
            raise TypeError(
                'The Stokes parameters should be specified either through positional or keyword '
                'arguments.'
            )
        if keywords:
            stokes = ''.join(sorted(keywords))
            if stokes not in get_args(ValidStokesType):
                raise TypeError(
                    f"Invalid Stokes vectors: {stokes!r}. Use 'I', 'QU', 'IQU' or 'IQUV'."
                )
            args = tuple(keywords[stoke] for stoke in stokes)

        args = as_promoted_dtype(args)
        if len(args) == 1:
            return StokesI(*args)
        if len(args) == 2:
            return StokesQU(*args)
        if len(args) == 3:
            return StokesIQU(*args)
        if len(args) == 4:
            return StokesIQUV(*args)
        raise TypeError(f'Unexpected number of Stokes parameters: {len(args)}.')

    @classmethod
    def from_iquv(
        cls,
        i: Float[Array, '...'],
        q: Float[Array, '...'],
        u: Float[Array, '...'],
        v: Float[Array, '...'],
    ) -> Self:
        """Build this Stokes type from the full I, Q, U, V set, keeping only its own components."""
        available = {'I': i, 'Q': q, 'U': u, 'V': v}
        return cls(*(available[letter] for letter in cls.stokes))

    @classmethod
    def zeros(cls, shape: tuple[int, ...], dtype: DTypeLike = float) -> Self:
        return zeros_like(cls.structure_for(shape, dtype))

    @classmethod
    def ones(cls, shape: tuple[int, ...], dtype: DTypeLike = float) -> Self:
        return ones_like(cls.structure_for(shape, dtype))

    @classmethod
    def full(cls, shape: tuple[int, ...], fill_value: ScalarLike, dtype: DTypeLike = float) -> Self:
        return full_like(cls.structure_for(shape, dtype), fill_value)

    @classmethod
    def normal(cls, key: Key[Array, ''], shape: tuple[int, ...], dtype: DTypeLike = float) -> Self:
        return normal_like(cls.structure_for(shape, dtype), key)

    @classmethod
    def uniform(
        cls,
        shape: tuple[int, ...],
        key: Key[Array, ''],
        dtype: DTypeLike = float,
        low: float = 0.0,
        high: float = 1.0,
    ) -> Self:
        return uniform_like(cls.structure_for(shape, dtype), key, low, high)

stokes class-attribute

data = jnp.stack(components, axis=0) instance-attribute

i property

q property

u property

v property

shape property

Returns the common shape of the Stokes components.

dtype property

structure property

__init__(*args, **kwargs)

Source code in src/furax/obs/stokes.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    letters = self.stokes
    if args and kwargs:
        raise TypeError('Stokes components must be given positionally or by keyword, not both.')
    if kwargs:
        expected = {letter.lower() for letter in letters}
        if set(kwargs) != expected:
            msg = f'Expected Stokes components {sorted(expected)}, got {sorted(kwargs)}.'
            raise TypeError(msg)
        components = tuple(kwargs[letter.lower()] for letter in letters)
    else:
        components = args
    if len(components) != len(letters):
        msg = (
            f'{type(self).__name__} expects {len(letters)} component(s), got {len(components)}.'
        )
        raise TypeError(msg)
    # Stack components on device; host (numpy) construction goes through `from_array`
    self.data = jnp.stack(components, axis=0)

from_array(array) classmethod

Wrap a backing array of shape (len(stokes), *shape).

A plain numpy.ndarray is kept as-is (no device transfer); anything else is coerced through jnp.asarray.

Source code in src/furax/obs/stokes.py
@classmethod
def from_array(cls, array: ArrayLike | jax.ShapeDtypeStruct) -> Self:
    """Wrap a backing array of shape ``(len(stokes), *shape)``.

    A plain ``numpy.ndarray`` is kept as-is (no device transfer); anything else is
    coerced through ``jnp.asarray``.
    """
    if not isinstance(array, (jax.ShapeDtypeStruct, np.ndarray)):
        array = jnp.asarray(array)
    if array.shape[0] != len(cls.stokes):
        msg = f'{cls.__name__} expects a leading axis of {len(cls.stokes)}, got shape {array.shape}.'
        raise ValueError(msg)
    # Bypass __init__ to avoid device transfer
    instance = object.__new__(cls)
    instance.data = array  # type: ignore[assignment]
    return instance

__pdoc__(**kwargs)

Source code in src/furax/obs/stokes.py
def __pdoc__(self, **kwargs: Any) -> wl.AbstractDoc:
    inner = wl.pdoc(self.data, **kwargs)
    return wl.ConcatDoc(wl.TextDoc(f'{type(self).__name__}('), inner, wl.TextDoc(')'))

__repr__()

Source code in src/furax/obs/stokes.py
def __repr__(self) -> str:
    return wl.pformat(self, width=80)  # type: ignore[no-any-return]

__getitem__(index)

Source code in src/furax/obs/stokes.py
def __getitem__(self, index: Integer[Array, '...']) -> Self:
    # ``index`` addresses the data axes (from the first); the leading Stokes axis is preserved.
    idx = index if isinstance(index, tuple) else (index,)
    return self.from_array(self.data[(slice(None), *idx)])

__eq__(other)

Source code in src/furax/obs/stokes.py
def __eq__(self, other: object) -> Any:
    # Same-type comparison of the backing array.
    # For ``ShapeDtypeStruct`` backings (structures) this returns a bool (shape/dtype match).
    # For concrete arrays it returns the elementwise boolean array.
    if not isinstance(other, Stokes) or self.stokes != other.stokes:
        return NotImplemented
    return self.data == other.data

__matmul__(other)

Returns the scalar product between Stokes maps.

Source code in src/furax/obs/stokes.py
def __matmul__(self, other: Any) -> Any:
    """Returns the scalar product between Stokes maps."""
    if not isinstance(other, type(self)):
        return NotImplemented
    return jnp.sum(jnp.conj(self.data) * other.data)

__abs__()

Source code in src/furax/obs/stokes.py
def __abs__(self) -> Self:
    return self.from_array(jnp.abs(self.data))

__pos__()

Source code in src/furax/obs/stokes.py
def __pos__(self) -> Self:
    return self

__neg__()

Source code in src/furax/obs/stokes.py
def __neg__(self) -> Self:
    return self.from_array(-self.data)

__add__(other)

Source code in src/furax/obs/stokes.py
def __add__(self, other: Any) -> Self:
    return self._binop(other, operator.add)

__sub__(other)

Source code in src/furax/obs/stokes.py
def __sub__(self, other: Any) -> Self:
    return self._binop(other, operator.sub)

__mul__(other)

Source code in src/furax/obs/stokes.py
def __mul__(self, other: Any) -> Self:
    return self._binop(other, operator.mul)

__truediv__(other)

Source code in src/furax/obs/stokes.py
def __truediv__(self, other: Any) -> Self:
    return self._binop(other, operator.truediv)

__pow__(other)

Source code in src/furax/obs/stokes.py
def __pow__(self, other: Any) -> Self:
    if isinstance(other, _Metaω):
        return NotImplemented
    return self._binop(other, operator.pow)

__radd__(other)

Source code in src/furax/obs/stokes.py
def __radd__(self, other: Any) -> Self:
    return self._binop(other, operator.add, reflected=True)

__rsub__(other)

Source code in src/furax/obs/stokes.py
def __rsub__(self, other: Any) -> Self:
    return self._binop(other, operator.sub, reflected=True)

__rmul__(other)

Source code in src/furax/obs/stokes.py
def __rmul__(self, other: Any) -> Self:
    return self._binop(other, operator.mul, reflected=True)

__rtruediv__(other)

Source code in src/furax/obs/stokes.py
def __rtruediv__(self, other: Any) -> Self:
    return self._binop(other, operator.truediv, reflected=True)

__rpow__(other)

Source code in src/furax/obs/stokes.py
def __rpow__(self, other: Any) -> Self:
    return self._binop(other, operator.pow, reflected=True)

ravel()

Ravels the batch axes of each Stokes component.

Source code in src/furax/obs/stokes.py
def ravel(self) -> Self:
    """Ravels the batch axes of each Stokes component."""
    return self.from_array(self.data.reshape(self.data.shape[0], -1))

reshape(shape)

Reshape the batch axes of each Stokes component.

Source code in src/furax/obs/stokes.py
def reshape(self, shape: tuple[int, ...]) -> Self:
    """Reshape the batch axes of each Stokes component."""
    return self.from_array(self.data.reshape(self.data.shape[0], *shape))

rotate_qu(cos_2angles, sin_2angles)

Rotate the Q, U components by an angle whose double-angle cos/sin are given.

Q' = Q cos2a + U sin2a, U' = -Q sin2a + U cos2a; I and V are unchanged. Q and U are adjacent rows on the leading Stokes axis, so this updates just those two rows in place. A type without Q (StokesI) is returned unchanged.

Source code in src/furax/obs/stokes.py
def rotate_qu(self, cos_2angles: Float[Array, '...'], sin_2angles: Float[Array, '...']) -> Self:
    """Rotate the Q, U components by an angle whose double-angle cos/sin are given.

    ``Q' = Q cos2a + U sin2a``, ``U' = -Q sin2a + U cos2a``; I and V are unchanged. Q and U are
    adjacent rows on the leading Stokes axis, so this updates just those two rows in place.
    A type without Q (``StokesI``) is returned unchanged.
    """
    qi = self.stokes.find('Q')
    if qi < 0:
        return self
    q, u = self.data[qi], self.data[qi + 1]
    rotated = jnp.stack([q * cos_2angles + u * sin_2angles, -q * sin_2angles + u * cos_2angles])
    return self.from_array(self.data.at[qi : qi + 2].set(rotated))

class_for(stokes) classmethod

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

Returns the StokesPyTree subclass associated to the specified Stokes types.

Source code in src/furax/obs/stokes.py
@classmethod
def class_for(cls, stokes: str) -> type['StokesType']:
    """Returns the StokesPyTree subclass associated to the specified Stokes types."""
    if stokes not in get_args(ValidStokesType):
        raise ValueError(f'Invalid Stokes parameters: {stokes!r}')
    requested_cls = {
        'I': StokesI,
        'QU': StokesQU,
        'IQU': StokesIQU,
        'IQUV': StokesIQUV,
    }[stokes]
    return cast(type[StokesType], requested_cls)

structure_for(shape, dtype=np.float64) classmethod

Source code in src/furax/obs/stokes.py
@classmethod
def structure_for(cls, shape: tuple[int, ...], dtype: DTypeLike = np.float64) -> Self:
    return cls.from_array(jax.ShapeDtypeStruct((len(cls.stokes), *shape), dtype))

from_stokes(*args, **keywords) classmethod

from_stokes(i: ArrayLike) -> StokesI
from_stokes(q: ArrayLike, u: ArrayLike) -> StokesQU
from_stokes(i: ArrayLike, q: ArrayLike, u: ArrayLike) -> StokesIQU
from_stokes(i: ArrayLike, q: ArrayLike, u: ArrayLike, v: ArrayLike) -> StokesIQUV

Returns a StokesPyTree according to the specified Stokes vectors.

Examples:

>>> tod_i = Stokes.from_stokes(i)
>>> tod_qu = Stokes.from_stokes(q, u)
>>> tod_iqu = Stokes.from_stokes(i, q, u)
>>> tod_iquv = Stokes.from_stokes(i, q, u, v)
Source code in src/furax/obs/stokes.py
@classmethod
def from_stokes(
    cls,
    *args: Any,
    **keywords: Any,
) -> 'Stokes':
    """Returns a StokesPyTree according to the specified Stokes vectors.

    Examples:
        >>> tod_i = Stokes.from_stokes(i)
        >>> tod_qu = Stokes.from_stokes(q, u)
        >>> tod_iqu = Stokes.from_stokes(i, q, u)
        >>> tod_iquv = Stokes.from_stokes(i, q, u, v)
    """
    if args and keywords:
        raise TypeError(
            'The Stokes parameters should be specified either through positional or keyword '
            'arguments.'
        )
    if keywords:
        stokes = ''.join(sorted(keywords))
        if stokes not in get_args(ValidStokesType):
            raise TypeError(
                f"Invalid Stokes vectors: {stokes!r}. Use 'I', 'QU', 'IQU' or 'IQUV'."
            )
        args = tuple(keywords[stoke] for stoke in stokes)

    args = as_promoted_dtype(args)
    if len(args) == 1:
        return StokesI(*args)
    if len(args) == 2:
        return StokesQU(*args)
    if len(args) == 3:
        return StokesIQU(*args)
    if len(args) == 4:
        return StokesIQUV(*args)
    raise TypeError(f'Unexpected number of Stokes parameters: {len(args)}.')

from_iquv(i, q, u, v) classmethod

Build this Stokes type from the full I, Q, U, V set, keeping only its own components.

Source code in src/furax/obs/stokes.py
@classmethod
def from_iquv(
    cls,
    i: Float[Array, '...'],
    q: Float[Array, '...'],
    u: Float[Array, '...'],
    v: Float[Array, '...'],
) -> Self:
    """Build this Stokes type from the full I, Q, U, V set, keeping only its own components."""
    available = {'I': i, 'Q': q, 'U': u, 'V': v}
    return cls(*(available[letter] for letter in cls.stokes))

zeros(shape, dtype=float) classmethod

Source code in src/furax/obs/stokes.py
@classmethod
def zeros(cls, shape: tuple[int, ...], dtype: DTypeLike = float) -> Self:
    return zeros_like(cls.structure_for(shape, dtype))

ones(shape, dtype=float) classmethod

Source code in src/furax/obs/stokes.py
@classmethod
def ones(cls, shape: tuple[int, ...], dtype: DTypeLike = float) -> Self:
    return ones_like(cls.structure_for(shape, dtype))

full(shape, fill_value, dtype=float) classmethod

Source code in src/furax/obs/stokes.py
@classmethod
def full(cls, shape: tuple[int, ...], fill_value: ScalarLike, dtype: DTypeLike = float) -> Self:
    return full_like(cls.structure_for(shape, dtype), fill_value)

normal(key, shape, dtype=float) classmethod

Source code in src/furax/obs/stokes.py
@classmethod
def normal(cls, key: Key[Array, ''], shape: tuple[int, ...], dtype: DTypeLike = float) -> Self:
    return normal_like(cls.structure_for(shape, dtype), key)

uniform(shape, key, dtype=float, low=0.0, high=1.0) classmethod

Source code in src/furax/obs/stokes.py
@classmethod
def uniform(
    cls,
    shape: tuple[int, ...],
    key: Key[Array, ''],
    dtype: DTypeLike = float,
    low: float = 0.0,
    high: float = 1.0,
) -> Self:
    return uniform_like(cls.structure_for(shape, dtype), key, low, high)

StokesI dataclass

Bases: Stokes

Attributes:

Source code in src/furax/obs/stokes.py
@register_dataclass_with_keys
class StokesI(Stokes):
    stokes: ClassVar[ValidStokesType] = 'I'

stokes = 'I' class-attribute

StokesQU dataclass

Bases: Stokes

Attributes:

Source code in src/furax/obs/stokes.py
@register_dataclass_with_keys
class StokesQU(Stokes):
    stokes: ClassVar[ValidStokesType] = 'QU'

stokes = 'QU' class-attribute

StokesIQU dataclass

Bases: Stokes

Attributes:

Source code in src/furax/obs/stokes.py
@register_dataclass_with_keys
class StokesIQU(Stokes):
    stokes: ClassVar[ValidStokesType] = 'IQU'

stokes = 'IQU' class-attribute

StokesIQUV dataclass

Bases: Stokes

Attributes:

Source code in src/furax/obs/stokes.py
@register_dataclass_with_keys
class StokesIQUV(Stokes):
    stokes: ClassVar[ValidStokesType] = 'IQUV'

stokes = 'IQUV' class-attribute