Skip to content

API

Core Module (serval.core.py)

PowerBeamVisProjector

Bases: VisProjector

Parameters:

Name Type Description Default
latitude float
required
longitude float
required
baseline_enu tuple[float, float, float]
required
power_beam_lmax int
required
frequencies_MHz ndarray
required
baseline_lmax int | None
None
sky_lmax int | None
None
sky_absm_limits list[int | None]
[0, None]
generate_gaunt_cache_on_init bool
False
generate_baseline_cache_on_init bool
False
generate_pointing_contractor_on_init bool
False
batch_parallel_mode Literal['channel-opt', 'channel', 'gaunt', 'gaunt-opt']
'gaunt-opt'
cache_truncation_rtol float
0
pointing_contract bool
False
pointing_altitude float | None
None
pointing_azimuth float | None
None
pointing_boresight float | None
None
pointed_beam_mmax int | None
None
aberrate_baseline bool
False

Attributes:

Name Type Description
pointing_contractor ndarray | None
Source code in src/serval/core.py
@define(eq=False)
class PowerBeamVisProjector(VisProjector):
    latitude: float
    longitude: float
    baseline_enu: tuple[float, float, float]
    power_beam_lmax: int
    frequencies_MHz: np.ndarray

    baseline_lmax: int | None = None
    sky_lmax: int | None = None
    sky_absm_limits: list[int | None] = field(factory=lambda: [0, None])
    generate_gaunt_cache_on_init: bool = False
    generate_baseline_cache_on_init: bool = False
    generate_pointing_contractor_on_init: bool = False
    batch_parallel_mode: Literal["channel-opt", "channel", "gaunt", "gaunt-opt"] = "gaunt-opt"
    cache_truncation_rtol: float = 0
    pointing_contract: bool = False
    pointing_altitude: float | None = None
    pointing_azimuth: float | None = None
    pointing_boresight: float | None = None
    pointed_beam_mmax: int | None = None
    aberrate_baseline: bool = False

    pointing_contractor: np.ndarray | None = field(init=False, default=None)
    _baseline_cache_T: np.ndarray | None = field(init=False, default=None)

    _to_attrs: ClassVar[list[str]] = [
        "latitude",
        "longitude",
        "power_beam_lmax",
        "baseline_lmax",
        "sky_lmax",
        "baseline_enu",
        "aberrate_baseline",
        "pointing_contract",
        "pointing_altitude",
        "pointing_azimuth",
        "pointing_boresight",
        "pointed_beam_mmax",
        "cache_truncation_rtol",
    ]
    _to_zarr_store: ClassVar[list[str]] = ["frequencies_MHz"]
    _opt_tag: ClassVar[str] = "opt12"

    def __attrs_post_init__(self):
        if self.generate_baseline_cache_on_init:
            self.generate_baseline_cache()
        lmax, self.baseline_mmax = baseline_bandlimits(
            self.baseline_enu,
            self.frequencies_MHz.max(),
            self.latitude,
            self.longitude,
        )
        if self.baseline_lmax is None:
            self.baseline_lmax = lmax
        self.set_bandlimits_from_baseline()
        if self.generate_gaunt_cache_on_init:
            self.generate_gaunt_cache()
        else:
            self.update_integrator()
        if self.pointing_contract:
            if self.pointed_beam_mmax is None:
                raise ValueError(
                    "Cannot determine pointing_contractor with the inputs given, "
                    "because pointed_beam_mmax is None."
                )
            if self.pointed_beam_mmax > self.power_beam_lmax:
                raise ValueError(
                    "pointed_beam_mmax cannot be larger than power_beam_lmax."
                )
            if self.generate_pointing_contractor_on_init:
                self.generate_pointing_contractor()

    def generate_pointing_contractor(self):
        self.pointing_contractor = wigner_d(
            self.power_beam_lmax,
            self.pointed_beam_mmax,
            tirs_to_pointing(
                latitude=self.latitude,
                longitude=self.longitude,
                altitude=self.pointing_altitude,
                azimuth=self.pointing_azimuth,
                boresight=self.pointing_boresight,
            ).inv(),
        )

    def generate_baseline_cache(self):
        self.baseline_cache = tirs_baseline_alm(
            enu=self.baseline_enu,
            frequencies_MHz=self.frequencies_MHz,
            latitude=self.latitude,
            longitude=self.longitude,
            lmax=self.baseline_lmax,
            aberrate_baseline=self.aberrate_baseline,
        )
        self.baseline_lmax = self.baseline_cache.shape[1] - 1
        self._baseline_cache_T = transpose_arr(self.baseline_cache, (2, 1, 0))

    def set_bandlimits_from_baseline(self):
        if self.sky_lmax is None:
            # No warning here, as it's the default case.
            self.sky_lmax = self.baseline_lmax + self.power_beam_lmax
        elif self.sky_lmax > self.baseline_lmax + self.power_beam_lmax:
            self.sky_lmax = self.baseline_lmax + self.power_beam_lmax
            warnings.warn(
                "The value of sky_lmax is too large. It is changed to the sum of baseline_lmax and "
                "power_beam_lmax which is " + str(self.sky_lmax) + ".",
                stacklevel=2,  # points to caller
            )
        elif self.sky_lmax < self.baseline_lmax + self.power_beam_lmax:
            warnings.warn(
                "The value of sky_lmax is smaller than the optimal value given the "
                "baseline and beam which is "
                + str(self.baseline_lmax + self.power_beam_lmax)
                + ". Consider changing it.",
                stacklevel=2,
            )  # points to caller
        if self.sky_absm_limits[1] is None:
            self.sky_absm_limits = (
                0,
                min(self.baseline_mmax + self.power_beam_lmax, self.sky_lmax) + 1,
            )
            # No warning here as it's a very common case to want this auto-generated.
        else:
            if self.sky_absm_limits[1] > self.sky_lmax + 1:
                self.sky_absm_limits = list(self.sky_absm_limits)
                self.sky_absm_limits[1] = self.sky_lmax + 1
                warnings.warn(
                    "The upper limit for the mmodes of the sky cannot be larger than "
                    "sky_lmax + 1. It is changed to "
                    + str(self.sky_absm_limits[1])
                    + ".",
                    stacklevel=2,
                )  # points to caller
            if self.sky_absm_limits[1] - 1 != self.baseline_mmax + self.power_beam_lmax:
                warnings.warn(
                    "The upper limit for the mmodes of the sky does not correspond to the "
                    "optimal value given the baseline and beam which is "
                    + str(self.baseline_mmax + self.power_beam_lmax)
                    + ". Consider changing it.",
                    stacklevel=2,
                )  # points to caller
            self.sky_absm_limits = tuple(self.sky_absm_limits)

    def beam_linear_mmode_generator(
        self,
        sky_alms,
        release_gaunt_cache: bool = True,
        sky_alms_transposed: npt.NDArray[np.complex128] | None = None,
    ):
        sky_alms = extend_dimensions_if_one_batch(sky_alms, 3)
        if sky_alms.shape[0] != len(self.frequencies_MHz):
            raise ValueError(
                "The sky_alms coefficients do not correspond to the number of frequency bins."
            )
        if sky_alms.shape[1] != self.sky_lmax + 1:
            raise ValueError(
                "The sky_alms coefficients do not correspond to the expected sky_lmax "
                "(baseline_lmax + power_beam_lmax)."
            )
        if sky_alms.shape[2] != 2 * self.sky_lmax + 1:
            raise ValueError(
                "The sky_alms coefficients do not correspond to the full harmonic orders "
                "of sky_lmax (baseline_lmax + power_beam_lmax) -> (ms = 2 * sky_lmax + 1)."
            )
        if self.baseline_cache is not None:
            self.generate_baseline_cache()
        if self.baseline_cache is None:
            raise ValueError("baseline_cache is None. It must be generated beforehand.")
        if self.pointing_contract and self.pointing_contractor is None:
            self.generate_pointing_contractor()
        alm2_transposed = getattr(self, "_baseline_cache_T", None)
        self.triple_sh_integrator.generate_integrator_cache_12(
            sky_alms,
            self.baseline_cache,
            contract3=self.pointing_contractor,
            release_gaunt_cache=release_gaunt_cache,
            batch_parallel_mode=self.batch_parallel_mode,
            alm1_transposed=sky_alms_transposed,
            alm2_transposed=alm2_transposed,
        )
        self.integrator_cache = self.triple_sh_integrator.linear_integrator_cache_12
        return self.integrator_cache

    def mmodes(self, beam_alms, sky_alms=None):
        beam_alms = extend_dimensions_if_one_batch(beam_alms, 3)
        if beam_alms.shape[0] != len(self.frequencies_MHz):
            raise ValueError(
                "The beam_alms coefficients do not correspond to the number of frequency bins."
            )
        if beam_alms.shape[1] != self.power_beam_lmax + 1:
            raise ValueError(
                "The beam_alms coefficients do not correspond to the inputted power_beam_lmax."
            )
        if self.pointing_contract:
            if beam_alms.shape[2] != 2 * self.pointed_beam_mmax + 1:
                raise ValueError(
                    "The beam_alms coefficients do not correspond to the expected "
                    "pointed_beam_mmax."
                )
        else:
            if beam_alms.shape[2] != 2 * self.power_beam_lmax + 1:
                raise ValueError(
                    "The beam_alms coefficients do not correspond to the full harmonic orders "
                    "of power_beam_lmax -> (ms = 2 * power_beam_lmax + 1)."
                )
        if sky_alms is None and self.integrator_cache is None:
            raise ValueError("Must specify either sky_alms or integrator cache.")
        else:
            if sky_alms is None:
                integrator_cache = self.integrator_cache
            else:
                integrator_cache = self.beam_linear_mmode_generator(sky_alms=sky_alms)
            integrator_cache = extend_dimensions_if_one_batch(integrator_cache, 4)
            return self.triple_sh_integrator.batch_gaunt_integrate_cached_12(
                beam_alms, integrator_cache=integrator_cache
            )

    def visibilities(self, beam_alms, sky_alms=None, return_mmodes=False):
        mmodes = self.mmodes(beam_alms=beam_alms, sky_alms=sky_alms)
        vis = mmodes_to_visibilities(mmodes, self.sky_lmax, ms=self.sky_m_values)
        if return_mmodes:
            return vis, mmodes
        else:
            return vis

    def setup_zarr_store(
        self,
        store_location: str | Path,
        frequency_chunksize: int | None = None,
        group_path: str = r"/",
        store_metadata_attrs: dict | None = None,
    ):
        if self.pointing_contract:
            num_beam_ms = 2 * self.pointed_beam_mmax + 1
        else:
            num_beam_ms = 2 * self.power_beam_lmax + 1
        integrator_cache_shape = (
            self.frequencies_MHz.size,
            2 * self.sky_lmax + 1,
            self.power_beam_lmax + 1,
            num_beam_ms,
        )
        integrator_cache_chunks = (
            (
                frequency_chunksize
                if frequency_chunksize is not None
                else self.frequencies_MHz.size
            ),
            1,  # per m-chunking for parallel writes
            self.power_beam_lmax + 1,
            num_beam_ms,
        )
        self._setup_zarr_store(
            store_location=store_location,
            group_path=group_path,
            integrator_cache_shape=integrator_cache_shape,
            integrator_cache_chunks=integrator_cache_chunks,
            store_metadata_attrs=store_metadata_attrs,
        )

PowerFromVoltageBeams

Parameters:

Name Type Description Default
voltage_beam_lmax int
required
voltage_beam_mmax int | None
None
power_beam_lmax int | None
None
power_beam_mmax int | None
None
generate_cache_on_init bool
True

Attributes:

Name Type Description
triple_sh_integrator TripleSHIntegrator
Source code in src/serval/core.py
@define(eq=False)
class PowerFromVoltageBeams:
    voltage_beam_lmax: int
    voltage_beam_mmax: int | None = None
    power_beam_lmax: int | None = None
    power_beam_mmax: int | None = None

    triple_sh_integrator: TripleSHIntegrator = field(init=False)
    generate_cache_on_init: bool = True


    def __attrs_post_init__(self):
        if self.voltage_beam_mmax is None:
            self.voltage_beam_mmax = self.voltage_beam_lmax
        elif self.voltage_beam_mmax > self.voltage_beam_lmax:
            self.voltage_beam_mmax = self.voltage_beam_lmax
            warnings.warn(
                "The value of the voltage beam mmax cannot be larger than the voltage beam lmax. "
                "It is changed to " + str(self.voltage_beam_lmax) + ".",
                stacklevel=2,  # points to caller
            )

        if self.power_beam_lmax is None:
            self.power_beam_lmax = 2 * self.voltage_beam_lmax
        elif not self.voltage_beam_lmax <= self.power_beam_lmax <= 2 * self.voltage_beam_lmax:
            warnings.warn(
                "The value of the power beam lmax should be in the range "
                "[min(VBi_lmax, VBj_lmax), VBi_lmax + VBj_lmax].",
                stacklevel=2,
            )

        if self.power_beam_mmax is None:
            self.power_beam_mmax = 2 * self.voltage_beam_mmax
        elif self.power_beam_mmax > self.power_beam_lmax:
            self.power_beam_mmax = self.power_beam_lmax
            warnings.warn(
                "The value of the power beam mmax cannot be larger than the power beam lmax. "
                "It is changed to " + str(self.power_beam_lmax) + ".",
                stacklevel=2,  # points to caller
            )
        if not self.voltage_beam_mmax <= self.power_beam_mmax <= 2 * self.voltage_beam_mmax:
            warnings.warn(
                "The value of the power beam mmax should be in the range "
                "[min(VBi_mmax, VBj_mmax), VBi_mmax + VBj_mmax]. ",
                stacklevel=2,  # points to caller
            )

        if self.generate_cache_on_init:
            self.generate_cache()
        else:
            self.update_integrator()

    def update_integrator(self):
        self.triple_sh_integrator = TripleSHIntegrator(
            l1max=self.voltage_beam_lmax,
            l2max=self.voltage_beam_lmax,
            l3max=self.power_beam_lmax,
            absm1_limits=(0, self.voltage_beam_mmax + 1),
            generate_cache_on_init=False,
        )

    def generate_cache(self, cache_type_tag: Literal["opt12", "generic"] = "opt12"):
        self.update_integrator()
        self.triple_sh_integrator.generate_gaunt_cache(cache_type_tag)

    def grid_batch_power_beam_alm(self, voltage_beam_alm_i, voltage_beam_alm_j):
        voltage_beam_alm_i = extend_dimensions_if_one_batch(voltage_beam_alm_i, 3)
        voltage_beam_alm_j = extend_dimensions_if_one_batch(voltage_beam_alm_j, 3)
        vb_i = set_bandlimits(
            voltage_beam_alm_i, lmax=self.power_beam_lmax, mmax=self.power_beam_lmax
        )
        vb_j = set_bandlimits(
            voltage_beam_alm_j, lmax=self.power_beam_lmax, mmax=self.power_beam_lmax
        )
        grids_i = batch_array_synthesis(vb_i, lmax=self.power_beam_lmax)
        grids_j = batch_array_synthesis(vb_j, lmax=self.power_beam_lmax)
        return set_bandlimits(
            batch_array_analysis(grids_i * np.conj(grids_j), lmax=self.power_beam_lmax),
            lmax=self.power_beam_lmax,
            mmax=self.power_beam_mmax,
        )

    def batch_power_beam_alm(
        self,
        voltage_beam_alm_i,
        voltage_beam_alm_j,
        batch_parallel_mode: Literal[
            "channel-opt", "channel", "gaunt", "gaunt-opt"
        ] = "channel-opt",
    ):
        # Conjugate j
        m2s = np.linspace(
            -self.voltage_beam_mmax,
            self.voltage_beam_mmax,
            2 * self.voltage_beam_mmax + 1,
        )
        ndim = voltage_beam_alm_j.ndim
        voltage_beam_alm_j = (
            voltage_beam_alm_j.conj()[..., ::-1]
            * (-1) ** m2s[(None,) * (ndim - 1) + (slice(None),)]
        )
        # Make m's full, eventually will not be necessary
        dm_volt = self.voltage_beam_lmax - self.voltage_beam_mmax
        if dm_volt > 0:
            voltage_beam_alm_i = np.pad(
                voltage_beam_alm_i, ((0, 0),) * (ndim - 1) + ((dm_volt, dm_volt),)
            )
            voltage_beam_alm_j = np.pad(
                voltage_beam_alm_j, ((0, 0),) * (ndim - 1) + ((dm_volt, dm_volt),)
            )

        self.triple_sh_integrator.generate_integrator_cache_12(
            voltage_beam_alm_i, voltage_beam_alm_j, batch_parallel_mode=batch_parallel_mode
        )
        power_beam_alm_unsummed = self.triple_sh_integrator.linear_integrator_cache_12
        if not np.isfinite(power_beam_alm_unsummed).all():
            raise ValueError("Power beam alm contains NaN values.")

        m3s = np.linspace(
            -self.power_beam_lmax, self.power_beam_lmax, 2 * self.power_beam_lmax + 1
        )
        ndim = power_beam_alm_unsummed.ndim
        # sum over the voltage beam ms, for 4 dims axis=1 for 3 dims axis=0 (no frequency axis)
        power_beam_alm = (
            power_beam_alm_unsummed.sum(axis=ndim - 3)[..., ::-1]
            * (-1) ** m3s[(None,) * (ndim - 2) + (slice(None),)]
        )

        # Keep the coefficients from -power_beam_mmax : power_beam_mmax
        power_beam_alm = power_beam_alm[
            ...,
            self.power_beam_lmax - self.power_beam_mmax : self.power_beam_lmax
            + self.power_beam_mmax + 1,
        ]
        return power_beam_alm

    def clear_cache(self):
        self.triple_sh_integrator.clear_gaunt_cache()

SingleVisibilityGenerator

Parameters:

Name Type Description Default
latitude float
required
longitude float
required
baseline_enu tuple[float, float, float]
required
frequency_MHz float
required
power_beam_alm ndarray[tuple[Any, ...], dtype[complex128]]
required
sky_alm ndarray[tuple[Any, ...], dtype[complex128]]
required
method Literal['gaunt', 'grid']
'gaunt'
baseline_lmax int | None
None
aberrate_baseline bool
False
sky_absm_limits tuple[int, int | None]
(0, None)
baseline_alm None | ndarray[tuple[Any, ...], dtype[complex128]]
None

Attributes:

Name Type Description
sky_lmax int
power_beam_lmax int
triple_sh_integrator TripleSHIntegrator
Source code in src/serval/core.py
@define(eq=False)
class SingleVisibilityGenerator:
    latitude: float
    longitude: float
    baseline_enu: tuple[float, float, float]
    frequency_MHz: float
    power_beam_alm: npt.NDArray[np.complex128]
    sky_alm: npt.NDArray[np.complex128]

    method: Literal["gaunt", "grid"] = "gaunt"
    baseline_lmax: int | None = None
    aberrate_baseline: bool = field(default=False, kw_only=True)

    sky_absm_limits: tuple[int, int | None] = (0, None)

    baseline_alm: None | npt.NDArray[np.complex128] = None

    sky_lmax: int = field(init=False)
    power_beam_lmax: int = field(init=False)
    triple_sh_integrator: TripleSHIntegrator = field(init=False)

    def generate_baseline_cache(self):
        self.baseline_alm = tirs_baseline_alm(
            enu=self.baseline_enu,
            frequencies_MHz=np.atleast_1d(self.frequency_MHz)[None, :],
            latitude=self.latitude,
            longitude=self.longitude,
            lmax=self.baseline_lmax,
            aberrate_baseline=self.aberrate_baseline,
        )[
            0, 0, ...
        ]  # TODO Figure out broadcasting here... The shape of the matrix:
        # [None from np.atleast_1s, len(frequencies_MHz)=1, lmax + 1, 2 * lmax + 1]
        self.baseline_lmax = self.baseline_alm.shape[0] - 1

    def __attrs_post_init__(self):
        if not isinstance(self.frequency_MHz, float | int):
            raise TypeError("frequency_MHz must be a float or integer")
        if self.baseline_alm is None:
            self.generate_baseline_cache()
        elif self.baseline_lmax is None:
            self.baseline_lmax = self.baseline_alm.shape[0] - 1
        else:
            if self.baseline_lmax != self.baseline_alm.shape[0] - 1:
                raise ValueError(
                    "The defined harmonic degree baseline_lmax does not "
                    "correspond to the defined baseline_alm coefficients"
                )
        self.sky_lmax = self.sky_alm.shape[0] - 1
        self.power_beam_lmax = self.power_beam_alm.shape[0] - 1
        self.triple_sh_integrator = TripleSHIntegrator(
            l1max=self.sky_lmax,
            l2max=self.baseline_lmax,
            l3max=self.power_beam_lmax,
            absm1_limits=self.sky_absm_limits,
            generate_cache_on_init=False,
        )

    @property
    def sky_m_values(self):
        return self.triple_sh_integrator.m1_values

    @property
    def sky_m_index_map(self):
        return self.triple_sh_integrator.m1_index_map

    @property
    def num_sky_ms(self):
        return len(self.sky_m_values)

    def mmodes(
        self,
    ):
        if self.method == "grid":
            return self.triple_sh_integrator.grid_integrate(
                alm1=self.sky_alm, alm2=self.baseline_alm, alm3=self.power_beam_alm
            )
        elif self.method == "gaunt":
            return self.triple_sh_integrator.gaunt_integrate(
                alm1=self.sky_alm, alm2=self.baseline_alm, alm3=self.power_beam_alm
            )

    def visibilities(self, return_mmodes=False):
        mmodes = self.mmodes()
        vis = mmodes_to_visibilities(mmodes, m1max=self.sky_lmax, ms=self.sky_m_values)
        if return_mmodes:
            return vis, mmodes
        else:
            return vis

SkyVisProjector

Bases: VisProjector

Parameters:

Name Type Description Default
latitude float
required
longitude float
required
baseline_enu tuple[float, float, float]
required
sky_lmax int
required
frequencies_MHz ndarray
required
baseline_lmax int | None
None
power_beam_lmax int | None
None
sky_absm_limits list[int | None]
[0, None]
generate_gaunt_cache_on_init bool
False
generate_baseline_cache_on_init bool
False
batch_parallel_mode Literal['channel', 'gaunt']
'channel'
cache_truncation_rtol float
0
aberrate_baseline bool
False
Source code in src/serval/core.py
@define(eq=False)
class SkyVisProjector(VisProjector):
    latitude: float
    longitude: float
    baseline_enu: tuple[float, float, float]
    sky_lmax: int
    frequencies_MHz: np.ndarray

    baseline_lmax: int | None = None
    power_beam_lmax: int | None = None
    sky_absm_limits: list[int | None] = field(factory=lambda: [0, None])
    generate_gaunt_cache_on_init: bool = False
    generate_baseline_cache_on_init: bool = False
    batch_parallel_mode: Literal["channel", "gaunt"] = "channel"
    cache_truncation_rtol: float = 0
    aberrate_baseline: bool = False

    _to_attrs = [
        "latitude",
        "longitude",
        "sky_lmax",
        "power_beam_lmax",
        "baseline_lmax",
        "baseline_enu",
        "aberrate_baseline",
        "cache_truncation_rtol",
    ]
    _to_zarr_store = ["frequencies_MHz"]

    def __attrs_post_init__(self):
        if self.generate_baseline_cache_on_init:
            self.generate_baseline_cache()
        if self.power_beam_lmax is None:
            if self.baseline_lmax is not None:
                if self.power_beam_lmax != self.sky_lmax - self.baseline_lmax:
                    warnings.warn(
                        "The value of power_beam_lmax is changed to the difference "
                        "of baseline_lmax from sky_lmax.",
                        stacklevel=2,  # points to caller
                    )
                    self.power_beam_lmax = self.sky_lmax - self.baseline_lmax
            else:
                raise ValueError(
                    "Cannot determine power_beam_lmax with inputs given.\n"
                    "Either baseline_cache needs to be computed on input, baseline_lmax must be "
                    "specified or power_beam_lmax must be specified."
                )
        self.triple_sh_integrator = TripleSHIntegrator(
            l1max=self.sky_lmax,
            l2max=self.baseline_lmax,
            l3max=self.power_beam_lmax,
            absm1_limits=self.sky_absm_limits,
            generate_cache_on_init=False,
        )
        if self.generate_gaunt_cache_on_init:
            self.generate_gaunt_cache()

    def generate_baseline_cache(self):
        self.baseline_cache = tirs_baseline_alm(
            enu=self.baseline_enu,
            frequencies_MHz=self.frequencies_MHz,
            latitude=self.latitude,
            longitude=self.longitude,
            lmax=self.baseline_lmax,
            aberrate_baseline=self.aberrate_baseline,
        )
        # Can assert this is the same as input if not none.
        self.baseline_lmax = self.baseline_cache.shape[1] - 1
        # Only changed here:
        if self.power_beam_lmax is None:
            warnings.warn(
                "The value of power_beam_lmax is changed to the difference "
                "of baseline_lmax from sky_lmax.",
                stacklevel=2,  # points to caller
            )
            self.power_beam_lmax = self.sky_lmax - self.baseline_lmax

    def sky_linear_mmode_generator(self, beam_alms):
        beam_alms = extend_dimensions_if_one_batch(beam_alms, 3)
        if beam_alms.shape[0] != len(self.frequencies_MHz):
            raise ValueError(
                "The beam_alms coefficients do not correspond to the number of frequency bins."
            )
        if (
            beam_alms.shape[1] != self.power_beam_lmax + 1
            or beam_alms.shape[2] != 2 * self.power_beam_lmax + 1
        ):
            raise ValueError(
                "The beam_alms coefficients do not correspond to the expected power_beam_lmax "
                "(sky_lmax - baseline_lmax). Either number ls or ms is incorrect."
            )
        self.triple_sh_integrator.generate_integrator_cache_23(
            alm2=self.baseline_cache,
            alm3=beam_alms,
            batch_parallel_mode=self.batch_parallel_mode,
        )
        self.integrator_cache = self.triple_sh_integrator.linear_integrator_cache_23
        return self.integrator_cache

    def mmodes(self, sky_alms, beam_alms=None):
        sky_alms = extend_dimensions_if_one_batch(sky_alms, 3)
        if sky_alms.shape[0] != len(self.frequencies_MHz):
            raise ValueError(
                "The sky_alms coefficients do not correspond to the number of frequency bins."
            )
        if sky_alms.shape[1] != self.sky_lmax + 1:
            raise ValueError(
                "The sky_alms coefficients do not correspond to the inputted sky_lmax."
            )
        if sky_alms.shape[2] != 2 * self.sky_lmax + 1:
            raise ValueError(
                "The sky_alms coefficients do not correspond to the expected harmonic orders "
                "for the inputted sky_lmax (ms = 2 * sky_lmax + 1)."
            )
        cut_sky_alms = sky_alms[
            ..., list(self.sky_m_index_map.keys())
        ]  # Need to select only the valid ms
        if beam_alms is None and self.integrator_cache is None:
            raise ValueError("Must specify either beam_alms or integrator cache.")
        else:
            if beam_alms is None:
                integrator_cache = self.integrator_cache
            else:
                integrator_cache = self.sky_linear_mmode_generator(beam_alms=beam_alms)
            integrator_cache = extend_dimensions_if_one_batch(integrator_cache, 3)
            return self.triple_sh_integrator.batch_gaunt_integrate_cached_23(
                alm1=cut_sky_alms, integrator_cache=integrator_cache
            )

    def visibilities(
        self,
        sky_alms,
        beam_alms=None,
        return_mmodes=False,
    ):
        mmodes = self.mmodes(beam_alms=beam_alms, sky_alms=sky_alms)
        vis = mmodes_to_visibilities(mmodes, self.sky_lmax, ms=self.sky_m_values)
        if return_mmodes:
            return vis, mmodes
        else:
            return vis

    def setup_zarr_store(
        self,
        store_location: str | Path,
        frequency_chunksize: int | None = None,
        group_path: str = r"/",
        store_metadata_attrs: dict | None = None,
    ):
        integrator_cache_shape = (
            self.frequencies_MHz.size,
            2 * self.sky_lmax + 1,
            self.sky_lmax + 1,
        )
        integrator_cache_chunks = (
            (
                frequency_chunksize
                if frequency_chunksize is not None
                else self.frequencies_MHz.size
            ),
            1,  # per m-chunking for parallel writes
            self.power_beam_lmax + 1,
        )
        self._setup_zarr_store(
            store_location=store_location,
            group_path=group_path,
            integrator_cache_shape=integrator_cache_shape,
            integrator_cache_chunks=integrator_cache_chunks,
            store_metadata_attrs=store_metadata_attrs,
        )

TripleSHIntegrator

A class for performing integrals of the products of three functions on the sphere expressed as their spherical harmonic transformations.

This class provides methods for this integration performed with a variety of techniques using Gaunt coefficients and grid-based approaches. It supports caching for efficient repeated computations when changing only one of the functions. Additionally it allows for computing the Fourier transform of these integrals in an azimuthal rotation angle of one of the functions (always the 1st) around the others, ie. the \(m\)-modes.

That is,

\[ \begin{align} \mathcal{V} &= \int\diff\Omega~f_1(\dirvec)f_2(\dirvec)f_3(\dirvec) \\ &= \sum_{\substack{\ell_1 \ell_2 \ell_3 \\ m_1 m_2 m_3}} a_{\ell_1 m_1}a_{\ell_2 m_2}a_{\ell_3 m_3} \int\diff\Omega~Y_{\ell_1 m_1}(\dirvec) Y_{\ell_2 m_2}(\dirvec) Y_{\ell_3 m_3}(\dirvec), \end{align} \]

where

\[ f_1(\dirvec) = \sum_{\ell_1=0}^{l_{1,\mathrm{max}}} \sum_{m_1=-\ell_1}^{\ell_1} a_{\ell_1 m_1} Y_{\ell_1 m_1}(\dirvec) \]

etc.

For the \(m\)-modes:

\[ \begin{align} \mathcal{V}_{m_1} &= \sum_{\substack{\ell_1 \ell_2 \ell_3 \\ m_2 m_3}} a_{\ell_1 m_1}a_{\ell_2 m_2}a_{\ell_3 m_3} \int\diff\Omega~Y_{\ell_1 m_1}(\dirvec) Y_{\ell_2 m_2}(\dirvec) Y_{\ell_3 m_3}(\dirvec), \\ &= \int \frac{\diff\phi}{2\pi} \exp[-im_1\phi] \int\diff\Omega~f_1( \matr{\mathcal{R}}_Z(-\phi)\dirvec)f_2(\dirvec)f_3(\dirvec) \end{align} \]

where \(\matr{\mathcal{R}}_Z(-\phi)\) is a passive basis rotation about the polar axis of the sphere.

Notes

The index of the functions only matters in differentiating from the others the first function/set of coefficients as the integration can be split up in \(m_1\) values. Additionally integrals can be performed rotating this function azimuthally around the others. That is, for the purposes of this code, this represents the sky.

Parameters:

Name Type Description Default
l1max int

Maximum spherical harmonic degree for the first set of coefficients.

required
l2max int

Maximum spherical harmonic degree for the second set of coefficients.

required
l3max int

Maximum spherical harmonic degree for the third set of coefficients.

required
absm1_limits tuple[int, int | None]

Inclusive lower and exclusive upper limits for the absolute value of the m1 index. Defaults to (0, None), ie, all m1 values.

(0, None)
generate_cache_on_init bool

Whether to generate the Gaunt coefficient cache during initialization, by default False.

False

Attributes:

Name Type Description
m1_values list[int]

List of m1 values that integrations are performed for.

m1_index_map dict[int, int]

Mapping of m1 values to their indices in a completed set of m1 values.

linear_integrator_cache_12 ndarray[tuple[Any, ...], dtype[complex128]] | None
linear_integrator_cache_23 ndarray[tuple[Any, ...], dtype[complex128]] | None
gaunt_cache CachingGauntContractor | CachingGauntContractorOpt12 | None
m1_global_index list[int]

Methods:

Name Description
generate_gaunt_cache

Generates the Gaunt coefficient cache.

clear_gaunt_cache

Clears the Gaunt coefficient cache.

grid_integrate

Performs integration using a grid-based approach.

gaunt_integrate

Performs integration using a direct sum over precomputed Gaunt coefficients.

linear_gaunt_integrator_12

Computes a linear integrator for fixed first and second sets of coefficients.

generate_integrator_cache_12

batch_parallel_mode="channel") Generates and caches the linear integrator for fixed first and second sets of coefficients.

batch_gaunt_integrate_cached_12

Uses a integrator cache for fixed first and second sets of coefficients to compute integrals over a batch of the third set of coefficients.

linear_gaunt_integrator_23

Computes a linear integrator for fixed second and third sets of coefficients.

generate_integrator_cache_23

Generates and caches the linear integrator for fixed second and third sets of coefficients.

batch_gaunt_integrate_cached_12

Uses a integrator cache for fixed second and third sets of coefficients to compute integrals over a bacth of the first set of coefficients.

Source code in src/serval/core.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
@define(eq=False)
class TripleSHIntegrator:
    r"""
    A class for performing integrals of the products of three functions on the
    sphere expressed as their spherical harmonic transformations.

    This class provides methods for this integration performed with a variety
    of techniques using Gaunt coefficients and grid-based approaches. It
    supports caching for efficient repeated computations when changing only
    one of the functions. Additionally it allows for computing the Fourier
    transform of these integrals in an azimuthal rotation angle of one of
    the functions (always the 1st) around the others, ie. the $m$-modes.

    That is,

    $$
    \begin{align}
    \mathcal{V} &= \int\diff\Omega~f_1(\dirvec)f_2(\dirvec)f_3(\dirvec) \\
                &= \sum_{\substack{\ell_1 \ell_2 \ell_3 \\ m_1 m_2 m_3}}
                  a_{\ell_1 m_1}a_{\ell_2 m_2}a_{\ell_3 m_3}
                  \int\diff\Omega~Y_{\ell_1 m_1}(\dirvec)
                  Y_{\ell_2 m_2}(\dirvec) Y_{\ell_3 m_3}(\dirvec),
    \end{align}
    $$

    where

    $$
    f_1(\dirvec) = \sum_{\ell_1=0}^{l_{1,\mathrm{max}}}
                   \sum_{m_1=-\ell_1}^{\ell_1} a_{\ell_1 m_1} Y_{\ell_1 m_1}(\dirvec)
    $$

    etc.

    For the $m$-modes:

    $$
    \begin{align}
    \mathcal{V}_{m_1} &= \sum_{\substack{\ell_1 \ell_2 \ell_3 \\ m_2 m_3}}
                        a_{\ell_1 m_1}a_{\ell_2 m_2}a_{\ell_3 m_3}
                        \int\diff\Omega~Y_{\ell_1 m_1}(\dirvec)
                        Y_{\ell_2 m_2}(\dirvec) Y_{\ell_3 m_3}(\dirvec), \\
                      &=
                        \int \frac{\diff\phi}{2\pi} \exp[-im_1\phi] \int\diff\Omega~f_1(
                        \matr{\mathcal{R}}_Z(-\phi)\dirvec)f_2(\dirvec)f_3(\dirvec)
    \end{align}
    $$

    where $\matr{\mathcal{R}}_Z(-\phi)$ is a passive basis rotation about the polar axis
    of the sphere.

    Notes
    -----
    The index of the functions only matters in differentiating from the others the first
    function/set of coefficients as the integration can be split up in $m_1$ values.
    Additionally integrals can be performed rotating this function azimuthally around
    the others. That is, for the purposes of this code, this represents the sky.


    Parameters
    ----------
    l1max : int
        Maximum spherical harmonic degree for the first set of coefficients.
    l2max : int
        Maximum spherical harmonic degree for the second set of coefficients.
    l3max : int
        Maximum spherical harmonic degree for the third set of coefficients.
    absm1_limits : tuple[int, int | None], optional
        Inclusive lower and exclusive upper limits for the absolute value of
        the m1 index. Defaults to (0, None), ie, all m1 values.
    generate_cache_on_init : bool, optional
        Whether to generate the Gaunt coefficient cache during initialization,
        by default False.

    Attributes
    ----------
    l1max : int
        Maximum spherical harmonic degree for the first set of coefficients.
    l2max : int
        Maximum spherical harmonic degree for the second set of coefficients.
    l3max : int
        Maximum spherical harmonic degree for the third set of coefficients.
    absm1_limits : tuple[int, int | None]
        Inclusive lower and exclusive upper limits for the absolute value of
        the m1 index.
    generate_cache_on_init : bool
        Whether to generate the Gaunt coefficient cache during initialization.
    m1_values : list[int]
        List of m1 values that integrations are performed for.
    m1_index_map : dict[int, int]
        Mapping of m1 values to their indices in a completed set of m1 values.

    Methods
    -------
    generate_gaunt_cache(cache_type=CachingGauntContractor)
        Generates the Gaunt coefficient cache.
    clear_gaunt_cache()
        Clears the Gaunt coefficient cache.
    grid_integrate(alm1, alm2, alm3, sum_m1=False)
        Performs integration using a grid-based approach.
    gaunt_integrate(alm1, alm2, alm3, sum_m1=False)
        Performs integration using a direct sum over precomputed Gaunt coefficients.
    linear_gaunt_integrator_12(alm1, alm2, contract3=None, sum_m1=False)
        Computes a linear integrator for fixed first and second sets of coefficients.
    generate_integrator_cache_12(alm1, alm2, contract3=None, release_gaunt_cache=True,
                                 batch_parallel_mode="channel")
        Generates and caches the linear integrator for fixed first and second sets of
        coefficients.
    batch_gaunt_integrate_cached_12(alm3, integrator_cache=None, sum_m1=False)
        Uses a integrator cache for fixed first and second sets of coefficients to
        compute integrals over a batch of the third set of coefficients.
    linear_gaunt_integrator_23(alm2, alm3, sum_m1=False)
        Computes a linear integrator for fixed second and third sets of coefficients.
    generate_integrator_cache_23(alm2, alm3)
        Generates and caches the linear integrator for fixed second and third sets of
        coefficients.
    batch_gaunt_integrate_cached_12(alm1, integrator_cache=None, sum_m1=False)
        Uses a integrator cache for fixed second and third sets of coefficients to
        compute integrals over a bacth of the first set of coefficients.
    """

    l1max: int
    l2max: int
    l3max: int

    absm1_limits: tuple[int, int | None] = (0, None)
    generate_cache_on_init: bool = False

    linear_integrator_cache_12: npt.NDArray[np.complex128] | None = field(
        init=False, default=None
    )
    linear_integrator_cache_23: npt.NDArray[np.complex128] | None = field(
        init=False, default=None
    )
    gaunt_cache: CachingGauntContractor | CachingGauntContractorOpt12 | None = field(
        init=False, default=None
    )
    m1_values: list[int] = field(init=False)
    m1_index_map: dict[int, int] = field(init=False)
    m1_global_index: list[int] = field(init=False)

    _cache_types: ClassVar[
        dict[str, type[CachingGauntContractor] | type[CachingGauntContractorOpt12]]
    ] = {
        "opt12": CachingGauntContractorOpt12,
        "generic": CachingGauntContractor,
    }

    _process_contract: ClassVar[dict[str, Callable]] = {
        "gaunt-opt": contract_transpose,
        "channel-opt": contract_standard,
        "channel": contract_standard,
        "gaunt": contract_standard,
    }

    def __attrs_post_init__(self):
        if self.absm1_limits[1] is None:
            self.absm1_limits = (self.absm1_limits[0], self.l1max + 1)
        if self.generate_cache_on_init:
            self.generate_gaunt_cache()
        self.m1_values, self.m1_index_map = m1_indexing(
            self.l1max, self.absm1_limits[0], self.absm1_limits[1]
        )
        self.m1_global_index = list(self.m1_index_map.keys())

    def generate_gaunt_cache(
        self, cache_type_tag: Literal["opt12", "generic"] = "generic"
    ):
        """Generates the Gaunt coefficient cache."""
        self.gaunt_cache = self._cache_types[cache_type_tag](
            self.l1max,
            self.l2max,
            self.l3max,
            absm1_lower=self.absm1_limits[0],
            absm1_upper=self.absm1_limits[1],
        )

    def clear_gaunt_cache(self):
        """Clears the Gaunt coefficient cache."""
        self.gaunt_cache = None

    def grid_integrate(
        self,
        alm1: npt.NDArray[np.complex128],
        alm2: npt.NDArray[np.complex128],
        alm3: npt.NDArray[np.complex128],
        sum_m1: bool = False,
    ) -> npt.NDArray[np.complex128]:
        r"""Perform the triple integral using a grid-based approach.

        Here the product of the second and third fields are evaluated on a
        consistent resolution grid, decomposed to conjugate spherical
        harmonics and multipled by the first set of coefficients. This more
        standard m-mode approach is performant for single integrals
        but, as implemented here, can't cache intermediate results and
        therefore not suitable for batching.


        Parameters
        ---------
        alm1 : npt.NDArray[np.complex128]
            The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).
        alm2 : npt.NDArray[np.complex128]
            The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).
        alm3 : npt.NDArray[np.complex128]
            The third set of spherical harmonic coefficients with shape (l3max+1, 2*l3max+1).
        sum_m1 : bool, optional
            Whether to sum over the m1's before output, by default False.

        Returns
        -------
        npt.NDArray[np.complex128] | np.complex128
            If sum_m1 = False, Nm1 array of the m-modes, otherwise, scalar
            sum over all m-modes.
        """
        grid_lmax = int(np.ceil(self.l1max + self.l2max + self.l3max))
        alm1, alm2, alm3 = broadcast_bandlimits(alm1, alm2, alm3, lmax=grid_lmax)
        static_grid = grid_template(grid_lmax)
        static_grid.data[:] = 1.0
        static_grid *= array_synthesis(alm2) * array_synthesis(alm3)
        static_alm = analysis(static_grid, conjugate=True)
        out_mmodes = compute_mmodes(static_alm, alm1)
        return out_mmodes[np.asarray(self.m1_values) + grid_lmax]

    def gaunt_integrate(
        self,
        alm1: npt.NDArray[np.complex128],
        alm2: npt.NDArray[np.complex128],
        alm3: npt.NDArray[np.complex128],
        sum_m1: bool = False,
    ) -> npt.NDArray[np.complex128] | float:
        r"""Perform the triple integral by doing and inplace
        sum-product over gaunt coefficients.

        $$
        \mathcal{V} = \sum_{\substack{\ell_1 \ell_2 \ell_3 \\ m_1 m_2 m_3}}
                    \mathcal{G}^{~~\ell_1~~\ell_2~~\ell_3}_{m_1 m_2 m_3}~
                    a_{\ell_1 m_1}a_{\ell_2 m_2}a_{\ell_3 m_3}.
        $$

        Here the sum is performed inplace with no caching.

        Parameters
        ----------
        alm1 : npt.NDArray[np.complex128]
            The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).
        alm2 : npt.NDArray[np.complex128]
            The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).
        alm3 : npt.NDArray[np.complex128]
            The third set of spherical harmonic coefficients with shape (l3max+1, 2*l3max+1).
        sum_m1 : bool, optional
            Whether to sum over the m1's before output, by default False.

        Returns
        -------
        npt.NDArray[np.complex128] | np.complex128
            If sum_m1 = False, Nm1 array of the m-modes, otherwise, scalar
            sum over all m-modes.
        """
        return gaunt_dot123(
            alm1=alm1,
            alm2=alm2,
            alm3=alm3,
            sum_m1=sum_m1,
            absm1_lower=self.absm1_limits[0],
            absm1_upper=self.absm1_limits[1],
        )

    def linear_gaunt_integrator_12(
        self,
        alm1: npt.NDArray[np.complex128],
        alm2: npt.NDArray[np.complex128],
        contract3: npt.NDArray[np.complex128] | None = None,
        sum_m1: bool = False,
    ) -> npt.NDArray[np.complex128]:
        r"""Generate a linear operator that, with fixed first and second set of coefficients
        performs the integral when sum-producted with the third set of coefficients.

        $$
        \matr{L}_{m_1}^{\ell_3 m_3} =
                    \sum_{\substack{\ell_1 \ell_2 \\ m_1 m_2}}
                    \mathcal{G}^{~~\ell_1~~\ell_2~~\ell_3}_{m_1 m_2 m_3}~
                    a_{\ell_1 m_1}a_{\ell_2 m_2}.
        $$

        If requested, additionally contract with a linear operator along the
        m3 axis (e.g. a rotation with a Wigner-d matrix),

        $$
        \matr{L}_{m_1}^{\ell_3 m^\prime_3} = \sum_{m_3} \matr{W}^{\ell_3 m^\prime_3}_{m_3}
                                             \matr{L}_{m_1}^{\ell_3 m_3}
        $$

        Parameters
        ----------
        alm1 : npt.NDArray[np.complex128]
            The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).
        alm2 : npt.NDArray[np.complex128]
            The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).
        contract3 : npt.NDArray[np.complex128] | None, optional
            An optional linear operator along the m3 axis, shape
            (l3max+1, 2*l3max+1, Nm3prime<=2*l3max+1), by default None
        sum_m1 : bool, optional
            Whether to sum over the m1's before output, by default False.

        Returns
        -------
        npt.NDArray[np.complex128]
            By default (Nm1, Nl3, nm3) linear operator. If sum_m1=True, an (Nl3, Nm3) array
            where the m1 axis has been summed over. If contract3 is present, this will instead
            be an (Nm1, Nl3, Nm3prime) or (Nl3, Nm3prime) array resulting from the subsequent
            contraction on the m3 axis.
        """
        tensor = gaunt_dot12(
            alm1=alm1,
            alm2=alm2,
            l3max=self.l3max,
            sum_m1=sum_m1,
            absm1_lower=self.absm1_limits[0],
            absm1_upper=self.absm1_limits[1],
        )
        if contract3 is not None:
            return integrator12_contract3(tensor, contract3)
        else:
            return tensor

    def generate_integrator_cache_12(
        self,
        alm1: npt.NDArray[np.complex128],
        alm2: npt.NDArray[np.complex128],
        contract3: npt.NDArray[np.complex128] | None = None,
        release_gaunt_cache: bool = True,
        batch_parallel_mode: Literal[
            "channel-opt", "channel", "gaunt", "gaunt-opt"
        ] = "channel-opt",
        alm1_transposed: npt.NDArray[np.complex128] | None = None,
        alm2_transposed: npt.NDArray[np.complex128] | None = None,
    ):
        """_summary_

        Parameters
        ----------
        alm1 : npt.NDArray[np.complex128]
            _description_
        alm2 : npt.NDArray[np.complex128]
            _description_
        contract3 : npt.NDArray[np.complex128] | None, optional
            _description_, by default None
        release_gaunt_cache : bool, optional
            _description_, by default True
        batch_parallel_mode : Literal["channel-opt", "channel", "gaunt", "gaunt-opt"], optional
            _description_, by default "channel-opt"
        alm1_transposed : npt.NDArray[np.complex128] | None, optional
            Pre-transposed alm1 in (m, l, batch) layout, by default None
        alm2_transposed : npt.NDArray[np.complex128] | None, optional
            Pre-transposed alm2 in (m, l, batch) layout, by default None
        """
        cache_type_tag: Literal["opt12", "generic"] = (
            "opt12" if batch_parallel_mode in ("channel-opt", "gaunt-opt") else "generic"
        )
        if self.gaunt_cache is None:
            self.generate_gaunt_cache(cache_type_tag)
        if not isinstance(self.gaunt_cache, self._cache_types[cache_type_tag]):
            raise TypeError(
                f"gaunt_cache must be a {self._cache_types[cache_type_tag]} object."
            )
        alm1 = extend_dimensions_if_one_batch(alm1, 3)
        alm2 = extend_dimensions_if_one_batch(alm2, 3)
        batch_size = alm1.shape[0]
        n_m1 = len(self.m1_values)
        match batch_parallel_mode:
            case "gaunt-opt":
                result = empty_complex_array((n_m1, self.l3max + 1, 2 * self.l3max + 1, batch_size))
                alm1_T = transpose_arr(alm1, (2, 1, 0), alm1_transposed)
                alm2_T = transpose_arr(alm2, (2, 1, 0), alm2_transposed)
                self.gaunt_cache.gaunt_opt_batch_dot12(alm1_T, alm2_T, result)
            case "channel-opt" | "channel":
                result = empty_complex_array((batch_size, n_m1, self.l3max + 1, 2 * self.l3max + 1))
                alm1_T = transpose_arr(alm1, (0, 2, 1))
                alm2_T = transpose_arr(alm2, (0, 2, 1))
                self.gaunt_cache.batch_parallel_batch_dot12(alm1_T, alm2_T, result)
            case _:
                result = empty_complex_array((batch_size, n_m1, self.l3max + 1, 2 * self.l3max + 1))
                self.gaunt_cache.gaunt_parallel_batch_dot12(alm1, alm2, result)
        result = self._process_contract[batch_parallel_mode](result, contract3)
        if release_gaunt_cache:
            self.clear_gaunt_cache()
        self.linear_integrator_cache_12 = (result[0, ...] if result.shape[0] == 1 else result)

    def batch_gaunt_integrate_cached_12(
        self,
        alm3: npt.NDArray[np.complex128],
        integrator_cache: npt.NDArray[np.complex128] | None = None,
        sum_m1: bool = False,
    ):
        if integrator_cache is None:
            if self.linear_integrator_cache_12 is None:
                raise ValueError("Integrator cache must be provided or pre-computed")
            else:
                tensor = self.linear_integrator_cache_12
        else:
            tensor = integrator_cache
        result = integrator12_dot3(tensor, alm3)
        if sum_m1:
            return np.sum(result, axis=0)
        else:
            return result

    def linear_gaunt_integrator_23(
        self,
        alm2: npt.NDArray[np.complex128],
        alm3: npt.NDArray[np.complex128],
        sum_m1: bool = False,
    ) -> npt.NDArray[np.complex128]:
        return gaunt_dot23(
            alm2=alm2,
            alm3=alm3,
            l1max=self.l1max,
            sum_m1=sum_m1,
            absm1_lower=self.absm1_limits[0],
            absm1_upper=self.absm1_limits[1],
        )

    def generate_integrator_cache_23(
        self,
        alm2: npt.NDArray[np.complex128],
        alm3: npt.NDArray[np.complex128],
        release_gaunt_cache: bool = True,
        batch_parallel_mode: Literal["channel", "gaunt"] = "channel",
    ):
        if self.gaunt_cache is None:
            self.generate_gaunt_cache()
        if not isinstance(self.gaunt_cache, CachingGauntContractor):
            raise TypeError("gaunt_cache must be a CachingGauntContractor object.")
        alm2 = extend_dimensions_if_one_batch(alm2, 3)
        alm3 = extend_dimensions_if_one_batch(alm3, 3)
        result = empty_complex_array((alm2.shape[0], len(self.m1_values), self.l1max + 1))
        if batch_parallel_mode == "channel":
            self.gaunt_cache.batch_parallel_batch_dot23(alm2, alm3, result)
        else:
            self.gaunt_cache.gaunt_parallel_batch_dot23(alm2, alm3, result)
        if release_gaunt_cache:
            self.clear_gaunt_cache()
        self.linear_integrator_cache_23 = (result[0, ...] if result.shape[0] == 1 else result)

    def batch_gaunt_integrate_cached_23(
        self,
        alm1: npt.NDArray[np.complex128],
        integrator_cache: npt.NDArray[np.complex128] | None = None,
        sum_m1: bool = False,
    ):
        if integrator_cache is None:
            if self.linear_integrator_cache_23 is None:
                raise ValueError("Integrator cache must be provided or pre-computed")
            else:
                tensor = self.linear_integrator_cache_23
        else:
            tensor = integrator_cache
        result = integrator23_dot1(tensor, alm1)
        if sum_m1:
            return np.sum(result, axis=0)
        else:
            return result

clear_gaunt_cache()

Clears the Gaunt coefficient cache.

Source code in src/serval/core.py
def clear_gaunt_cache(self):
    """Clears the Gaunt coefficient cache."""
    self.gaunt_cache = None

gaunt_integrate(alm1, alm2, alm3, sum_m1=False)

Perform the triple integral by doing and inplace sum-product over gaunt coefficients.

\[ \mathcal{V} = \sum_{\substack{\ell_1 \ell_2 \ell_3 \\ m_1 m_2 m_3}} \mathcal{G}^{~~\ell_1~~\ell_2~~\ell_3}_{m_1 m_2 m_3}~ a_{\ell_1 m_1}a_{\ell_2 m_2}a_{\ell_3 m_3}. \]

Here the sum is performed inplace with no caching.

Parameters:

Name Type Description Default
alm1 NDArray[complex128]

The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).

required
alm2 NDArray[complex128]

The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).

required
alm3 NDArray[complex128]

The third set of spherical harmonic coefficients with shape (l3max+1, 2*l3max+1).

required
sum_m1 bool

Whether to sum over the m1's before output, by default False.

False

Returns:

Type Description
NDArray[complex128] | complex128

If sum_m1 = False, Nm1 array of the m-modes, otherwise, scalar sum over all m-modes.

Source code in src/serval/core.py
def gaunt_integrate(
    self,
    alm1: npt.NDArray[np.complex128],
    alm2: npt.NDArray[np.complex128],
    alm3: npt.NDArray[np.complex128],
    sum_m1: bool = False,
) -> npt.NDArray[np.complex128] | float:
    r"""Perform the triple integral by doing and inplace
    sum-product over gaunt coefficients.

    $$
    \mathcal{V} = \sum_{\substack{\ell_1 \ell_2 \ell_3 \\ m_1 m_2 m_3}}
                \mathcal{G}^{~~\ell_1~~\ell_2~~\ell_3}_{m_1 m_2 m_3}~
                a_{\ell_1 m_1}a_{\ell_2 m_2}a_{\ell_3 m_3}.
    $$

    Here the sum is performed inplace with no caching.

    Parameters
    ----------
    alm1 : npt.NDArray[np.complex128]
        The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).
    alm2 : npt.NDArray[np.complex128]
        The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).
    alm3 : npt.NDArray[np.complex128]
        The third set of spherical harmonic coefficients with shape (l3max+1, 2*l3max+1).
    sum_m1 : bool, optional
        Whether to sum over the m1's before output, by default False.

    Returns
    -------
    npt.NDArray[np.complex128] | np.complex128
        If sum_m1 = False, Nm1 array of the m-modes, otherwise, scalar
        sum over all m-modes.
    """
    return gaunt_dot123(
        alm1=alm1,
        alm2=alm2,
        alm3=alm3,
        sum_m1=sum_m1,
        absm1_lower=self.absm1_limits[0],
        absm1_upper=self.absm1_limits[1],
    )

generate_gaunt_cache(cache_type_tag='generic')

Generates the Gaunt coefficient cache.

Source code in src/serval/core.py
def generate_gaunt_cache(
    self, cache_type_tag: Literal["opt12", "generic"] = "generic"
):
    """Generates the Gaunt coefficient cache."""
    self.gaunt_cache = self._cache_types[cache_type_tag](
        self.l1max,
        self.l2max,
        self.l3max,
        absm1_lower=self.absm1_limits[0],
        absm1_upper=self.absm1_limits[1],
    )

generate_integrator_cache_12(alm1, alm2, contract3=None, release_gaunt_cache=True, batch_parallel_mode='channel-opt', alm1_transposed=None, alm2_transposed=None)

summary

Parameters:

Name Type Description Default
alm1 NDArray[complex128]

description

required
alm2 NDArray[complex128]

description

required
contract3 NDArray[complex128] | None

description, by default None

None
release_gaunt_cache bool

description, by default True

True
batch_parallel_mode Literal['channel-opt', 'channel', 'gaunt', 'gaunt-opt']

description, by default "channel-opt"

'channel-opt'
alm1_transposed NDArray[complex128] | None

Pre-transposed alm1 in (m, l, batch) layout, by default None

None
alm2_transposed NDArray[complex128] | None

Pre-transposed alm2 in (m, l, batch) layout, by default None

None
Source code in src/serval/core.py
def generate_integrator_cache_12(
    self,
    alm1: npt.NDArray[np.complex128],
    alm2: npt.NDArray[np.complex128],
    contract3: npt.NDArray[np.complex128] | None = None,
    release_gaunt_cache: bool = True,
    batch_parallel_mode: Literal[
        "channel-opt", "channel", "gaunt", "gaunt-opt"
    ] = "channel-opt",
    alm1_transposed: npt.NDArray[np.complex128] | None = None,
    alm2_transposed: npt.NDArray[np.complex128] | None = None,
):
    """_summary_

    Parameters
    ----------
    alm1 : npt.NDArray[np.complex128]
        _description_
    alm2 : npt.NDArray[np.complex128]
        _description_
    contract3 : npt.NDArray[np.complex128] | None, optional
        _description_, by default None
    release_gaunt_cache : bool, optional
        _description_, by default True
    batch_parallel_mode : Literal["channel-opt", "channel", "gaunt", "gaunt-opt"], optional
        _description_, by default "channel-opt"
    alm1_transposed : npt.NDArray[np.complex128] | None, optional
        Pre-transposed alm1 in (m, l, batch) layout, by default None
    alm2_transposed : npt.NDArray[np.complex128] | None, optional
        Pre-transposed alm2 in (m, l, batch) layout, by default None
    """
    cache_type_tag: Literal["opt12", "generic"] = (
        "opt12" if batch_parallel_mode in ("channel-opt", "gaunt-opt") else "generic"
    )
    if self.gaunt_cache is None:
        self.generate_gaunt_cache(cache_type_tag)
    if not isinstance(self.gaunt_cache, self._cache_types[cache_type_tag]):
        raise TypeError(
            f"gaunt_cache must be a {self._cache_types[cache_type_tag]} object."
        )
    alm1 = extend_dimensions_if_one_batch(alm1, 3)
    alm2 = extend_dimensions_if_one_batch(alm2, 3)
    batch_size = alm1.shape[0]
    n_m1 = len(self.m1_values)
    match batch_parallel_mode:
        case "gaunt-opt":
            result = empty_complex_array((n_m1, self.l3max + 1, 2 * self.l3max + 1, batch_size))
            alm1_T = transpose_arr(alm1, (2, 1, 0), alm1_transposed)
            alm2_T = transpose_arr(alm2, (2, 1, 0), alm2_transposed)
            self.gaunt_cache.gaunt_opt_batch_dot12(alm1_T, alm2_T, result)
        case "channel-opt" | "channel":
            result = empty_complex_array((batch_size, n_m1, self.l3max + 1, 2 * self.l3max + 1))
            alm1_T = transpose_arr(alm1, (0, 2, 1))
            alm2_T = transpose_arr(alm2, (0, 2, 1))
            self.gaunt_cache.batch_parallel_batch_dot12(alm1_T, alm2_T, result)
        case _:
            result = empty_complex_array((batch_size, n_m1, self.l3max + 1, 2 * self.l3max + 1))
            self.gaunt_cache.gaunt_parallel_batch_dot12(alm1, alm2, result)
    result = self._process_contract[batch_parallel_mode](result, contract3)
    if release_gaunt_cache:
        self.clear_gaunt_cache()
    self.linear_integrator_cache_12 = (result[0, ...] if result.shape[0] == 1 else result)

grid_integrate(alm1, alm2, alm3, sum_m1=False)

Perform the triple integral using a grid-based approach.

Here the product of the second and third fields are evaluated on a consistent resolution grid, decomposed to conjugate spherical harmonics and multipled by the first set of coefficients. This more standard m-mode approach is performant for single integrals but, as implemented here, can't cache intermediate results and therefore not suitable for batching.

Parameters:

Name Type Description Default
alm1 NDArray[complex128]

The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).

required
alm2 NDArray[complex128]

The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).

required
alm3 NDArray[complex128]

The third set of spherical harmonic coefficients with shape (l3max+1, 2*l3max+1).

required
sum_m1 bool

Whether to sum over the m1's before output, by default False.

False

Returns:

Type Description
NDArray[complex128] | complex128

If sum_m1 = False, Nm1 array of the m-modes, otherwise, scalar sum over all m-modes.

Source code in src/serval/core.py
def grid_integrate(
    self,
    alm1: npt.NDArray[np.complex128],
    alm2: npt.NDArray[np.complex128],
    alm3: npt.NDArray[np.complex128],
    sum_m1: bool = False,
) -> npt.NDArray[np.complex128]:
    r"""Perform the triple integral using a grid-based approach.

    Here the product of the second and third fields are evaluated on a
    consistent resolution grid, decomposed to conjugate spherical
    harmonics and multipled by the first set of coefficients. This more
    standard m-mode approach is performant for single integrals
    but, as implemented here, can't cache intermediate results and
    therefore not suitable for batching.


    Parameters
    ---------
    alm1 : npt.NDArray[np.complex128]
        The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).
    alm2 : npt.NDArray[np.complex128]
        The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).
    alm3 : npt.NDArray[np.complex128]
        The third set of spherical harmonic coefficients with shape (l3max+1, 2*l3max+1).
    sum_m1 : bool, optional
        Whether to sum over the m1's before output, by default False.

    Returns
    -------
    npt.NDArray[np.complex128] | np.complex128
        If sum_m1 = False, Nm1 array of the m-modes, otherwise, scalar
        sum over all m-modes.
    """
    grid_lmax = int(np.ceil(self.l1max + self.l2max + self.l3max))
    alm1, alm2, alm3 = broadcast_bandlimits(alm1, alm2, alm3, lmax=grid_lmax)
    static_grid = grid_template(grid_lmax)
    static_grid.data[:] = 1.0
    static_grid *= array_synthesis(alm2) * array_synthesis(alm3)
    static_alm = analysis(static_grid, conjugate=True)
    out_mmodes = compute_mmodes(static_alm, alm1)
    return out_mmodes[np.asarray(self.m1_values) + grid_lmax]

linear_gaunt_integrator_12(alm1, alm2, contract3=None, sum_m1=False)

Generate a linear operator that, with fixed first and second set of coefficients performs the integral when sum-producted with the third set of coefficients.

\[ \matr{L}_{m_1}^{\ell_3 m_3} = \sum_{\substack{\ell_1 \ell_2 \\ m_1 m_2}} \mathcal{G}^{~~\ell_1~~\ell_2~~\ell_3}_{m_1 m_2 m_3}~ a_{\ell_1 m_1}a_{\ell_2 m_2}. \]

If requested, additionally contract with a linear operator along the m3 axis (e.g. a rotation with a Wigner-d matrix),

\[ \matr{L}_{m_1}^{\ell_3 m^\prime_3} = \sum_{m_3} \matr{W}^{\ell_3 m^\prime_3}_{m_3} \matr{L}_{m_1}^{\ell_3 m_3} \]

Parameters:

Name Type Description Default
alm1 NDArray[complex128]

The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).

required
alm2 NDArray[complex128]

The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).

required
contract3 NDArray[complex128] | None

An optional linear operator along the m3 axis, shape (l3max+1, 2l3max+1, Nm3prime<=2l3max+1), by default None

None
sum_m1 bool

Whether to sum over the m1's before output, by default False.

False

Returns:

Type Description
NDArray[complex128]

By default (Nm1, Nl3, nm3) linear operator. If sum_m1=True, an (Nl3, Nm3) array where the m1 axis has been summed over. If contract3 is present, this will instead be an (Nm1, Nl3, Nm3prime) or (Nl3, Nm3prime) array resulting from the subsequent contraction on the m3 axis.

Source code in src/serval/core.py
def linear_gaunt_integrator_12(
    self,
    alm1: npt.NDArray[np.complex128],
    alm2: npt.NDArray[np.complex128],
    contract3: npt.NDArray[np.complex128] | None = None,
    sum_m1: bool = False,
) -> npt.NDArray[np.complex128]:
    r"""Generate a linear operator that, with fixed first and second set of coefficients
    performs the integral when sum-producted with the third set of coefficients.

    $$
    \matr{L}_{m_1}^{\ell_3 m_3} =
                \sum_{\substack{\ell_1 \ell_2 \\ m_1 m_2}}
                \mathcal{G}^{~~\ell_1~~\ell_2~~\ell_3}_{m_1 m_2 m_3}~
                a_{\ell_1 m_1}a_{\ell_2 m_2}.
    $$

    If requested, additionally contract with a linear operator along the
    m3 axis (e.g. a rotation with a Wigner-d matrix),

    $$
    \matr{L}_{m_1}^{\ell_3 m^\prime_3} = \sum_{m_3} \matr{W}^{\ell_3 m^\prime_3}_{m_3}
                                         \matr{L}_{m_1}^{\ell_3 m_3}
    $$

    Parameters
    ----------
    alm1 : npt.NDArray[np.complex128]
        The first set of spherical harmonic coefficients with shape (l1max+1, Nm1).
    alm2 : npt.NDArray[np.complex128]
        The second set of spherical harmonic coefficients with shape (l2max+1, 2*l2max+1).
    contract3 : npt.NDArray[np.complex128] | None, optional
        An optional linear operator along the m3 axis, shape
        (l3max+1, 2*l3max+1, Nm3prime<=2*l3max+1), by default None
    sum_m1 : bool, optional
        Whether to sum over the m1's before output, by default False.

    Returns
    -------
    npt.NDArray[np.complex128]
        By default (Nm1, Nl3, nm3) linear operator. If sum_m1=True, an (Nl3, Nm3) array
        where the m1 axis has been summed over. If contract3 is present, this will instead
        be an (Nm1, Nl3, Nm3prime) or (Nl3, Nm3prime) array resulting from the subsequent
        contraction on the m3 axis.
    """
    tensor = gaunt_dot12(
        alm1=alm1,
        alm2=alm2,
        l3max=self.l3max,
        sum_m1=sum_m1,
        absm1_lower=self.absm1_limits[0],
        absm1_upper=self.absm1_limits[1],
    )
    if contract3 is not None:
        return integrator12_contract3(tensor, contract3)
    else:
        return tensor

VisProjector

Parameters:

Name Type Description Default
latitude float
required
longitude float
required
baseline_enu tuple[float, float, float]
required
frequencies_MHz ndarray
required
power_beam_lmax int | None
None
baseline_lmax int | None
None
sky_lmax int | None
None
sky_absm_limits list[int | None]
[0, None]
generate_gaunt_cache_on_init bool
False
generate_baseline_cache_on_init bool
False
batch_parallel_mode Literal['channel-opt', 'channel', 'gaunt', 'gaunt-opt']
'channel'
cache_truncation_rtol float
0

Attributes:

Name Type Description
baseline_mmax int | None
baseline_cache ndarray | None
integrator_cache ndarray | None
triple_sh_integrator TripleSHIntegrator
Source code in src/serval/core.py
@define(eq=False)
class VisProjector:
    latitude: float
    longitude: float
    baseline_enu: tuple[float, float, float]
    frequencies_MHz: np.ndarray

    power_beam_lmax: int | None = None
    baseline_lmax: int | None = None
    baseline_mmax: int | None = field(init=False, default=None)
    sky_lmax: int | None = None

    sky_absm_limits: list[int | None] = field(factory=lambda: [0, None])

    generate_gaunt_cache_on_init: bool = False
    generate_baseline_cache_on_init: bool = False
    batch_parallel_mode: Literal["channel-opt", "channel", "gaunt", "gaunt-opt"] = "channel"
    cache_truncation_rtol: float = 0

    baseline_cache: np.ndarray | None = field(init=False, default=None)
    integrator_cache: np.ndarray | None = field(init=False, default=None)

    triple_sh_integrator: TripleSHIntegrator = field(init=False)

    _last_store_location: Path | None = field(init=False, default=None)
    _last_group_path: str | None = field(init=False, default=None)

    _to_attrs: ClassVar[list[str]] = []
    _to_zarr_store: ClassVar[list[str]] = []
    _opt_tag: ClassVar[str] = ""

    def update_integrator(self):
        self.triple_sh_integrator = TripleSHIntegrator(
            l1max=self.sky_lmax,
            l2max=self.baseline_lmax,
            l3max=self.power_beam_lmax,
            absm1_limits=self.sky_absm_limits,
            generate_cache_on_init=False,
        )

    def generate_gaunt_cache(self):
        if self.baseline_cache is None:
            self.generate_baseline_cache()
        self.update_integrator()
        if self.batch_parallel_mode in ("channel-opt", "gaunt-opt"):
            self.triple_sh_integrator.generate_gaunt_cache(self._opt_tag)
        else:
            self.triple_sh_integrator.generate_gaunt_cache("generic")

    @property
    def sky_m_values(self):
        return self.triple_sh_integrator.m1_values

    @property
    def sky_m_index_map(self):
        return self.triple_sh_integrator.m1_index_map

    @property
    def sky_m_global_index(self):
        return self.triple_sh_integrator.m1_global_index

    @property
    def sky_m_index_slices(self):
        m_inds = self.sky_m_global_index
        nm_inds = m_inds[: len(m_inds) // 2]
        if len(nm_inds) != 0:  # If ms == [0,]
            pm_inds = m_inds[len(m_inds) // 2 :]
            return (
                [slice(0, self.num_sky_ms // 2), slice(self.num_sky_ms // 2, None)],
                [
                    slice(nm_inds[0], nm_inds[-1] + 1),
                    slice(pm_inds[0], pm_inds[-1] + 1),
                ],
            )
        else:
            return [
                slice(0, 1),
            ], [
                slice(m_inds[0], m_inds[0] + 1),
            ]

    @property
    def num_sky_ms(self):
        return len(self.sky_m_values)

    def _setup_zarr_store(
        self,
        store_location: str | Path,
        integrator_cache_shape: tuple[int, ...],
        integrator_cache_chunks: tuple[int, ...],
        group_path: str = r"/",
        store_metadata_attrs: dict | None = None,
    ):
        to_attrs_dict = {slot: getattr(self, slot) for slot in self._to_attrs}
        to_zarr_store_dict = {slot: getattr(self, slot) for slot in self._to_zarr_store}

        destination_dir = Path(store_location)

        self._last_store_location = destination_dir
        self._last_group_path = group_path

        store = zarr.storage.LocalStore(destination_dir)
        grp = zarr.create_group(store=store, path=group_path, overwrite=True)

        assert self.sky_lmax is not None

        if store_metadata_attrs is not None:
            if group_path != r"/":  # coulf check this more thoroughly
                root = zarr.group(store=store, path="/")
                root.attrs.update(store_metadata_attrs)
            else:
                grp.attrs.update(store_metadata_attrs)

        for k, v in to_attrs_dict.items():
            grp.attrs[k] = v

        for k, v in to_zarr_store_dict.items():
            arr = grp.create_array(
                name=k,
                shape=v.shape,
                dtype=v.dtype,
                overwrite=True,
            )
            arr[:] = v

        ms_completed = grp.create_array(
            name="ms_completed",
            chunks=1,
            shape=2 * self.sky_lmax + 1,
            dtype=bool,
            overwrite=True,
        )
        ms_completed[:] = False

        grp.create_array(
            name="integrator_cache",
            shape=integrator_cache_shape,
            chunks=integrator_cache_chunks,
            dtype=np.complex128,
            overwrite=True,
            compressors=zarr.codecs.BloscCodec(**ZARR_COMPRESSORS),
        )

    def _prepare_integrator_cache_store_write(
        self, store_location: str | Path | None = None, group_path: str | None = None
    ) -> tuple[Path, str, list[tuple[slice, npt.NDArray[np.complex128]]]]:
        if self.integrator_cache is None:
            raise ValueError("Cannot store integrator_cache because it is None.")
        if store_location is None and self._last_store_location is not None:
            destination_dir = self._last_store_location
        elif store_location is not None:
            destination_dir = Path(store_location)
        else:
            raise ValueError("Store location must be specified.")
        if group_path is None and self._last_group_path is not None:
            group_path = self._last_group_path
        elif group_path is None:
            group_path = r"/"  # Default to root group
        _, global_m_slices = self.sky_m_index_slices
        cache = extend_dimensions_if_one_batch(
            self.integrator_cache, self.integrator_cache.ndim + (self.frequencies_MHz.size == 1)
        )
        _truncate_cache_inplace(cache, self.cache_truncation_rtol)
        prepared_writes = []
        local_start = 0
        for gslc in global_m_slices:
            width = gslc.stop - gslc.start
            local_stop = local_start + width
            prepared_writes.append((gslc, cache[:, local_start:local_stop]))
            local_start = local_stop
        if local_start != cache.shape[1]:
            raise ValueError("Global m slices do not match the local cache m-axis length.")
        return destination_dir, group_path, prepared_writes

    @staticmethod
    def _prepared_integrator_cache_write(
        destination_dir: Path,
        group_path: str,
        prepared_writes: list[tuple[slice, npt.NDArray[np.complex128]]],
    ) -> list[ts.WriteFutures]:
        group_store_path = destination_dir / group_path.strip("/")
        cache_path = group_store_path / "integrator_cache"
        integrator_cache = ts.open(
            {
                "driver": "zarr3",
                "kvstore": {"driver": "file", "path": str(cache_path)},
            },
            write=True,
            open=True,
        ).result()
        ms_completed = ts.open(
            {
                "driver": "zarr3",
                "kvstore": {
                    "driver": "file",
                    "path": str(group_store_path / "ms_completed"),
                },
            },
            write=True,
            open=True,
        ).result()
        futures = []
        for global_m_slice, truncated_cache in prepared_writes:
            future = integrator_cache[:, global_m_slice, ...].write(truncated_cache)

            def mark_completed_callback(write_future, gslc=global_m_slice):
                write_future.result()
                ms_completed[gslc].write(True).result()

            future.add_done_callback(mark_completed_callback)
            futures.append(future)
        return futures

    def write_integrator_cache_to_store(
        self, store_location: str | Path | None = None, group_path: str | None = None
    ) -> None:
        destination_dir, group_path, prepared_writes = (
            self._prepare_integrator_cache_store_write(store_location, group_path)
        )
        futures = self._prepared_integrator_cache_write(
            destination_dir, group_path, prepared_writes
        )
        for future in futures:
            future.result()

    def async_write_integrator_cache_to_store(
        self,
        store_location: str | Path | None = None,
        group_path: str | None = None,
    ) -> list[ts.WriteFutures]:
        destination_dir, group_path, prepared_writes = (
            self._prepare_integrator_cache_store_write(store_location, group_path)
        )
        return self._prepared_integrator_cache_write(destination_dir, group_path, prepared_writes)

    @classmethod
    def from_zarr_store(
        cls,
        store_location: str | Path,
        group_path: str = r"/",
        freq_slice: slice | None = None,
        sky_absm_limits=(0, None),
        load_cache=True,
    ):
        if freq_slice is None:
            freq_slice = slice(None, None, None)
        destination_dir = Path(store_location)
        store = zarr.storage.LocalStore(destination_dir, read_only=True)
        grp = zarr.open_group(store=store, path=group_path, mode="r")
        init_dict = {
            k: grp.attrs[k]
            for k in cls._to_attrs
            if k in grp.attrs
        }
        init_dict.update({k: grp[k][freq_slice] for k in cls._to_zarr_store})
        proj = cls(sky_absm_limits=sky_absm_limits, **init_dict)
        if load_cache:
            local_m_slices, global_m_slices = proj.sky_m_index_slices
            cache_shape = (
                proj.frequencies_MHz.size,
                len(proj.sky_m_values),
            ) + grp["integrator_cache"].shape[2:]
            proj.integrator_cache = empty_complex_array(cache_shape)
            cache_path = destination_dir / group_path.strip("/") / "integrator_cache"
            integrator_cache = ts.open(
                {
                    "driver": "zarr3",
                    "kvstore": {"driver": "file", "path": str(cache_path)},
                },
                read=True,
                open=True,
            ).result()
            for lslc, gslc in zip(local_m_slices, global_m_slices):
                proj.integrator_cache[:, lslc, ...] = integrator_cache[
                    freq_slice, gslc, ...
                ].read().result()
                if not grp["ms_completed"][gslc].all():
                    warnings.warn(
                        "Zarr Store indicates not all requested sky ms computed.",
                        stacklevel=2,
                    )  # points to caller
        return proj

contract_standard(result, contract3)

Standard multiplication of the integrator result with contract3. Applied in all types of cache except gaunt-opt.

Args: result (npt.NDArray[np.complex128]): integrator 12 contract3 (npt.NDArray[np.complex128] | None): contract array

Returns: npt.NDArray[np.complex128]: the multiplication of the arguments unless contract is None.

Source code in src/serval/core.py
def contract_standard(
    result: npt.NDArray[np.complex128], contract3: npt.NDArray[np.complex128] | None
) -> npt.NDArray[np.complex128]:
    """Standard multiplication of the integrator result with contract3.
    Applied in all types of cache except gaunt-opt.

    Args:
        result (npt.NDArray[np.complex128]): integrator 12
        contract3 (npt.NDArray[np.complex128] | None): contract array

    Returns:
        npt.NDArray[np.complex128]: the multiplication of the arguments unless contract is None.
    """
    if contract3 is None:
        return result
    return integrator12_contract3(result, contract3)

contract_transpose(result, contract3)

Multiplication of the integrator result with contract3 and transposition. Applied for gaunt-opt type of cache.

Args: result (npt.NDArray[np.complex128]): integrator 12 contract3 (npt.NDArray[np.complex128] | None): contract array

Returns: npt.NDArray[np.complex128]: the multiplication of the arguments if contract is not None. The transposed integrator 12.

Source code in src/serval/core.py
def contract_transpose(
    result: npt.NDArray[np.complex128], contract3: npt.NDArray[np.complex128] | None
) -> npt.NDArray[np.complex128]:
    """Multiplication of the integrator result with contract3 and transposition.
    Applied for gaunt-opt type of cache.

    Args:
        result (npt.NDArray[np.complex128]): integrator 12
        contract3 (npt.NDArray[np.complex128] | None): contract array

    Returns:
        npt.NDArray[np.complex128]: the multiplication of the arguments if contract is not None.
        The transposed integrator 12.
    """
    if contract3 is None:
        return transpose_batch_last_to_first(result)
    return integrator12_contract3_transpose(result, contract3)

extend_dimensions_if_one_batch(arr, dim)

If a single frequency is calculated then the alms and cache matrices will be missing the first dimension that corresponds to the number of frequencies. This function takes the arrays and adds a (1, ...) dimension

Parameters:

Name Type Description Default
arr NDArray[complex128]

The input array.

required
dim int

The desired number of dimensions.

required

Returns:

Type Description
NDArray[complex128]

The array with the correct number of dimensions.

Source code in src/serval/core.py
def extend_dimensions_if_one_batch(
    arr: npt.NDArray[np.complex128], dim: int
) -> npt.NDArray[np.complex128]:
    """If a single frequency is calculated then the alms and cache matrices will be missing
    the first dimension that corresponds to the number of frequencies. This function takes the
    arrays and adds a (1, ...) dimension
    Parameters
    ----------
    arr : npt.NDArray[np.complex128]
        The input array.
    dim : int
        The desired number of dimensions.
    Returns
    -------
    npt.NDArray[np.complex128]
        The array with the correct number of dimensions.
    """
    if arr.ndim < dim:
        arr = arr[None, ...]
    return arr

Rotation Utilities (serval.rotate.py)

Containers (serval.containers.py)

CSTBeamInterpolator

Reads CST farfield export files and builds a Ludwig-3 callable with spline interpolation in angle and linear interpolation in frequency.

Supported CST format

Only the CST 2D farfield export is supported, with header Theta [deg.] Phi [deg.] ..., where Theta ranges over \([-180, 180)\) and Phi over \([-90, 90]\). Other CST export formats (e.g. Theta \([0, 180]\), Phi \([0, 360)\)) are not guaranteed to work.

Parameters:

Name Type Description Default
file_mapping dict[float, str]

{freq_MHz: filepath} dict mapping each sampled frequency (in MHz) to the path of the corresponding CST farfield text file.

required
linearize_from_dB bool

If True (default), convert dB magnitude columns to linear voltage amplitude.

True
freq_interp_kind int | str

Interpolation order for the frequency axis. Passed as kind to [interp1d][scipy.interpolate.interp1d]. "linear" (default), "cubic", or an integer spline order.

'linear'

Attributes:

Name Type Description
frequencies ndarray[tuple[Any, ...], dtype[float64]]
Source code in src/serval/containers.py
@define
class CSTBeamInterpolator:
    r"""Reads CST farfield export files and builds a Ludwig-3 callable with spline
    interpolation in angle and linear interpolation in frequency.

    !!! note "Supported CST format"
        Only the CST **2D farfield** export is supported, with header
        ``Theta [deg.]  Phi [deg.]  ...``, where Theta ranges over
        $[-180, 180)$ and Phi over $[-90, 90]$.  Other CST export formats
        (e.g. Theta $[0, 180]$, Phi $[0, 360)$) are not guaranteed to work.

    Parameters
    ----------
    file_mapping
        ``{freq_MHz: filepath}`` dict mapping each sampled frequency (in MHz) to the
        path of the corresponding CST farfield text file.
    linearize_from_dB
        If ``True`` (default), convert dB magnitude columns to linear voltage amplitude.
    freq_interp_kind
        Interpolation order for the frequency axis.  Passed as ``kind`` to
        [interp1d][scipy.interpolate.interp1d].  ``"linear"`` (default), ``"cubic"``,
        or an integer spline order.
    """

    file_mapping: dict[float, str]
    linearize_from_dB: bool = True
    freq_interp_kind: int | str = "linear"
    frequencies: npt.NDArray[np.float64] = field(init=False)
    _splines: dict = field(init=False, repr=False)

    def __attrs_post_init__(self):
        self.frequencies = np.sort(np.array(list(self.file_mapping.keys())))
        self._splines = {
            f: self._read_file(self.file_mapping[f], self.linearize_from_dB)
            for f in self.frequencies
        }

    @staticmethod
    def _read_file(filepath, linearize_from_dB):
        """Parse one CST farfield file into four angular splines (co_re, co_im, cr_re, cr_im)."""
        data = np.genfromtxt(filepath, skip_header=2)
        theta_cst = data[:, 0]
        phi_cst = data[:, 1]

        mag_cr, ph_cr = data[:, 3], data[:, 4]
        mag_co, ph_co = data[:, 5], data[:, 6]

        if linearize_from_dB:
            mag_cr = 10 ** (mag_cr / 20)
            mag_co = 10 ** (mag_co / 20)

        J_co = mag_co * np.exp(1j * np.radians(ph_co))
        J_cr = mag_cr * np.exp(1j * np.radians(ph_cr))

        # CST 2D export: Theta is polar angle (0 = boresight), range [-180, 180).
        # Phi is azimuthal, range [-90, 90].  Negative theta folds over with a
        # 180° azimuth offset.
        is_positive = theta_cst >= 0
        colatitude = np.where(is_positive, theta_cst, -theta_cst)
        azimuth = np.where(is_positive, phi_cst, phi_cst + 180) % 360

        colat_step = np.round(np.diff(np.sort(np.unique(colatitude)))[0], decimals=6)
        azim_step = np.round(np.diff(np.sort(np.unique(azimuth)))[0], decimals=6)
        n_colat = int(round(180 / colat_step)) + 1
        n_azim = int(round(360 / azim_step))

        colat_idx = np.round(colatitude / colat_step).astype(int)
        azim_idx = np.round(azimuth / azim_step).astype(int) % n_azim

        grid_co = np.empty((n_colat, n_azim), dtype=complex)
        grid_cr = np.empty((n_colat, n_azim), dtype=complex)
        grid_co[colat_idx, azim_idx] = J_co
        grid_cr[colat_idx, azim_idx] = J_cr

        colat_deg = np.arange(n_colat) * colat_step
        azim_deg = np.arange(n_azim) * azim_step
        theta_rad = np.radians(colat_deg[1:-1])
        phi_rad = np.radians(azim_deg)

        splines = []
        for component in [grid_co.real, grid_co.imag, grid_cr.real, grid_cr.imag]:
            splines.append(
                RectSphereBivariateSpline(
                    theta_rad,
                    phi_rad,
                    component[1:-1, :],
                    pole_values=(component[0, 0], component[-1, 0]),
                    s=0,
                )
            )
        return splines

    def _eval_single_freq(self, freq_MHz, theta, phi):
        """Evaluate co/cross-pol at one known frequency via angular splines."""
        spl = self._splines[freq_MHz]
        phi_wrapped = phi % (2 * np.pi)
        co = spl[0](theta, phi_wrapped, grid=False) + 1j * spl[1](
            theta, phi_wrapped, grid=False
        )
        cr = spl[2](theta, phi_wrapped, grid=False) + 1j * spl[3](
            theta, phi_wrapped, grid=False
        )
        return np.stack([co, cr], axis=0)

    def __call__(self, freq, theta, phi):
        r"""Evaluate the interpolated beam, matching the Ludwig-3 callable signature.

        Parameters
        ----------
        freq : array, shape (n_freq, 1, 1)
            Frequencies in MHz.
        theta : array, shape (nlat, nlon)
            Colatitude in radians.
        phi : array, shape (nlat, nlon)
            Azimuth in radians.

        Returns
        -------
        array, shape (2, n_freq, nlat, nlon)
            Co-pol (index 0) and cross-pol (index 1).

        Raises
        ------
        ValueError
            If any requested frequency is outside the range of known frequencies.
        """
        freqs_1d = np.squeeze(freq)
        if freqs_1d.ndim == 0:
            freqs_1d = freqs_1d[np.newaxis]

        f_min, f_max = self.frequencies[0], self.frequencies[-1]
        out_of_range = (freqs_1d < f_min) | (freqs_1d > f_max)
        if np.any(out_of_range):
            bad = freqs_1d[out_of_range]
            raise ValueError(
                f"Requested frequencies {bad} outside sampled range "
                f"[{f_min}, {f_max}] MHz"
            )

        theta_flat = theta.ravel()
        phi_flat = phi.ravel()

        if len(self.frequencies) == 1:
            if not np.allclose(freqs_1d, self.frequencies[0]):
                raise ValueError(
                    f"Only one CST file provided at {self.frequencies[0]} MHz; "
                    f"cannot interpolate to {freqs_1d} MHz"
                )
            beam = self._eval_single_freq(self.frequencies[0], theta_flat, phi_flat)
            result = np.broadcast_to(beam, (len(freqs_1d), 2, len(theta_flat))).copy()
        else:
            kind = self.freq_interp_kind
            if isinstance(kind, int):
                min_pts = kind + 1
            elif kind == "cubic":
                min_pts = 4
            elif kind == "quadratic":
                min_pts = 3
            else:
                min_pts = 2
            if len(self.frequencies) < min_pts:
                raise ValueError(
                    f"freq_interp_kind={kind!r} requires at least {min_pts} sampled "
                    f"frequencies, but only {len(self.frequencies)} provided"
                )
            known_beams = np.stack(
                [
                    self._eval_single_freq(f, theta_flat, phi_flat)
                    for f in self.frequencies
                ]
            )
            interpolator = interp1d(self.frequencies, known_beams, axis=0, kind=kind)
            result = interpolator(freqs_1d)

        return np.moveaxis(result, 0, 1).reshape(2, len(freqs_1d), *theta.shape)

__call__(freq, theta, phi)

Evaluate the interpolated beam, matching the Ludwig-3 callable signature.

Parameters:

Name Type Description Default
freq (array, shape(n_freq, 1, 1))

Frequencies in MHz.

required
theta (array, shape(nlat, nlon))

Colatitude in radians.

required
phi (array, shape(nlat, nlon))

Azimuth in radians.

required

Returns:

Type Description
(array, shape(2, n_freq, nlat, nlon))

Co-pol (index 0) and cross-pol (index 1).

Raises:

Type Description
ValueError

If any requested frequency is outside the range of known frequencies.

Source code in src/serval/containers.py
def __call__(self, freq, theta, phi):
    r"""Evaluate the interpolated beam, matching the Ludwig-3 callable signature.

    Parameters
    ----------
    freq : array, shape (n_freq, 1, 1)
        Frequencies in MHz.
    theta : array, shape (nlat, nlon)
        Colatitude in radians.
    phi : array, shape (nlat, nlon)
        Azimuth in radians.

    Returns
    -------
    array, shape (2, n_freq, nlat, nlon)
        Co-pol (index 0) and cross-pol (index 1).

    Raises
    ------
    ValueError
        If any requested frequency is outside the range of known frequencies.
    """
    freqs_1d = np.squeeze(freq)
    if freqs_1d.ndim == 0:
        freqs_1d = freqs_1d[np.newaxis]

    f_min, f_max = self.frequencies[0], self.frequencies[-1]
    out_of_range = (freqs_1d < f_min) | (freqs_1d > f_max)
    if np.any(out_of_range):
        bad = freqs_1d[out_of_range]
        raise ValueError(
            f"Requested frequencies {bad} outside sampled range "
            f"[{f_min}, {f_max}] MHz"
        )

    theta_flat = theta.ravel()
    phi_flat = phi.ravel()

    if len(self.frequencies) == 1:
        if not np.allclose(freqs_1d, self.frequencies[0]):
            raise ValueError(
                f"Only one CST file provided at {self.frequencies[0]} MHz; "
                f"cannot interpolate to {freqs_1d} MHz"
            )
        beam = self._eval_single_freq(self.frequencies[0], theta_flat, phi_flat)
        result = np.broadcast_to(beam, (len(freqs_1d), 2, len(theta_flat))).copy()
    else:
        kind = self.freq_interp_kind
        if isinstance(kind, int):
            min_pts = kind + 1
        elif kind == "cubic":
            min_pts = 4
        elif kind == "quadratic":
            min_pts = 3
        else:
            min_pts = 2
        if len(self.frequencies) < min_pts:
            raise ValueError(
                f"freq_interp_kind={kind!r} requires at least {min_pts} sampled "
                f"frequencies, but only {len(self.frequencies)} provided"
            )
        known_beams = np.stack(
            [
                self._eval_single_freq(f, theta_flat, phi_flat)
                for f in self.frequencies
            ]
        )
        interpolator = interp1d(self.frequencies, known_beams, axis=0, kind=kind)
        result = interpolator(freqs_1d)

    return np.moveaxis(result, 0, 1).reshape(2, len(freqs_1d), *theta.shape)

HarmonicContainer

Base container for spherical harmonic coefficient data.

Stores alm arrays in standard Fourier order with shape (..., n_freq, lmax+1, 2*mmax+1).

Parameters:

Name Type Description Default
mmax int

Maximum spherical harmonic order. Defaults to lmax.

<dynamic>
lmax int
required
frequencies_MHz ndarray[tuple[Any, ...], dtype[float64]]
required
alm ndarray[tuple[Any, ...], dtype[complex128]]
required
metadata dict

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

<class 'dict'>
Source code in src/serval/containers.py
@define(eq=False)
class HarmonicContainer:
    """Base container for spherical harmonic coefficient data.

    Stores alm arrays in standard Fourier order with shape
    ``(..., n_freq, lmax+1, 2*mmax+1)``.

    Parameters
    ----------
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    """

    lmax: int = field(validator=lmax_validator)
    mmax: int = field(
        default=Factory(lambda self: self.lmax, takes_self=True),
        kw_only=True,
        validator=mmax_validator,
    )
    frequencies_MHz: npt.NDArray[np.float64] = field(
        validator=frequencies_MHz_validator
    )
    alm: npt.NDArray[np.complex128] = field(validator=alm_shape_validator)
    metadata: dict = field(factory=dict, kw_only=True)

    _to_attrs: ClassVar[list[str]] = ["lmax", "mmax"]
    _to_zarr_store: ClassVar[list[str]] = ["frequencies_MHz", "alm"]
    _freq_axis: ClassVar[int] = 0

    @classmethod
    def from_zarr_store(
        cls,
        store_location: str | Path,
        group_path: str = r"/",
        lmax: int | None = None,
        mmax: int | None = None,
        freq_slice: slice | None = None,
    ) -> Self:
        """Load a container from a zarr store on disk.

        Parameters
        ----------
        lmax : int, optional
            Load only alm coefficients up to this degree. Must be <= the stored
            lmax. Defaults to the stored lmax (full read).
        mmax : int, optional
            Load only alm coefficients up to this order. Must be <= the stored
            mmax. Defaults to the stored mmax (full read).
        freq_slice : slice, optional
            Load only the frequency channels selected by this slice. Applied along
            ``_freq_axis`` (axis 0 for most containers, axis 1 for
            ``TIRSVoltageBeam``). Also slices ``frequencies_MHz`` to match.
            Defaults to all frequencies (full read).
        """
        destination_dir = Path(store_location)
        store = zarr.storage.LocalStore(destination_dir, read_only=True)
        grp = zarr.open_group(store=store, path=group_path, mode="r")
        try:
            init_dict: dict[str, typing.Any] = {k: grp.attrs[k] for k in cls._to_attrs}
            init_dict["metadata"] = grp.attrs.asdict().get("metadata", {})

            stored_lmax = int(init_dict["lmax"])
            stored_mmax = int(init_dict["mmax"])
            if lmax is not None and lmax > stored_lmax:
                raise ValueError(
                    f"Requested lmax={lmax} exceeds stored lmax={stored_lmax}."
                )
            if mmax is not None and mmax > stored_mmax:
                raise ValueError(
                    f"Requested mmax={mmax} exceeds stored mmax={stored_mmax}."
                )

            init_dict.update(
                {k: np.asarray(grp[k]) for k in cls._to_zarr_store if k != "alm"}
            )
            alm_arr = typing.cast(zarr.Array, grp["alm"])
            if freq_slice is None and lmax is None and mmax is None:
                init_dict["alm"] = (
                    alm_arr  # zarr.Array is lazy — data read deferred until access
                )
            else:
                read_lmax = lmax if lmax is not None else stored_lmax
                read_mmax = mmax if mmax is not None else stored_mmax
                ndim = len(alm_arr.shape)
                idx: list[slice] = [slice(None)] * ndim
                if freq_slice is not None:
                    idx[cls._freq_axis] = freq_slice
                    init_dict["frequencies_MHz"] = init_dict["frequencies_MHz"][
                        freq_slice
                    ]
                if lmax is not None or mmax is not None:
                    idx[-2] = slice(read_lmax + 1)
                    idx[-1] = slice(
                        stored_mmax - read_mmax, stored_mmax + read_mmax + 1
                    )
                init_dict["alm"] = alm_arr[tuple(idx)]  # zarr indexing returns ndarray
                init_dict["lmax"] = read_lmax
                init_dict["mmax"] = read_mmax
        except KeyError as e:
            raise ValueError(
                f"Could not load {cls.__name__} from '{store_location}' (group '{group_path}'): "
                f"missing attribute or array {e}. "
                f"Ensure the store was created using {cls.__name__}.to_zarr_store(). "
                f"Required attributes: {cls._to_attrs}. "
                f"Required arrays: {cls._to_zarr_store}."
            ) from e
        return cls(**init_dict)

    def to_zarr_store(
        self,
        store_location: str | Path,
        group_path: str = r"/",
        metadata: dict | None = None,
    ) -> None:
        """Persist the container to a zarr store on disk.

        Parameters
        ----------
        store_location : str or Path
            Path to the zarr store directory.
        group_path : str
            Group path within the store.
        metadata : dict, optional
            Additional key/value pairs merged into the container's ``metadata``
            attribute before writing.  Does not modify the container in place.
        """
        to_attrs_dict = {slot: getattr(self, slot) for slot in self._to_attrs}
        to_zarr_store_dict = {slot: getattr(self, slot) for slot in self._to_zarr_store}

        combined_metadata = dict(self.metadata)
        if metadata is not None:
            combined_metadata.update(metadata)

        destination_dir = Path(store_location)
        store = zarr.storage.LocalStore(destination_dir)
        grp = zarr.create_group(store=store, path=group_path, overwrite=True)

        for k, v in to_attrs_dict.items():
            grp.attrs[k] = v
        grp.attrs["metadata"] = combined_metadata

        for k, v in to_zarr_store_dict.items():
            if k == "alm":
                grp.create_array(
                    name=k,
                    data=v,
                    overwrite=True,
                    compressors=zarr.codecs.BloscCodec(**ZARR_COMPRESSORS),
                )
            else:
                arr = grp.create_array(
                    name=k,
                    shape=v.shape,
                    dtype=v.dtype,
                    overwrite=True,
                )
                arr[:] = v

    def effective_bandlimits(
        self, threshold: float = SPARSE_THRESHOLD, per_slice: bool = False
    ) -> tuple[int, int] | npt.NDArray[np.int_]:
        """Return the effective (lmax, mmax) of the alm data.

        For each leading-dimension slice, finds the largest ell and m
        where any coefficient exceeds ``threshold * max(|alm|)``
        (peak taken per slice).

        Parameters
        ----------
        threshold : float
            Relative threshold below which coefficients are considered
            negligible. Default is ``SPARSE_THRESHOLD``.
        per_slice : bool
            If False (default), return the overall maximum across all
            leading dimensions as a ``(lmax, mmax)`` tuple. If True,
            return an integer array of shape ``(*leading_dims, 2)``
            with the effective ``(lmax, mmax)`` per slice.

        Returns
        -------
        tuple[int, int] | npt.NDArray[np.int_]
            Effective bandlimits, either as a single tuple or a per-slice
            array.
        """
        leading_shape = self.alm.shape[:-2]
        flat = np.abs(self.alm).reshape(-1, *self.alm.shape[-2:])
        peaks = flat.max(axis=(-2, -1), keepdims=True)
        peaks = np.where(peaks == 0, 1.0, peaks)
        significant = flat > threshold * peaks  # (n_slices, lmax+1, 2*mmax+1)

        # ell indices where each slice has significant coefficients
        ell_sig = significant.any(axis=2)  # (n_slices, lmax+1)
        # m column indices -> |m| values
        m_abs = np.abs(np.arange(2 * self.mmax + 1) - self.mmax)  # (2*mmax+1,)
        m_sig = significant.any(axis=1)  # (n_slices, 2*mmax+1)

        if not per_slice:
            any_ell = ell_sig.any(axis=0)
            any_m = m_sig.any(axis=0)
            if not any_ell.any():
                return 0, 0
            eff_lmax = int(np.max(np.nonzero(any_ell)))
            eff_mmax = int(m_abs[any_m].max())
            return eff_lmax, eff_mmax

        # Per-slice: use masked ell indices and m_abs, take max per row
        ell_indices = np.arange(self.lmax + 1)[np.newaxis, :]  # (1, lmax+1)
        # Where not significant, replace with 0 so max gives the effective lmax
        eff_lmax = np.where(ell_sig, ell_indices, 0).max(axis=1)  # (n_slices,)
        # Where not significant, replace with 0 so max gives the effective mmax
        eff_mmax = np.where(m_sig, m_abs[np.newaxis, :], 0).max(axis=1)  # (n_slices,)
        result = np.stack([eff_lmax, eff_mmax], axis=-1)
        return result.reshape(*leading_shape, 2)

    def bandlimited_to(self, lmax: int, mmax: int | None = None) -> Self:
        """Return a new container with alm truncated to the given bandlimits."""
        if mmax is None:
            mmax = lmax
        new_alm = set_bandlimits(self.alm, lmax=lmax, mmax=mmax)
        return attrs.evolve(self, alm=new_alm, lmax=lmax, mmax=mmax)

    def rotate(
        self, eulers_or_rotation: tuple[float, float, float] | Rotation, **kwargs
    ) -> Self:
        """Rotate the alm coefficients by the given rotation.

        Parameters
        ----------
        eulers_or_rotation : tuple[float, float, float] | Rotation
            ZYZ Euler angles ``(alpha, beta, gamma)`` in radians (passive/basis
            convention), or a ``scipy.spatial.transform.Rotation`` object.
        **kwargs
            Additional keyword arguments forwarded to ``attrs.evolve``, allowing
            callers to update other attributes in the same step and avoid a
            second copy.

        Returns
        -------
        HarmonicContainer
            A copy of this container with rotated alm and ``mmax`` set to
            ``lmax`` (rotation generally mixes all m-modes).
        """
        rotated_alm = batch_rotate_alm(
            self.alm, self.lmax, self.mmax, eulers_or_rotation
        )
        return attrs.evolve(self, alm=rotated_alm, mmax=self.lmax, **kwargs)

    def as_grid(
        self,
        pol_inds: int | slice | None = None,
        freq_inds: int | slice | None = None,
        return_shgrid: bool = False,
    ) -> npt.NDArray | Any | list[Any]:
        """Synthesise alm coefficients to a spatial grid.

        Parameters
        ----------
        pol_inds : int, slice, or None, optional
            Polarisation index or slice.
        freq_inds : int, slice, or None, optional
            Frequency index or slice.
        return_shgrid : bool
            If True, return ``pyshtools.SHGrid`` object(s) instead of a
            numpy array.  A single ``SHGrid`` is returned when the
            result has no leading dimensions; otherwise a list of
            ``SHGrid`` objects is returned (one per leading index).

        Returns
        -------
        npt.NDArray | pysh.SHGrid | list[pysh.SHGrid]
            When ``return_shgrid=False`` (default): numpy array with shape
            ``(..., nlat, nlon)``.
            When ``return_shgrid=True``: a single ``SHGrid`` or a list.
        """
        sliced_alm = self._slice_alm(pol_inds, freq_inds)
        leading_shape = sliced_alm.shape[:-2]
        flat_alm = sliced_alm.reshape(-1, *sliced_alm.shape[-2:])
        grids = []
        for i in range(flat_alm.shape[0]):
            grids.append(array_synthesis(flat_alm[i]))
        if return_shgrid:
            if len(grids) == 1:
                return grids[0]
            return grids
        grid_data = np.stack([g.data for g in grids], axis=0)
        return grid_data.reshape(*leading_shape, *grid_data.shape[-2:])

    def as_coeff(
        self,
        pol_inds: int | slice | None = None,
        freq_inds: int | slice | None = None,
    ) -> npt.NDArray[np.complex128]:
        """Return the alm coefficient array, optionally sliced along polarisation and frequency.

        Parameters
        ----------
        pol_inds : int, slice, or None, optional
            Polarisation index/indices to select from the leading axis.  If
            ``None`` (default), all polarisations are returned.
        freq_inds : int, slice, or None, optional
            Frequency index/indices to select along the frequency axis.  If
            ``None`` (default), all frequencies are returned.

        Returns
        -------
        ndarray of complex128
            Sliced alm array with the same axis order as ``self.alm``.
        """
        return self._slice_alm(pol_inds, freq_inds)

    def to_healpix(
        self,
        nside: int,
        pol_inds: int | slice | None = None,
        freq_inds: int | slice | None = None,
    ) -> npt.NDArray[np.complex128]:
        """Convert alm to healpix-format alm arrays.

        Parameters
        ----------
        nside : int
            Healpix nside parameter (determines output lmax = 3*nside - 1).
        pol_inds : int, slice, or None, optional
            Polarisation index or slice.
        freq_inds : int, slice, or None, optional
            Frequency index or slice.

        Returns
        -------
        npt.NDArray[np.complex128]
            Healpy-ordered alm array(s).
        """
        hp_lmax = 3 * nside - 1
        sliced_alm = self._slice_alm(pol_inds, freq_inds)
        leading_shape = sliced_alm.shape[:-2]
        bounded_alm = set_bandlimits(sliced_alm, lmax=hp_lmax, mmax=hp_lmax)
        flat_alm = bounded_alm.reshape(-1, *bounded_alm.shape[-2:])
        hp_alms = np.stack(
            [healpix_from_alm(flat_alm[i]) for i in range(flat_alm.shape[0])],
            axis=0,
        )
        return hp_alms.reshape(*leading_shape, -1)

    def _slice_alm(
        self,
        pol_inds: int | slice | None = None,
        freq_inds: int | slice | None = None,
    ) -> npt.NDArray[np.complex128]:
        """Return a view/copy of self.alm sliced by pol and freq indices."""
        alm = self.alm
        if alm.ndim == 4 and pol_inds is not None:
            alm = alm[pol_inds]
        if alm.ndim >= 3 and freq_inds is not None:
            alm = alm[..., freq_inds, :, :]
        return alm

as_coeff(pol_inds=None, freq_inds=None)

Return the alm coefficient array, optionally sliced along polarisation and frequency.

Parameters:

Name Type Description Default
pol_inds int, slice, or None

Polarisation index/indices to select from the leading axis. If None (default), all polarisations are returned.

None
freq_inds int, slice, or None

Frequency index/indices to select along the frequency axis. If None (default), all frequencies are returned.

None

Returns:

Type Description
ndarray of complex128

Sliced alm array with the same axis order as self.alm.

Source code in src/serval/containers.py
def as_coeff(
    self,
    pol_inds: int | slice | None = None,
    freq_inds: int | slice | None = None,
) -> npt.NDArray[np.complex128]:
    """Return the alm coefficient array, optionally sliced along polarisation and frequency.

    Parameters
    ----------
    pol_inds : int, slice, or None, optional
        Polarisation index/indices to select from the leading axis.  If
        ``None`` (default), all polarisations are returned.
    freq_inds : int, slice, or None, optional
        Frequency index/indices to select along the frequency axis.  If
        ``None`` (default), all frequencies are returned.

    Returns
    -------
    ndarray of complex128
        Sliced alm array with the same axis order as ``self.alm``.
    """
    return self._slice_alm(pol_inds, freq_inds)

as_grid(pol_inds=None, freq_inds=None, return_shgrid=False)

Synthesise alm coefficients to a spatial grid.

Parameters:

Name Type Description Default
pol_inds int, slice, or None

Polarisation index or slice.

None
freq_inds int, slice, or None

Frequency index or slice.

None
return_shgrid bool

If True, return pyshtools.SHGrid object(s) instead of a numpy array. A single SHGrid is returned when the result has no leading dimensions; otherwise a list of SHGrid objects is returned (one per leading index).

False

Returns:

Type Description
NDArray | SHGrid | list[SHGrid]

When return_shgrid=False (default): numpy array with shape (..., nlat, nlon). When return_shgrid=True: a single SHGrid or a list.

Source code in src/serval/containers.py
def as_grid(
    self,
    pol_inds: int | slice | None = None,
    freq_inds: int | slice | None = None,
    return_shgrid: bool = False,
) -> npt.NDArray | Any | list[Any]:
    """Synthesise alm coefficients to a spatial grid.

    Parameters
    ----------
    pol_inds : int, slice, or None, optional
        Polarisation index or slice.
    freq_inds : int, slice, or None, optional
        Frequency index or slice.
    return_shgrid : bool
        If True, return ``pyshtools.SHGrid`` object(s) instead of a
        numpy array.  A single ``SHGrid`` is returned when the
        result has no leading dimensions; otherwise a list of
        ``SHGrid`` objects is returned (one per leading index).

    Returns
    -------
    npt.NDArray | pysh.SHGrid | list[pysh.SHGrid]
        When ``return_shgrid=False`` (default): numpy array with shape
        ``(..., nlat, nlon)``.
        When ``return_shgrid=True``: a single ``SHGrid`` or a list.
    """
    sliced_alm = self._slice_alm(pol_inds, freq_inds)
    leading_shape = sliced_alm.shape[:-2]
    flat_alm = sliced_alm.reshape(-1, *sliced_alm.shape[-2:])
    grids = []
    for i in range(flat_alm.shape[0]):
        grids.append(array_synthesis(flat_alm[i]))
    if return_shgrid:
        if len(grids) == 1:
            return grids[0]
        return grids
    grid_data = np.stack([g.data for g in grids], axis=0)
    return grid_data.reshape(*leading_shape, *grid_data.shape[-2:])

bandlimited_to(lmax, mmax=None)

Return a new container with alm truncated to the given bandlimits.

Source code in src/serval/containers.py
def bandlimited_to(self, lmax: int, mmax: int | None = None) -> Self:
    """Return a new container with alm truncated to the given bandlimits."""
    if mmax is None:
        mmax = lmax
    new_alm = set_bandlimits(self.alm, lmax=lmax, mmax=mmax)
    return attrs.evolve(self, alm=new_alm, lmax=lmax, mmax=mmax)

effective_bandlimits(threshold=SPARSE_THRESHOLD, per_slice=False)

Return the effective (lmax, mmax) of the alm data.

For each leading-dimension slice, finds the largest ell and m where any coefficient exceeds threshold * max(|alm|) (peak taken per slice).

Parameters:

Name Type Description Default
threshold float

Relative threshold below which coefficients are considered negligible. Default is SPARSE_THRESHOLD.

SPARSE_THRESHOLD
per_slice bool

If False (default), return the overall maximum across all leading dimensions as a (lmax, mmax) tuple. If True, return an integer array of shape (*leading_dims, 2) with the effective (lmax, mmax) per slice.

False

Returns:

Type Description
tuple[int, int] | NDArray[int_]

Effective bandlimits, either as a single tuple or a per-slice array.

Source code in src/serval/containers.py
def effective_bandlimits(
    self, threshold: float = SPARSE_THRESHOLD, per_slice: bool = False
) -> tuple[int, int] | npt.NDArray[np.int_]:
    """Return the effective (lmax, mmax) of the alm data.

    For each leading-dimension slice, finds the largest ell and m
    where any coefficient exceeds ``threshold * max(|alm|)``
    (peak taken per slice).

    Parameters
    ----------
    threshold : float
        Relative threshold below which coefficients are considered
        negligible. Default is ``SPARSE_THRESHOLD``.
    per_slice : bool
        If False (default), return the overall maximum across all
        leading dimensions as a ``(lmax, mmax)`` tuple. If True,
        return an integer array of shape ``(*leading_dims, 2)``
        with the effective ``(lmax, mmax)`` per slice.

    Returns
    -------
    tuple[int, int] | npt.NDArray[np.int_]
        Effective bandlimits, either as a single tuple or a per-slice
        array.
    """
    leading_shape = self.alm.shape[:-2]
    flat = np.abs(self.alm).reshape(-1, *self.alm.shape[-2:])
    peaks = flat.max(axis=(-2, -1), keepdims=True)
    peaks = np.where(peaks == 0, 1.0, peaks)
    significant = flat > threshold * peaks  # (n_slices, lmax+1, 2*mmax+1)

    # ell indices where each slice has significant coefficients
    ell_sig = significant.any(axis=2)  # (n_slices, lmax+1)
    # m column indices -> |m| values
    m_abs = np.abs(np.arange(2 * self.mmax + 1) - self.mmax)  # (2*mmax+1,)
    m_sig = significant.any(axis=1)  # (n_slices, 2*mmax+1)

    if not per_slice:
        any_ell = ell_sig.any(axis=0)
        any_m = m_sig.any(axis=0)
        if not any_ell.any():
            return 0, 0
        eff_lmax = int(np.max(np.nonzero(any_ell)))
        eff_mmax = int(m_abs[any_m].max())
        return eff_lmax, eff_mmax

    # Per-slice: use masked ell indices and m_abs, take max per row
    ell_indices = np.arange(self.lmax + 1)[np.newaxis, :]  # (1, lmax+1)
    # Where not significant, replace with 0 so max gives the effective lmax
    eff_lmax = np.where(ell_sig, ell_indices, 0).max(axis=1)  # (n_slices,)
    # Where not significant, replace with 0 so max gives the effective mmax
    eff_mmax = np.where(m_sig, m_abs[np.newaxis, :], 0).max(axis=1)  # (n_slices,)
    result = np.stack([eff_lmax, eff_mmax], axis=-1)
    return result.reshape(*leading_shape, 2)

from_zarr_store(store_location, group_path='/', lmax=None, mmax=None, freq_slice=None) classmethod

Load a container from a zarr store on disk.

Parameters:

Name Type Description Default
lmax int

Load only alm coefficients up to this degree. Must be <= the stored lmax. Defaults to the stored lmax (full read).

None
mmax int

Load only alm coefficients up to this order. Must be <= the stored mmax. Defaults to the stored mmax (full read).

None
freq_slice slice

Load only the frequency channels selected by this slice. Applied along _freq_axis (axis 0 for most containers, axis 1 for TIRSVoltageBeam). Also slices frequencies_MHz to match. Defaults to all frequencies (full read).

None
Source code in src/serval/containers.py
@classmethod
def from_zarr_store(
    cls,
    store_location: str | Path,
    group_path: str = r"/",
    lmax: int | None = None,
    mmax: int | None = None,
    freq_slice: slice | None = None,
) -> Self:
    """Load a container from a zarr store on disk.

    Parameters
    ----------
    lmax : int, optional
        Load only alm coefficients up to this degree. Must be <= the stored
        lmax. Defaults to the stored lmax (full read).
    mmax : int, optional
        Load only alm coefficients up to this order. Must be <= the stored
        mmax. Defaults to the stored mmax (full read).
    freq_slice : slice, optional
        Load only the frequency channels selected by this slice. Applied along
        ``_freq_axis`` (axis 0 for most containers, axis 1 for
        ``TIRSVoltageBeam``). Also slices ``frequencies_MHz`` to match.
        Defaults to all frequencies (full read).
    """
    destination_dir = Path(store_location)
    store = zarr.storage.LocalStore(destination_dir, read_only=True)
    grp = zarr.open_group(store=store, path=group_path, mode="r")
    try:
        init_dict: dict[str, typing.Any] = {k: grp.attrs[k] for k in cls._to_attrs}
        init_dict["metadata"] = grp.attrs.asdict().get("metadata", {})

        stored_lmax = int(init_dict["lmax"])
        stored_mmax = int(init_dict["mmax"])
        if lmax is not None and lmax > stored_lmax:
            raise ValueError(
                f"Requested lmax={lmax} exceeds stored lmax={stored_lmax}."
            )
        if mmax is not None and mmax > stored_mmax:
            raise ValueError(
                f"Requested mmax={mmax} exceeds stored mmax={stored_mmax}."
            )

        init_dict.update(
            {k: np.asarray(grp[k]) for k in cls._to_zarr_store if k != "alm"}
        )
        alm_arr = typing.cast(zarr.Array, grp["alm"])
        if freq_slice is None and lmax is None and mmax is None:
            init_dict["alm"] = (
                alm_arr  # zarr.Array is lazy — data read deferred until access
            )
        else:
            read_lmax = lmax if lmax is not None else stored_lmax
            read_mmax = mmax if mmax is not None else stored_mmax
            ndim = len(alm_arr.shape)
            idx: list[slice] = [slice(None)] * ndim
            if freq_slice is not None:
                idx[cls._freq_axis] = freq_slice
                init_dict["frequencies_MHz"] = init_dict["frequencies_MHz"][
                    freq_slice
                ]
            if lmax is not None or mmax is not None:
                idx[-2] = slice(read_lmax + 1)
                idx[-1] = slice(
                    stored_mmax - read_mmax, stored_mmax + read_mmax + 1
                )
            init_dict["alm"] = alm_arr[tuple(idx)]  # zarr indexing returns ndarray
            init_dict["lmax"] = read_lmax
            init_dict["mmax"] = read_mmax
    except KeyError as e:
        raise ValueError(
            f"Could not load {cls.__name__} from '{store_location}' (group '{group_path}'): "
            f"missing attribute or array {e}. "
            f"Ensure the store was created using {cls.__name__}.to_zarr_store(). "
            f"Required attributes: {cls._to_attrs}. "
            f"Required arrays: {cls._to_zarr_store}."
        ) from e
    return cls(**init_dict)

rotate(eulers_or_rotation, **kwargs)

Rotate the alm coefficients by the given rotation.

Parameters:

Name Type Description Default
eulers_or_rotation tuple[float, float, float] | Rotation

ZYZ Euler angles (alpha, beta, gamma) in radians (passive/basis convention), or a scipy.spatial.transform.Rotation object.

required
**kwargs

Additional keyword arguments forwarded to attrs.evolve, allowing callers to update other attributes in the same step and avoid a second copy.

{}

Returns:

Type Description
HarmonicContainer

A copy of this container with rotated alm and mmax set to lmax (rotation generally mixes all m-modes).

Source code in src/serval/containers.py
def rotate(
    self, eulers_or_rotation: tuple[float, float, float] | Rotation, **kwargs
) -> Self:
    """Rotate the alm coefficients by the given rotation.

    Parameters
    ----------
    eulers_or_rotation : tuple[float, float, float] | Rotation
        ZYZ Euler angles ``(alpha, beta, gamma)`` in radians (passive/basis
        convention), or a ``scipy.spatial.transform.Rotation`` object.
    **kwargs
        Additional keyword arguments forwarded to ``attrs.evolve``, allowing
        callers to update other attributes in the same step and avoid a
        second copy.

    Returns
    -------
    HarmonicContainer
        A copy of this container with rotated alm and ``mmax`` set to
        ``lmax`` (rotation generally mixes all m-modes).
    """
    rotated_alm = batch_rotate_alm(
        self.alm, self.lmax, self.mmax, eulers_or_rotation
    )
    return attrs.evolve(self, alm=rotated_alm, mmax=self.lmax, **kwargs)

to_healpix(nside, pol_inds=None, freq_inds=None)

Convert alm to healpix-format alm arrays.

Parameters:

Name Type Description Default
nside int

Healpix nside parameter (determines output lmax = 3*nside - 1).

required
pol_inds int, slice, or None

Polarisation index or slice.

None
freq_inds int, slice, or None

Frequency index or slice.

None

Returns:

Type Description
NDArray[complex128]

Healpy-ordered alm array(s).

Source code in src/serval/containers.py
def to_healpix(
    self,
    nside: int,
    pol_inds: int | slice | None = None,
    freq_inds: int | slice | None = None,
) -> npt.NDArray[np.complex128]:
    """Convert alm to healpix-format alm arrays.

    Parameters
    ----------
    nside : int
        Healpix nside parameter (determines output lmax = 3*nside - 1).
    pol_inds : int, slice, or None, optional
        Polarisation index or slice.
    freq_inds : int, slice, or None, optional
        Frequency index or slice.

    Returns
    -------
    npt.NDArray[np.complex128]
        Healpy-ordered alm array(s).
    """
    hp_lmax = 3 * nside - 1
    sliced_alm = self._slice_alm(pol_inds, freq_inds)
    leading_shape = sliced_alm.shape[:-2]
    bounded_alm = set_bandlimits(sliced_alm, lmax=hp_lmax, mmax=hp_lmax)
    flat_alm = bounded_alm.reshape(-1, *bounded_alm.shape[-2:])
    hp_alms = np.stack(
        [healpix_from_alm(flat_alm[i]) for i in range(flat_alm.shape[0])],
        axis=0,
    )
    return hp_alms.reshape(*leading_shape, -1)

to_zarr_store(store_location, group_path='/', metadata=None)

Persist the container to a zarr store on disk.

Parameters:

Name Type Description Default
store_location str or Path

Path to the zarr store directory.

required
group_path str

Group path within the store.

'/'
metadata dict

Additional key/value pairs merged into the container's metadata attribute before writing. Does not modify the container in place.

None
Source code in src/serval/containers.py
def to_zarr_store(
    self,
    store_location: str | Path,
    group_path: str = r"/",
    metadata: dict | None = None,
) -> None:
    """Persist the container to a zarr store on disk.

    Parameters
    ----------
    store_location : str or Path
        Path to the zarr store directory.
    group_path : str
        Group path within the store.
    metadata : dict, optional
        Additional key/value pairs merged into the container's ``metadata``
        attribute before writing.  Does not modify the container in place.
    """
    to_attrs_dict = {slot: getattr(self, slot) for slot in self._to_attrs}
    to_zarr_store_dict = {slot: getattr(self, slot) for slot in self._to_zarr_store}

    combined_metadata = dict(self.metadata)
    if metadata is not None:
        combined_metadata.update(metadata)

    destination_dir = Path(store_location)
    store = zarr.storage.LocalStore(destination_dir)
    grp = zarr.create_group(store=store, path=group_path, overwrite=True)

    for k, v in to_attrs_dict.items():
        grp.attrs[k] = v
    grp.attrs["metadata"] = combined_metadata

    for k, v in to_zarr_store_dict.items():
        if k == "alm":
            grp.create_array(
                name=k,
                data=v,
                overwrite=True,
                compressors=zarr.codecs.BloscCodec(**ZARR_COMPRESSORS),
            )
        else:
            arr = grp.create_array(
                name=k,
                shape=v.shape,
                dtype=v.dtype,
                overwrite=True,
            )
            arr[:] = v

SkyModel

Bases: HarmonicContainer

Container for sky model alm data.

Stores sky brightness in spherical harmonic coefficients with associated metadata for units, polarisation, coordinate frame and epoch.

Parameters:

Name Type Description Default
mmax int

Maximum spherical harmonic order. Defaults to lmax.

<dynamic>
lmax int
required
frequencies_MHz ndarray[tuple[Any, ...], dtype[float64]]
required
alm ndarray[tuple[Any, ...], dtype[complex128]]
required
map_unit Literal['K', 'Jy/sr']
required
polarisation Literal['I', 'IQU', 'IQUV']
required
coordinate_basis Literal['ICRS', 'CIRS', 'Galactic']
required
epoch str | None
None
Source code in src/serval/containers.py
@define(eq=False)
class SkyModel(HarmonicContainer):
    """Container for sky model alm data.

    Stores sky brightness in spherical harmonic coefficients with associated
    metadata for units, polarisation, coordinate frame and epoch.

    Parameters
    ----------
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    """

    lmax: int = field(validator=lmax_validator)
    mmax: int = field(
        default=Factory(lambda self: self.lmax, takes_self=True),
        kw_only=True,
        validator=mmax_validator,
    )
    frequencies_MHz: npt.NDArray[np.float64] = field(
        validator=frequencies_MHz_validator
    )
    alm: npt.NDArray[np.complex128] = field(validator=skymodel_alm_validator)
    map_unit: Literal["K", "Jy/sr"] = field(
        validator=attrs.validators.in_(["K", "Jy/sr"])
    )
    polarisation: Literal["I", "IQU", "IQUV"] = field(
        validator=attrs.validators.in_(["I", "IQU", "IQUV"])
    )
    coordinate_basis: Literal["ICRS", "CIRS", "Galactic"] = field(
        validator=attrs.validators.in_(["ICRS", "CIRS", "Galactic"])
    )
    epoch: str | None = None

    _to_attrs: ClassVar[list[str]] = [
        "lmax",
        "mmax",
        "map_unit",
        "polarisation",
        "coordinate_basis",
        "epoch",
    ]
    _to_zarr_store: ClassVar[list[str]] = ["frequencies_MHz", "alm"]

    @classmethod
    def from_healpix_maps(
        cls,
        maps: npt.NDArray[np.float64],
        frequencies_MHz: npt.NDArray[np.float64],
        lmax: int,
        mmax: int | None = None,
        map_unit: Literal["K", "Jy/sr"] = "K",
        polarisation: Literal["I", "IQU", "IQUV"] = "I",
        coordinate_basis: Literal["ICRS", "CIRS", "Galactic"] = "ICRS",
    ) -> "SkyModel":
        """Construct a SkyModel from HEALPix pixel-domain maps.

        Parameters
        ----------
        maps : npt.NDArray[np.float64]
            HEALPix maps.  Shape depends on *polarisation*:

            - ``"I"``: ``(n_freq, n_pix)`` or ``(n_pix,)`` for a single frequency.
            - ``"IQU"``: ``(3, n_freq, n_pix)``.
            - ``"IQUV"``: ``(4, n_freq, n_pix)``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        lmax : int
            Maximum spherical harmonic degree for the decomposition.
        mmax : int | None
            Maximum azimuthal order.  Defaults to *lmax*.
        map_unit : ``"K"`` or ``"Jy/sr"``
            Physical unit of the map values.
        polarisation : ``"I"``, ``"IQU"`` or ``"IQUV"``
            Polarisation content of *maps*.
        coordinate_basis : ``"ICRS"``, ``"CIRS"`` or ``"Galactic"``
            Celestial coordinate frame of the input maps.
        """
        if mmax is None:
            mmax = lmax
        frequencies_MHz = np.asarray(frequencies_MHz)
        n_pol = len(polarisation)  # 1, 3, or 4

        # Normalise maps shape to (n_pol, n_freq, n_pix)
        maps = np.asarray(maps, dtype=np.float64)
        if polarisation == "I":
            maps = np.atleast_2d(maps)  # (n_freq, n_pix)
            if maps.shape[0] != frequencies_MHz.size:
                raise ValueError(
                    f"maps first dimension ({maps.shape[0]}) does not match "
                    f"number of frequencies ({frequencies_MHz.size})."
                )
            maps = maps[np.newaxis, ...]  # (1, n_freq, n_pix)
        else:
            if maps.ndim != 3 or maps.shape[0] != n_pol:
                raise ValueError(
                    f"For polarisation='{polarisation}', maps must have shape "
                    f"({n_pol}, n_freq, n_pix), got {maps.shape}."
                )
            if maps.shape[1] != frequencies_MHz.size:
                raise ValueError(
                    f"maps frequency dimension ({maps.shape[1]}) does not match "
                    f"number of frequencies ({frequencies_MHz.size})."
                )

        # Flatten to (n_total, n_pix) and decompose each map
        n_freq = frequencies_MHz.size
        flat_maps = maps.reshape(-1, maps.shape[-1])  # (n_pol * n_freq, n_pix)
        alm_list = []
        for i in range(flat_maps.shape[0]):
            hp_alm = healpix_map_to_alm(flat_maps[i], lmax=lmax)
            serval_alm = alm_from_healpix(hp_alm)
            serval_alm = set_bandlimits(serval_alm, lmax=lmax, mmax=mmax)
            alm_list.append(serval_alm)

        # Reshape to final alm array
        alm = np.stack(alm_list, axis=0)
        if polarisation == "I":
            alm = alm.reshape(n_freq, lmax + 1, 2 * mmax + 1)
        else:
            alm = alm.reshape(n_pol, n_freq, lmax + 1, 2 * mmax + 1)

        return cls(
            lmax=lmax,
            mmax=mmax,
            frequencies_MHz=frequencies_MHz,
            alm=alm,
            map_unit=map_unit,
            polarisation=polarisation,
            coordinate_basis=coordinate_basis,
        )

    @classmethod
    def from_point_source_catalog(
        cls,
        coords: SkyCoord,
        flux_Jy: npt.NDArray[np.float64],
        frequencies_MHz: npt.NDArray[np.float64],
        lmax: int,
        mmax: int | None = None,
        coordinate_basis: Literal["ICRS", "CIRS", "Galactic"] = "ICRS",
        epoch: str | None = None,
        n_jobs: int | None = None,
    ):
        """Construct a sky model from a point source catalogue.

        Parameters
        ----------
        coords : astropy.coordinates.SkyCoord
            Source positions.  Will be transformed to the frame specified by
            *coordinate_basis* before computing harmonic coefficients.
        flux_Jy : npt.NDArray[np.float64]
            Flux densities. Shape ``(n_sources,)`` or ``(n_freq, n_sources)``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int | None
            Maximum azimuthal order. Defaults to lmax.
        coordinate_basis : ``"ICRS"``, ``"CIRS"`` or ``"Galactic"``
            Target coordinate frame for the output SkyModel.
        epoch : str | None
            Required when *coordinate_basis* is ``"CIRS"``.  An epoch string
            such as ``"J2026.3"`` used to instantiate the CIRS frame via
            ``astropy.time.Time``.
        n_jobs : int | None
            Number of worker processes for parallel source computation.
            ``None`` uses all available CPU cores; ``1`` runs serially with no
            subprocess overhead; ``N`` spawns exactly N worker processes.
        """
        if mmax is None:
            mmax = lmax

        # Transform source coordinates to the requested frame
        if coordinate_basis == "ICRS":
            frame_coords = coords.transform_to(ICRS())
        elif coordinate_basis == "Galactic":
            frame_coords = coords.transform_to(Galactic())
        elif coordinate_basis == "CIRS":
            if epoch is None:
                raise ValueError("epoch is required when coordinate_basis='CIRS'.")
            frame_coords = coords.transform_to(CIRS(obstime=Time(epoch)))
        else:
            raise ValueError(f"Unknown coordinate_basis: {coordinate_basis!r}")

        frame_coords = frame_coords.reshape((-1,))
        lon_deg = frame_coords.spherical.lon.deg
        lat_deg = frame_coords.spherical.lat.deg

        flux_Jy = np.atleast_2d(flux_Jy)

        # Split sources into chunks — each worker computes the flux-weighted partial sum
        # for its chunk, reducing main-thread accumulation to n_chunks additions.
        n_workers: int = os.cpu_count() or 1 if n_jobs is None else n_jobs
        n_sources = len(lon_deg)
        chunk_size = max(1, (n_sources + n_workers - 1) // n_workers)
        chunks = [
            (
                lon_deg[i : i + chunk_size],
                lat_deg[i : i + chunk_size],
                flux_Jy[:, i : i + chunk_size],
                lmax,
            )
            for i in range(0, n_sources, chunk_size)
        ]
        n_workers = min(n_workers, len(chunks))
        alm = np.zeros((flux_Jy.shape[0], lmax + 1, 2 * lmax + 1), dtype=np.complex128)
        if n_workers <= 1:
            for partial in map(_weighted_source_alm_chunk, chunks):
                alm += partial
        else:
            mp_context = multiprocessing.get_context("spawn")
            with ProcessPoolExecutor(max_workers=n_workers, mp_context=mp_context) as ex:
                for partial in ex.map(_weighted_source_alm_chunk, chunks):
                    alm += partial
        if mmax != lmax:
            alm = set_bandlimits(alm, lmax=lmax, mmax=mmax)

        return cls(
            lmax=lmax,
            mmax=mmax,
            frequencies_MHz=np.asarray(frequencies_MHz),
            alm=alm,
            map_unit="Jy/sr",
            polarisation="I",
            coordinate_basis=coordinate_basis,
            epoch=epoch,
        )

    def to_Jy_per_sr(self) -> "SkyModel":
        r"""Return a copy converted from brightness temperature (K) to Jy/sr.

        Uses the Rayleigh-Jeans approximation:

        $$S_\nu = \frac{2 k_\mathrm{B} \nu^2}{c^2}\, T_\mathrm{B}$$

        Returns a copy unchanged if already in Jy/sr.

        Returns
        -------
        SkyModel
            Sky model with ``map_unit="Jy/sr"``.
        """
        if self.map_unit == "Jy/sr":
            return attrs.evolve(self)
        scale = (
            (1.0 * units.K)
            .to(
                units.Jy / units.sr,
                equivalencies=units.brightness_temperature(
                    self.frequencies_MHz * units.MHz
                ),
            )
            .value
        )  # (n_freq,)
        if self.polarisation == "I":
            scale = scale[:, np.newaxis, np.newaxis]
        else:
            scale = scale[np.newaxis, :, np.newaxis, np.newaxis]
        return attrs.evolve(self, alm=self.alm * scale, map_unit="Jy/sr")

    def to_brightness_K(self) -> "SkyModel":
        r"""Return a copy converted from Jy/sr to brightness temperature (K).

        Uses the Rayleigh-Jeans approximation:

        $$T_\mathrm{B} = \frac{c^2}{2 k_\mathrm{B} \nu^2}\, S_\nu$$

        Returns a copy unchanged if already in K.

        Returns
        -------
        SkyModel
            Sky model with ``map_unit="K"``.
        """
        if self.map_unit == "K":
            return attrs.evolve(self)
        scale = (
            (1.0 * units.K)
            .to(
                units.Jy / units.sr,
                equivalencies=units.brightness_temperature(
                    self.frequencies_MHz * units.MHz
                ),
            )
            .value
        )  # (n_freq,)
        if self.polarisation == "I":
            scale = scale[:, np.newaxis, np.newaxis]
        else:
            scale = scale[np.newaxis, :, np.newaxis, np.newaxis]
        return attrs.evolve(self, alm=self.alm / scale, map_unit="K")

    def to_cirs(self, epoch: str) -> "SkyModel":
        """Rotate the sky model to the CIRS frame at the given epoch.

        Supports conversion from ICRS, Galactic, or CIRS at a different epoch.

        Parameters
        ----------
        epoch : str
            Target epoch string (e.g. ``"J2026.3"``), parsed by
            ``astropy.time.Time``.

        Returns
        -------
        SkyModel
            A new SkyModel in the CIRS frame at *epoch*.
        """
        if self.coordinate_basis == "CIRS":
            if self.epoch == epoch:
                return attrs.evolve(self)
            source_basis = f"CIRS:{self.epoch}"
        else:
            source_basis = self.coordinate_basis

        rotation = frame_rotation_to_cirs(source_basis, epoch)
        return self.rotate(rotation, coordinate_basis="CIRS", epoch=epoch)

    def rotate(
        self, eulers_or_rotation: tuple[float, float, float] | Rotation, **kwargs
    ) -> "SkyModel":
        """Rotate the alm coefficients by the given rotation.

        Parameters
        ----------
        eulers_or_rotation : tuple[float, float, float] | Rotation
            ZYZ Euler angles ``(alpha, beta, gamma)`` in radians (passive/basis
            convention), or a ``scipy.spatial.transform.Rotation`` object.
        **kwargs
            Additional keyword arguments forwarded to ``attrs.evolve``.

        Returns
        -------
        SkyModel
            A copy of this sky model with rotated alm and ``mmax`` set to ``lmax``.

        Warns
        -----
        UserWarning
            If ``polarisation`` is not ``"I"``. Rotating polarized sky models by
            treating each Stokes parameter independently is incorrect: a coordinate
            rotation mixes Q and U. Full spin-weighted rotation support is planned
            for a future release.
        """
        if self.polarisation != "I":
            warnings.warn(
                f"SkyModel with polarisation='{self.polarisation}': rotating polarized "
                "sky models treats each Stokes parameter independently, which is "
                "incorrect — a coordinate rotation mixes Q and U. Proper spin-weighted "
                "rotation support is planned for a future release. Only the Stokes I "
                "component will be rotated correctly.",
                UserWarning,
                stacklevel=2,
            )
        return super().rotate(eulers_or_rotation, **kwargs)

from_healpix_maps(maps, frequencies_MHz, lmax, mmax=None, map_unit='K', polarisation='I', coordinate_basis='ICRS') classmethod

Construct a SkyModel from HEALPix pixel-domain maps.

Parameters:

Name Type Description Default
maps NDArray[float64]

HEALPix maps. Shape depends on polarisation:

  • "I": (n_freq, n_pix) or (n_pix,) for a single frequency.
  • "IQU": (3, n_freq, n_pix).
  • "IQUV": (4, n_freq, n_pix).
required
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
lmax int

Maximum spherical harmonic degree for the decomposition.

required
mmax int | None

Maximum azimuthal order. Defaults to lmax.

None
map_unit ``"K"`` or ``"Jy/sr"``

Physical unit of the map values.

'K'
polarisation ``"I"``, ``"IQU"`` or ``"IQUV"``

Polarisation content of maps.

'I'
coordinate_basis ``"ICRS"``, ``"CIRS"`` or ``"Galactic"``

Celestial coordinate frame of the input maps.

'ICRS'
Source code in src/serval/containers.py
@classmethod
def from_healpix_maps(
    cls,
    maps: npt.NDArray[np.float64],
    frequencies_MHz: npt.NDArray[np.float64],
    lmax: int,
    mmax: int | None = None,
    map_unit: Literal["K", "Jy/sr"] = "K",
    polarisation: Literal["I", "IQU", "IQUV"] = "I",
    coordinate_basis: Literal["ICRS", "CIRS", "Galactic"] = "ICRS",
) -> "SkyModel":
    """Construct a SkyModel from HEALPix pixel-domain maps.

    Parameters
    ----------
    maps : npt.NDArray[np.float64]
        HEALPix maps.  Shape depends on *polarisation*:

        - ``"I"``: ``(n_freq, n_pix)`` or ``(n_pix,)`` for a single frequency.
        - ``"IQU"``: ``(3, n_freq, n_pix)``.
        - ``"IQUV"``: ``(4, n_freq, n_pix)``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    lmax : int
        Maximum spherical harmonic degree for the decomposition.
    mmax : int | None
        Maximum azimuthal order.  Defaults to *lmax*.
    map_unit : ``"K"`` or ``"Jy/sr"``
        Physical unit of the map values.
    polarisation : ``"I"``, ``"IQU"`` or ``"IQUV"``
        Polarisation content of *maps*.
    coordinate_basis : ``"ICRS"``, ``"CIRS"`` or ``"Galactic"``
        Celestial coordinate frame of the input maps.
    """
    if mmax is None:
        mmax = lmax
    frequencies_MHz = np.asarray(frequencies_MHz)
    n_pol = len(polarisation)  # 1, 3, or 4

    # Normalise maps shape to (n_pol, n_freq, n_pix)
    maps = np.asarray(maps, dtype=np.float64)
    if polarisation == "I":
        maps = np.atleast_2d(maps)  # (n_freq, n_pix)
        if maps.shape[0] != frequencies_MHz.size:
            raise ValueError(
                f"maps first dimension ({maps.shape[0]}) does not match "
                f"number of frequencies ({frequencies_MHz.size})."
            )
        maps = maps[np.newaxis, ...]  # (1, n_freq, n_pix)
    else:
        if maps.ndim != 3 or maps.shape[0] != n_pol:
            raise ValueError(
                f"For polarisation='{polarisation}', maps must have shape "
                f"({n_pol}, n_freq, n_pix), got {maps.shape}."
            )
        if maps.shape[1] != frequencies_MHz.size:
            raise ValueError(
                f"maps frequency dimension ({maps.shape[1]}) does not match "
                f"number of frequencies ({frequencies_MHz.size})."
            )

    # Flatten to (n_total, n_pix) and decompose each map
    n_freq = frequencies_MHz.size
    flat_maps = maps.reshape(-1, maps.shape[-1])  # (n_pol * n_freq, n_pix)
    alm_list = []
    for i in range(flat_maps.shape[0]):
        hp_alm = healpix_map_to_alm(flat_maps[i], lmax=lmax)
        serval_alm = alm_from_healpix(hp_alm)
        serval_alm = set_bandlimits(serval_alm, lmax=lmax, mmax=mmax)
        alm_list.append(serval_alm)

    # Reshape to final alm array
    alm = np.stack(alm_list, axis=0)
    if polarisation == "I":
        alm = alm.reshape(n_freq, lmax + 1, 2 * mmax + 1)
    else:
        alm = alm.reshape(n_pol, n_freq, lmax + 1, 2 * mmax + 1)

    return cls(
        lmax=lmax,
        mmax=mmax,
        frequencies_MHz=frequencies_MHz,
        alm=alm,
        map_unit=map_unit,
        polarisation=polarisation,
        coordinate_basis=coordinate_basis,
    )

from_point_source_catalog(coords, flux_Jy, frequencies_MHz, lmax, mmax=None, coordinate_basis='ICRS', epoch=None, n_jobs=None) classmethod

Construct a sky model from a point source catalogue.

Parameters:

Name Type Description Default
coords SkyCoord

Source positions. Will be transformed to the frame specified by coordinate_basis before computing harmonic coefficients.

required
flux_Jy NDArray[float64]

Flux densities. Shape (n_sources,) or (n_freq, n_sources).

required
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
lmax int

Maximum spherical harmonic degree.

required
mmax int | None

Maximum azimuthal order. Defaults to lmax.

None
coordinate_basis ``"ICRS"``, ``"CIRS"`` or ``"Galactic"``

Target coordinate frame for the output SkyModel.

'ICRS'
epoch str | None

Required when coordinate_basis is "CIRS". An epoch string such as "J2026.3" used to instantiate the CIRS frame via astropy.time.Time.

None
n_jobs int | None

Number of worker processes for parallel source computation. None uses all available CPU cores; 1 runs serially with no subprocess overhead; N spawns exactly N worker processes.

None
Source code in src/serval/containers.py
@classmethod
def from_point_source_catalog(
    cls,
    coords: SkyCoord,
    flux_Jy: npt.NDArray[np.float64],
    frequencies_MHz: npt.NDArray[np.float64],
    lmax: int,
    mmax: int | None = None,
    coordinate_basis: Literal["ICRS", "CIRS", "Galactic"] = "ICRS",
    epoch: str | None = None,
    n_jobs: int | None = None,
):
    """Construct a sky model from a point source catalogue.

    Parameters
    ----------
    coords : astropy.coordinates.SkyCoord
        Source positions.  Will be transformed to the frame specified by
        *coordinate_basis* before computing harmonic coefficients.
    flux_Jy : npt.NDArray[np.float64]
        Flux densities. Shape ``(n_sources,)`` or ``(n_freq, n_sources)``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int | None
        Maximum azimuthal order. Defaults to lmax.
    coordinate_basis : ``"ICRS"``, ``"CIRS"`` or ``"Galactic"``
        Target coordinate frame for the output SkyModel.
    epoch : str | None
        Required when *coordinate_basis* is ``"CIRS"``.  An epoch string
        such as ``"J2026.3"`` used to instantiate the CIRS frame via
        ``astropy.time.Time``.
    n_jobs : int | None
        Number of worker processes for parallel source computation.
        ``None`` uses all available CPU cores; ``1`` runs serially with no
        subprocess overhead; ``N`` spawns exactly N worker processes.
    """
    if mmax is None:
        mmax = lmax

    # Transform source coordinates to the requested frame
    if coordinate_basis == "ICRS":
        frame_coords = coords.transform_to(ICRS())
    elif coordinate_basis == "Galactic":
        frame_coords = coords.transform_to(Galactic())
    elif coordinate_basis == "CIRS":
        if epoch is None:
            raise ValueError("epoch is required when coordinate_basis='CIRS'.")
        frame_coords = coords.transform_to(CIRS(obstime=Time(epoch)))
    else:
        raise ValueError(f"Unknown coordinate_basis: {coordinate_basis!r}")

    frame_coords = frame_coords.reshape((-1,))
    lon_deg = frame_coords.spherical.lon.deg
    lat_deg = frame_coords.spherical.lat.deg

    flux_Jy = np.atleast_2d(flux_Jy)

    # Split sources into chunks — each worker computes the flux-weighted partial sum
    # for its chunk, reducing main-thread accumulation to n_chunks additions.
    n_workers: int = os.cpu_count() or 1 if n_jobs is None else n_jobs
    n_sources = len(lon_deg)
    chunk_size = max(1, (n_sources + n_workers - 1) // n_workers)
    chunks = [
        (
            lon_deg[i : i + chunk_size],
            lat_deg[i : i + chunk_size],
            flux_Jy[:, i : i + chunk_size],
            lmax,
        )
        for i in range(0, n_sources, chunk_size)
    ]
    n_workers = min(n_workers, len(chunks))
    alm = np.zeros((flux_Jy.shape[0], lmax + 1, 2 * lmax + 1), dtype=np.complex128)
    if n_workers <= 1:
        for partial in map(_weighted_source_alm_chunk, chunks):
            alm += partial
    else:
        mp_context = multiprocessing.get_context("spawn")
        with ProcessPoolExecutor(max_workers=n_workers, mp_context=mp_context) as ex:
            for partial in ex.map(_weighted_source_alm_chunk, chunks):
                alm += partial
    if mmax != lmax:
        alm = set_bandlimits(alm, lmax=lmax, mmax=mmax)

    return cls(
        lmax=lmax,
        mmax=mmax,
        frequencies_MHz=np.asarray(frequencies_MHz),
        alm=alm,
        map_unit="Jy/sr",
        polarisation="I",
        coordinate_basis=coordinate_basis,
        epoch=epoch,
    )

rotate(eulers_or_rotation, **kwargs)

Rotate the alm coefficients by the given rotation.

Parameters:

Name Type Description Default
eulers_or_rotation tuple[float, float, float] | Rotation

ZYZ Euler angles (alpha, beta, gamma) in radians (passive/basis convention), or a scipy.spatial.transform.Rotation object.

required
**kwargs

Additional keyword arguments forwarded to attrs.evolve.

{}

Returns:

Type Description
SkyModel

A copy of this sky model with rotated alm and mmax set to lmax.

Warns:

Type Description
UserWarning

If polarisation is not "I". Rotating polarized sky models by treating each Stokes parameter independently is incorrect: a coordinate rotation mixes Q and U. Full spin-weighted rotation support is planned for a future release.

Source code in src/serval/containers.py
def rotate(
    self, eulers_or_rotation: tuple[float, float, float] | Rotation, **kwargs
) -> "SkyModel":
    """Rotate the alm coefficients by the given rotation.

    Parameters
    ----------
    eulers_or_rotation : tuple[float, float, float] | Rotation
        ZYZ Euler angles ``(alpha, beta, gamma)`` in radians (passive/basis
        convention), or a ``scipy.spatial.transform.Rotation`` object.
    **kwargs
        Additional keyword arguments forwarded to ``attrs.evolve``.

    Returns
    -------
    SkyModel
        A copy of this sky model with rotated alm and ``mmax`` set to ``lmax``.

    Warns
    -----
    UserWarning
        If ``polarisation`` is not ``"I"``. Rotating polarized sky models by
        treating each Stokes parameter independently is incorrect: a coordinate
        rotation mixes Q and U. Full spin-weighted rotation support is planned
        for a future release.
    """
    if self.polarisation != "I":
        warnings.warn(
            f"SkyModel with polarisation='{self.polarisation}': rotating polarized "
            "sky models treats each Stokes parameter independently, which is "
            "incorrect — a coordinate rotation mixes Q and U. Proper spin-weighted "
            "rotation support is planned for a future release. Only the Stokes I "
            "component will be rotated correctly.",
            UserWarning,
            stacklevel=2,
        )
    return super().rotate(eulers_or_rotation, **kwargs)

to_Jy_per_sr()

Return a copy converted from brightness temperature (K) to Jy/sr.

Uses the Rayleigh-Jeans approximation:

\[S_\nu = \frac{2 k_\mathrm{B} \nu^2}{c^2}\, T_\mathrm{B}\]

Returns a copy unchanged if already in Jy/sr.

Returns:

Type Description
SkyModel

Sky model with map_unit="Jy/sr".

Source code in src/serval/containers.py
def to_Jy_per_sr(self) -> "SkyModel":
    r"""Return a copy converted from brightness temperature (K) to Jy/sr.

    Uses the Rayleigh-Jeans approximation:

    $$S_\nu = \frac{2 k_\mathrm{B} \nu^2}{c^2}\, T_\mathrm{B}$$

    Returns a copy unchanged if already in Jy/sr.

    Returns
    -------
    SkyModel
        Sky model with ``map_unit="Jy/sr"``.
    """
    if self.map_unit == "Jy/sr":
        return attrs.evolve(self)
    scale = (
        (1.0 * units.K)
        .to(
            units.Jy / units.sr,
            equivalencies=units.brightness_temperature(
                self.frequencies_MHz * units.MHz
            ),
        )
        .value
    )  # (n_freq,)
    if self.polarisation == "I":
        scale = scale[:, np.newaxis, np.newaxis]
    else:
        scale = scale[np.newaxis, :, np.newaxis, np.newaxis]
    return attrs.evolve(self, alm=self.alm * scale, map_unit="Jy/sr")

to_brightness_K()

Return a copy converted from Jy/sr to brightness temperature (K).

Uses the Rayleigh-Jeans approximation:

\[T_\mathrm{B} = \frac{c^2}{2 k_\mathrm{B} \nu^2}\, S_\nu\]

Returns a copy unchanged if already in K.

Returns:

Type Description
SkyModel

Sky model with map_unit="K".

Source code in src/serval/containers.py
def to_brightness_K(self) -> "SkyModel":
    r"""Return a copy converted from Jy/sr to brightness temperature (K).

    Uses the Rayleigh-Jeans approximation:

    $$T_\mathrm{B} = \frac{c^2}{2 k_\mathrm{B} \nu^2}\, S_\nu$$

    Returns a copy unchanged if already in K.

    Returns
    -------
    SkyModel
        Sky model with ``map_unit="K"``.
    """
    if self.map_unit == "K":
        return attrs.evolve(self)
    scale = (
        (1.0 * units.K)
        .to(
            units.Jy / units.sr,
            equivalencies=units.brightness_temperature(
                self.frequencies_MHz * units.MHz
            ),
        )
        .value
    )  # (n_freq,)
    if self.polarisation == "I":
        scale = scale[:, np.newaxis, np.newaxis]
    else:
        scale = scale[np.newaxis, :, np.newaxis, np.newaxis]
    return attrs.evolve(self, alm=self.alm / scale, map_unit="K")

to_cirs(epoch)

Rotate the sky model to the CIRS frame at the given epoch.

Supports conversion from ICRS, Galactic, or CIRS at a different epoch.

Parameters:

Name Type Description Default
epoch str

Target epoch string (e.g. "J2026.3"), parsed by astropy.time.Time.

required

Returns:

Type Description
SkyModel

A new SkyModel in the CIRS frame at epoch.

Source code in src/serval/containers.py
def to_cirs(self, epoch: str) -> "SkyModel":
    """Rotate the sky model to the CIRS frame at the given epoch.

    Supports conversion from ICRS, Galactic, or CIRS at a different epoch.

    Parameters
    ----------
    epoch : str
        Target epoch string (e.g. ``"J2026.3"``), parsed by
        ``astropy.time.Time``.

    Returns
    -------
    SkyModel
        A new SkyModel in the CIRS frame at *epoch*.
    """
    if self.coordinate_basis == "CIRS":
        if self.epoch == epoch:
            return attrs.evolve(self)
        source_basis = f"CIRS:{self.epoch}"
    else:
        source_basis = self.coordinate_basis

    rotation = frame_rotation_to_cirs(source_basis, epoch)
    return self.rotate(rotation, coordinate_basis="CIRS", epoch=epoch)

TIRSPowerBeam

Bases: HarmonicContainer

Container for power beam alm data in the TIRS frame.

Represents the beam power pattern as spherical harmonic coefficients. The alm array has shape (n_freq, lmax+1, 2*mmax+1).

Parameters:

Name Type Description Default
sht_basis Literal['Pointing', 'TIRS']

Basis used for the spherical harmonic decomposition. "TIRS" is the natural basis after construction from voltage beams; "Pointing" after a call to to_pointed_basis. Note that regardless of this value, polarisation projections are always defined in the TIRS frame.

"Pointing"
pol_product Literal['XX', 'YY', 'XY', 'YX']

Correlator polarisation product, formed from the polarisation labels of the two input voltage beams.

"XX"
sky_pol Literal['I', 'Q', 'U', 'V']

Sky Stokes parameter this power beam projects onto.

"I"
mmax int

Maximum spherical harmonic order. Defaults to lmax.

<dynamic>
lmax int
required
frequencies_MHz ndarray[tuple[Any, ...], dtype[float64]]
required
alm ndarray[tuple[Any, ...], dtype[complex128]]
required
normalisation Literal['unnormalised', 'peak', 'integral', 'custom']
'unnormalised'
Source code in src/serval/containers.py
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
@define(eq=False)
class TIRSPowerBeam(HarmonicContainer):
    """Container for power beam alm data in the TIRS frame.

    Represents the beam power pattern as spherical harmonic coefficients.
    The ``alm`` array has shape ``(n_freq, lmax+1, 2*mmax+1)``.

    Parameters
    ----------
    sht_basis : {"Pointing", "TIRS"}
        Basis used for the spherical harmonic decomposition.  ``"TIRS"`` is
        the natural basis after construction from voltage beams; ``"Pointing"``
        after a call to [to_pointed_basis][serval.containers.TIRSPowerBeam.to_pointed_basis].
        Note that regardless of
        this value, polarisation projections are always defined in the TIRS
        frame.
    pol_product : {"XX", "YY", "XY", "YX"}
        Correlator polarisation product, formed from the ``polarisation``
        labels of the two input voltage beams.
    sky_pol : {"I", "Q", "U", "V"}
        Sky Stokes parameter this power beam projects onto.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    """

    lmax: int = field(validator=lmax_validator)
    mmax: int = field(
        default=Factory(lambda self: self.lmax, takes_self=True),
        kw_only=True,
        validator=mmax_validator,
    )
    frequencies_MHz: npt.NDArray[np.float64] = field(
        validator=frequencies_MHz_validator
    )
    alm: npt.NDArray[np.complex128] = field(validator=powerbeam_alm_validator)
    sht_basis: Literal["Pointing", "TIRS"] = field(
        validator=attrs.validators.in_(["Pointing", "TIRS"])
    )
    pol_product: Literal["XX", "YY", "XY", "YX"] = field(
        validator=attrs.validators.in_(["XX", "YY", "XY", "YX"])
    )
    sky_pol: Literal["I", "Q", "U", "V"] = field(
        validator=attrs.validators.in_(["I", "Q", "U", "V"])
    )
    normalisation: Literal["unnormalised", "peak", "integral", "custom"] = field(
        default="unnormalised",
        kw_only=True,
        validator=attrs.validators.in_(["unnormalised", "peak", "integral", "custom"]),
    )

    _to_attrs: ClassVar[list[str]] = [
        "lmax",
        "mmax",
        "sht_basis",
        "pol_product",
        "sky_pol",
        "normalisation",
    ]
    _to_zarr_store: ClassVar[list[str]] = ["frequencies_MHz", "alm"]

    @classmethod
    def from_voltage_beams(
        cls,
        vbeam_i: "TIRSVoltageBeam",
        vbeam_j: "TIRSVoltageBeam",
        sky_pol: Literal["I", "Q", "U", "V"] = "I",
        power_beam_lmax: int | None = None,
        power_beam_mmax: int | None = None,
        method: Literal["grid", "gaunt"] = "grid",
    ) -> "TIRSPowerBeam":
        """Construct a TIRSPowerBeam from two TIRSVoltageBeams.

        The ``pol_product`` is inferred as
        ``vbeam_i.polarisation + vbeam_j.polarisation``.

        Parameters
        ----------
        vbeam_i, vbeam_j : TIRSVoltageBeam
            Voltage beams for feeds i and j.
        sky_pol : {"I", "Q", "U", "V"}
            Sky Stokes parameter to project onto.
        power_beam_lmax : int, optional
            Bandlimit of the output power beam. Defaults to 2 * vbeam_i.lmax.
        power_beam_mmax : int, optional
            Azimuthal bandlimit. Defaults to 2 * vbeam_i.mmax.
        method : {"grid", "gaunt"}
            SH product method. "grid" is faster; "gaunt" uses cached Gaunt
            coefficients.

        Returns
        -------
        TIRSPowerBeam
            Power beam in the TIRS frame with alm of shape
            ``(n_freq, lmax+1, 2*mmax+1)``.
        """

        # Capture voltage beam provenance for metadata
        vbeam_i_meta = {
            **vbeam_i.metadata,
            "lmax": vbeam_i.lmax,
            "mmax": vbeam_i.mmax,
            "polarisation": vbeam_i.polarisation,
        }
        vbeam_j_meta = {
            **vbeam_j.metadata,
            "lmax": vbeam_j.lmax,
            "mmax": vbeam_j.mmax,
            "polarisation": vbeam_j.polarisation,
        }

        pol_product = vbeam_i.polarisation + vbeam_j.polarisation
        if pol_product not in ["XX", "YY", "XY", "YX"]:
            raise ValueError(
                f"pol_product '{pol_product}' is not valid. "
                f"vbeam_i.polarisation='{vbeam_i.polarisation}' and "
                f"vbeam_j.polarisation='{vbeam_j.polarisation}' must together "
                f"form one of 'XX', 'YY', 'XY', 'YX'."
            )
        if not np.array_equal(vbeam_i.frequencies_MHz, vbeam_j.frequencies_MHz):
            raise ValueError(
                f"vbeam_i and vbeam_j frequencies_MHz do not match. "
                f"vbeam_i: {vbeam_i.frequencies_MHz}, vbeam_j: {vbeam_j.frequencies_MHz}."
            )
        if vbeam_i.sht_basis != vbeam_j.sht_basis:
            raise ValueError(
                f"vbeam_i and vbeam_j must have the same sht_basis, got "
                f"'{vbeam_i.sht_basis}' and '{vbeam_j.sht_basis}'."
            )

        if vbeam_i.lmax != vbeam_j.lmax or vbeam_i.mmax != vbeam_j.mmax:
            target_lmax = max(vbeam_i.lmax, vbeam_j.lmax)
            target_mmax = max(vbeam_i.mmax, vbeam_j.mmax)
            vbeam_i = vbeam_i.bandlimited_to(lmax=target_lmax, mmax=target_mmax)
            vbeam_j = vbeam_j.bandlimited_to(lmax=target_lmax, mmax=target_mmax)

        if power_beam_lmax is None:
            power_beam_lmax = 2 * vbeam_i.lmax
        if power_beam_mmax is None:
            power_beam_mmax = 2 * vbeam_i.mmax

        converter = PowerFromVoltageBeams(
            voltage_beam_lmax=vbeam_i.lmax,
            voltage_beam_mmax=vbeam_i.mmax,
            power_beam_lmax=power_beam_lmax,
            power_beam_mmax=power_beam_mmax,
            generate_cache_on_init=False,
        )

        stokes_factors = {
            "I": [[1.0, 0.0], [0.0, 1.0]],
            "Q": [[1.0, 0.0], [0.0, -1.0]],
            "U": [[0.0, 1.0], [1.0, 0.0]],
            "V": [[0.0, 1.0j], [-1.0j, 0.0]],
        }[sky_pol]

        n_freq = len(vbeam_i.frequencies_MHz)
        pbeam_alm = empty_complex_array(
            (n_freq, power_beam_lmax + 1, 2 * power_beam_mmax + 1))
        for i_index, j_index in np.argwhere(stokes_factors):
            pbeam_alm += stokes_factors[i_index][
                j_index
            ] * converter.grid_batch_power_beam_alm(
                vbeam_i.alm[i_index], vbeam_j.alm[j_index]
            )

        return cls(
            lmax=power_beam_lmax,
            mmax=power_beam_mmax,
            frequencies_MHz=vbeam_i.frequencies_MHz.copy(),
            alm=pbeam_alm,
            sht_basis=vbeam_i.sht_basis,
            pol_product=pol_product,
            sky_pol=sky_pol,
            metadata={"vbeam_i": vbeam_i_meta, "vbeam_j": vbeam_j_meta},
        )

    @classmethod
    def from_scalar_callable(
        cls,
        scalar_callable: Callable[
            [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]],
            npt.NDArray[np.float64],
        ],
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        pol_product: Literal["XX", "YY", "XY", "YX"],
        sky_pol: Literal["I", "Q", "U", "V"],
        normalisation: Literal[
            "unnormalised", "peak", "integral", "custom"
        ] = "unnormalised",
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
    ) -> "TIRSPowerBeam":
        r"""Construct a TIRSPowerBeam from a scalar power beam callable.

        Evaluates a scalar (real-valued) power pattern on the TIRS grid in
        pointing-frame coordinates, then decomposes it into spherical harmonics.

        Parameters
        ----------
        scalar_callable : Callable
            A function ``(freq, theta, phi) -> beam`` evaluating the power
            pattern in pointing-frame coordinates.  Input shapes:

            - ``freq``: broadcastable to ``(n_freq, ntheta, nphi)``, in MHz.
            - ``theta``: pointing-frame colatitude in radians, shape ``(nlat, nlon)``.
            - ``phi``: pointing-frame azimuth in radians, shape ``(nlat, nlon)``.

            Return shape: ``(n_freq, nlat, nlon)``, real-valued.
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        pol_product : {"XX", "YY", "XY", "YX"}
            Correlator polarisation product label.
        sky_pol : {"I", "Q", "U", "V"}
            Sky Stokes parameter this power beam projects onto.
        normalisation : {"unnormalised", "peak", "integral", "custom"}
            Normalisation convention.  Default ``"unnormalised"``.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$.
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$.
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        apply_horizon : bool
            If ``True``, apply a horizon taper to the beam map before the SHT.
            Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments for the horizon taper: ``taper_width`` (float,
            default 0.0) and ``apply_sqrt`` (bool, default ``False`` — power
            beams are masked directly, not amplitude-tapered).
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.

        Returns
        -------
        TIRSPowerBeam
            Power beam in the TIRS frame, ``alm`` shape
            ``(n_freq, lmax+1, 2*mmax+1)``.
        """
        if aberrate:
            grid = grid_template(lmax)
            tirs_theta, tirs_phi = grid_theta_phi(grid, meshgrid=True)
            tirs_normals = spherical_to_normal(tirs_theta, tirs_phi)
            tirs_normals_ab = diurnal_aberrate_tirs(tirs_normals, latitude, longitude)
            rot = tirs_to_pointing(
                latitude=latitude,
                longitude=longitude,
                altitude=altitude,
                azimuth=azimuth,
                boresight=boresight,
            ).as_matrix()
            pointing_normals = (rot @ tirs_normals_ab[..., None]).squeeze()
            pointing_theta, pointing_phi = normal_to_spherical(pointing_normals)
        else:
            pointing_theta, pointing_phi = cls._compute_pointing_coords(
                lmax, latitude, longitude, altitude, azimuth, boresight
            )
        power_map: npt.NDArray[np.float64] = scalar_callable(
            frequencies_MHz[:, None, None], pointing_theta, pointing_phi
        )
        if apply_horizon:
            _kw = horizon_taper_kwargs or {}
            power_map = power_map * _compute_horizon_mask(
                lmax, latitude, longitude,
                taper_width=_kw.get("taper_width", 0.0),
                apply_sqrt=_kw.get("apply_sqrt", False),
            )
        pbeam_alm: npt.NDArray[np.complex128] = np.asarray(
            batch_array_analysis(power_map.astype(np.complex128), lmax)
        )
        _mmax = mmax if mmax is not None else lmax
        if _mmax < lmax:
            pbeam_alm = set_bandlimits(pbeam_alm, lmax=lmax, mmax=_mmax)
        return cls(
            lmax=lmax,
            mmax=_mmax,
            frequencies_MHz=frequencies_MHz,
            alm=pbeam_alm,
            sht_basis="TIRS",
            pol_product=pol_product,
            sky_pol=sky_pol,
            normalisation=normalisation,
        )

    @classmethod
    def from_gaussian(
        cls,
        D_eff: float,
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        pol_product: Literal["XX", "YY", "XY", "YX"],
        sky_pol: Literal["I", "Q", "U", "V"],
        normalisation: Literal[
            "unnormalised", "peak", "integral", "custom"
        ] = "unnormalised",
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        asymmetry_ratio: float = 1.0,
        asymmetry_angle: float = 0.0,
        use_sin_theta: bool = False,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
    ) -> "TIRSPowerBeam":
        r"""Construct a TIRSPowerBeam from a Gaussian power beam model.

        The power pattern is a Gaussian with major-axis FWHM equal to
        $\approx 1.029 \, \lambda / D_\mathrm{eff}$, chosen to match the FWHM
        of the Airy disk for a uniformly-illuminated circular aperture.  An
        optional elliptical asymmetry stretches the beam along a specified axis.

        Parameters
        ----------
        D_eff : float
            Effective dish diameter in metres.  Sets the major-axis power-beam
            FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$
            (the Airy-disk FWHM for a uniformly-illuminated aperture).
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        pol_product : {"XX", "YY", "XY", "YX"}
            Correlator polarisation product label.
        sky_pol : {"I", "Q", "U", "V"}
            Sky Stokes parameter this power beam projects onto.
        normalisation : {"unnormalised", "peak", "integral", "custom"}
            Normalisation convention.  Default ``"unnormalised"``.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$.
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$.
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        asymmetry_ratio : float
            Ratio of semi-major to semi-minor FWHM ($\geq 1$).  The major axis
            FWHM equals the base FWHM from ``D_eff``; the minor axis is
            reduced by this factor.  Default 1.0 (circular).
        asymmetry_angle : float
            Orientation of the major axis in the pointing frame, in radians
            measured from $\phi = 0$.  Default 0.0.
        use_sin_theta : bool
            If ``True``, use $\sin\theta$ instead of $\theta$ in the Gaussian exponent.
            The FWHM is then exact in $\sin\theta$, matching the physically motivated
            ``from_airy``.  Default ``False``.
        apply_horizon : bool
            Apply a horizon taper before the SHT.  Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments for the horizon taper.
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.

        Returns
        -------
        TIRSPowerBeam
        """
        _gaussian_power_callable = partial(
            gaussian_pattern,
            D_eff=D_eff,
            asymmetry_ratio=asymmetry_ratio,
            asymmetry_angle=asymmetry_angle,
            use_sin_theta=use_sin_theta,
            power=True,
        )

        return cls.from_scalar_callable(
            _gaussian_power_callable,
            lmax,
            mmax,
            frequencies_MHz=frequencies_MHz,
            pol_product=pol_product,
            sky_pol=sky_pol,
            normalisation=normalisation,
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
            apply_horizon=apply_horizon,
            horizon_taper_kwargs=horizon_taper_kwargs,
            aberrate=aberrate,
        )

    @classmethod
    def from_airy(
        cls,
        D_eff: float,
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        pol_product: Literal["XX", "YY", "XY", "YX"],
        sky_pol: Literal["I", "Q", "U", "V"],
        normalisation: Literal[
            "unnormalised", "peak", "integral", "custom"
        ] = "unnormalised",
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        asymmetry_ratio: float = 1.0,
        asymmetry_angle: float = 0.0,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
    ) -> "TIRSPowerBeam":
        r"""Construct a TIRSPowerBeam from an Airy disk power beam model.

        The power pattern is $(2 J_1(x) / x)^2$ where
        $x = \pi D_\mathrm{eff} \sin\theta / \lambda$, the standard diffraction
        pattern for a uniformly-illuminated circular aperture.  The FWHM of the
        power beam is $\approx 1.029 \, \lambda / D_\mathrm{eff}$.  An optional
        elliptical asymmetry makes the effective aperture direction-dependent.

        Parameters
        ----------
        D_eff : float
            Effective dish diameter in metres.  Sets the major-axis power-beam
            FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$.
            Along the minor axis the effective diameter is
            $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        pol_product : {"XX", "YY", "XY", "YX"}
            Correlator polarisation product label.
        sky_pol : {"I", "Q", "U", "V"}
            Sky Stokes parameter this power beam projects onto.
        normalisation : {"unnormalised", "peak", "integral", "custom"}
            Normalisation convention.  Default ``"unnormalised"``.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$.
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$.
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        asymmetry_ratio : float
            Ratio of semi-major to semi-minor beam width ($\geq 1$).  Along the
            major axis the aperture is $D_\mathrm{eff}$; along the minor axis it
            is $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.  Default 1.0.
        asymmetry_angle : float
            Orientation of the major axis in the pointing frame, in radians
            measured from $\phi = 0$.  Default 0.0.
        apply_horizon : bool
            Apply a horizon taper before the SHT.  Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments for the horizon taper.
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.

        Returns
        -------
        TIRSPowerBeam
        """
        _airy_power_callable = partial(
            airy_pattern,
            D_eff=D_eff,
            asymmetry_ratio=asymmetry_ratio,
            asymmetry_angle=asymmetry_angle,
            power=True,
        )

        return cls.from_scalar_callable(
            _airy_power_callable,
            lmax,
            mmax,
            frequencies_MHz=frequencies_MHz,
            pol_product=pol_product,
            sky_pol=sky_pol,
            normalisation=normalisation,
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
            apply_horizon=apply_horizon,
            horizon_taper_kwargs=horizon_taper_kwargs,
            aberrate=aberrate,
        )

    @classmethod
    @functools.lru_cache(maxsize=1)
    def _compute_pointing_coords(
        cls,
        lmax: int,
        latitude: float,
        longitude: float,
        altitude: float,
        azimuth: float,
        boresight: float,
    ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
        """Compute and cache pointing-frame coordinates for this beam class."""
        return pointed_theta_phi(
            lmax, latitude, longitude, altitude, azimuth, boresight
        )

    @classmethod
    def clear_cache(cls) -> None:
        """Clear this class's pointing-coordinates LRU cache."""
        cls._compute_pointing_coords.cache_clear()

    def to_pointed_basis(
        self,
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        mmax: int | None = None,
    ) -> "TIRSPowerBeam":
        """Rotate the SHT decomposition basis from TIRS to a pointing-aligned frame.

        Useful for compactly representing beams with large amounts of azimuthal
        symmetry in the pointing frame (e.g. a zenith-pointing dish).

        .. important::
            This method **only** changes the SHT decomposition basis of the beam.
            Polarisation projections (the mapping from feed X/Y to Stokes
            parameters) remain defined in the TIRS frame and are unaffected by
            this rotation.  Use ``mmax`` to truncate the azimuthal bandwidth of
            the output when the beam is compact in m in the new basis.

        Parameters
        ----------
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians. Default π/2 (zenith).
        azimuth : float
            Pointing azimuth in radians (measured from North through East).
            Default π (South).
        boresight : float
            Rotation about the pointing axis in radians. Default 0.
        mmax : int, optional
            If provided, truncate the output to this azimuthal bandlimit after
            rotation.  Must be ≤ ``lmax``.  Useful when rotating to a basis where
            the beam is more compact in m-modes.

        Returns
        -------
        TIRSPowerBeam
            A new TIRSPowerBeam with ``sht_basis="Pointing"``.
        """
        return _to_pointed_basis_impl(self, latitude, longitude, altitude, azimuth, boresight, mmax)

    def integrals(self) -> npt.NDArray[np.complex128]:
        """Per-frequency integral of the beam over the unit sphere."""
        return integrals_from_alm(self.alm)

    def normalise(
        self,
        normalisation: Literal["peak", "integral", "custom"],
        integrals: npt.NDArray[np.float64] | None = None,
    ) -> "TIRSPowerBeam":
        """Return a new TIRSPowerBeam with normalised alm coefficients.

        Parameters
        ----------
        normalisation : {"peak", "integral", "custom"}
            Normalisation mode:

            - ``"peak"`` — each frequency channel divided by the maximum
              absolute value of its synthesised map.
            - ``"integral"`` — each frequency channel divided by its sphere
              integral so that ``integrals() == 1``.
            - ``"custom"`` — divide so that ``integrals()`` equals the
              user-supplied target ``integrals`` array.

        integrals : ndarray of shape (n_freq,), optional
            Required when ``normalisation="custom"``.  Target per-frequency
            sphere integrals for the returned beam.

        Returns
        -------
        TIRSPowerBeam
            A new container with scaled ``alm`` and updated ``normalisation``.
        """
        if normalisation == "peak":
            factors = np.array(
                [
                    np.max(np.abs(array_synthesis(self.alm[f]).data))
                    for f in range(self.alm.shape[0])
                ]
            )
        elif normalisation == "integral":
            factors = self.integrals()
        elif normalisation == "custom":
            if integrals is None:
                raise ValueError(
                    "'integrals' must be provided for normalisation='custom'."
                )
            factors = self.integrals() / np.asarray(integrals)
        else:
            raise ValueError(f"Unknown normalisation mode '{normalisation}'.")

        factors = np.asarray(factors)
        if np.any(factors == 0):
            raise ValueError("Cannot normalise: one or more factors are zero.")

        new_alm = self.alm / factors[:, np.newaxis, np.newaxis]
        return attrs.evolve(self, alm=new_alm, normalisation=normalisation)

    def apply_horizon(
        self,
        latitude: float,
        longitude: float,
        taper_width: float = 0.0,
    ) -> "TIRSPowerBeam":
        """Apply a horizon mask to the beam in map space.

        Multiplies each frequency channel's synthesised map by a mask that is 1
        above the local horizon, tapers smoothly to 0 over ``taper_width`` radians
        approaching the horizon, and is exactly 0 below the horizon.

        Only supported for beams with ``sht_basis="TIRS"``.

        Parameters
        ----------
        latitude : float
            Observer latitude in radians.
        longitude : float
            Observer longitude in radians.
        taper_width : float
            Angular width of the sin²-taper region in radians.  Default 0
            gives a sharp horizon cutoff.

        Returns
        -------
        TIRSPowerBeam
            New beam with horizon mask applied; ``sht_basis`` unchanged.

        Raises
        ------
        ValueError
            If ``sht_basis`` is not ``"TIRS"``.
        """
        if self.sht_basis != "TIRS":
            raise ValueError(
                f"apply_horizon requires sht_basis='TIRS', got '{self.sht_basis}'."
            )

        mask = _compute_horizon_mask(self.lmax, latitude, longitude, taper_width=taper_width)

        maps = np.array(
            [array_synthesis(self.alm[f]).data for f in range(self.alm.shape[0])]
        )
        tapered_maps = maps * mask  # (n_freq, nlat, nlon) * (nlat, nlon)

        new_alm = batch_array_analysis(tapered_maps, self.lmax)
        new_alm = set_bandlimits(new_alm, lmax=self.lmax, mmax=self.mmax)
        return attrs.evolve(self, alm=new_alm)

apply_horizon(latitude, longitude, taper_width=0.0)

Apply a horizon mask to the beam in map space.

Multiplies each frequency channel's synthesised map by a mask that is 1 above the local horizon, tapers smoothly to 0 over taper_width radians approaching the horizon, and is exactly 0 below the horizon.

Only supported for beams with sht_basis="TIRS".

Parameters:

Name Type Description Default
latitude float

Observer latitude in radians.

required
longitude float

Observer longitude in radians.

required
taper_width float

Angular width of the sin²-taper region in radians. Default 0 gives a sharp horizon cutoff.

0.0

Returns:

Type Description
TIRSPowerBeam

New beam with horizon mask applied; sht_basis unchanged.

Raises:

Type Description
ValueError

If sht_basis is not "TIRS".

Source code in src/serval/containers.py
def apply_horizon(
    self,
    latitude: float,
    longitude: float,
    taper_width: float = 0.0,
) -> "TIRSPowerBeam":
    """Apply a horizon mask to the beam in map space.

    Multiplies each frequency channel's synthesised map by a mask that is 1
    above the local horizon, tapers smoothly to 0 over ``taper_width`` radians
    approaching the horizon, and is exactly 0 below the horizon.

    Only supported for beams with ``sht_basis="TIRS"``.

    Parameters
    ----------
    latitude : float
        Observer latitude in radians.
    longitude : float
        Observer longitude in radians.
    taper_width : float
        Angular width of the sin²-taper region in radians.  Default 0
        gives a sharp horizon cutoff.

    Returns
    -------
    TIRSPowerBeam
        New beam with horizon mask applied; ``sht_basis`` unchanged.

    Raises
    ------
    ValueError
        If ``sht_basis`` is not ``"TIRS"``.
    """
    if self.sht_basis != "TIRS":
        raise ValueError(
            f"apply_horizon requires sht_basis='TIRS', got '{self.sht_basis}'."
        )

    mask = _compute_horizon_mask(self.lmax, latitude, longitude, taper_width=taper_width)

    maps = np.array(
        [array_synthesis(self.alm[f]).data for f in range(self.alm.shape[0])]
    )
    tapered_maps = maps * mask  # (n_freq, nlat, nlon) * (nlat, nlon)

    new_alm = batch_array_analysis(tapered_maps, self.lmax)
    new_alm = set_bandlimits(new_alm, lmax=self.lmax, mmax=self.mmax)
    return attrs.evolve(self, alm=new_alm)

clear_cache() classmethod

Clear this class's pointing-coordinates LRU cache.

Source code in src/serval/containers.py
@classmethod
def clear_cache(cls) -> None:
    """Clear this class's pointing-coordinates LRU cache."""
    cls._compute_pointing_coords.cache_clear()

from_airy(D_eff, lmax, mmax=None, *, frequencies_MHz, pol_product, sky_pol, normalisation='unnormalised', latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, asymmetry_ratio=1.0, asymmetry_angle=0.0, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False) classmethod

Construct a TIRSPowerBeam from an Airy disk power beam model.

The power pattern is \((2 J_1(x) / x)^2\) where \(x = \pi D_\mathrm{eff} \sin\theta / \lambda\), the standard diffraction pattern for a uniformly-illuminated circular aperture. The FWHM of the power beam is \(\approx 1.029 \, \lambda / D_\mathrm{eff}\). An optional elliptical asymmetry makes the effective aperture direction-dependent.

Parameters:

Name Type Description Default
D_eff float

Effective dish diameter in metres. Sets the major-axis power-beam FWHM via \(\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}\). Along the minor axis the effective diameter is \(D_\mathrm{eff} / \mathrm{asymmetry\_ratio}\).

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
pol_product (XX, YY, XY, YX)

Correlator polarisation product label.

"XX"
sky_pol (I, Q, U, V)

Sky Stokes parameter this power beam projects onto.

"I"
normalisation (unnormalised, peak, integral, custom)

Normalisation convention. Default "unnormalised".

"unnormalised"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
asymmetry_ratio float

Ratio of semi-major to semi-minor beam width (\(\geq 1\)). Along the major axis the aperture is \(D_\mathrm{eff}\); along the minor axis it is \(D_\mathrm{eff} / \mathrm{asymmetry\_ratio}\). Default 1.0.

1.0
asymmetry_angle float

Orientation of the major axis in the pointing frame, in radians measured from \(\phi = 0\). Default 0.0.

0.0
apply_horizon bool

Apply a horizon taper before the SHT. Default False.

False
horizon_taper_kwargs dict

Keyword arguments for the horizon taper.

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False

Returns:

Type Description
TIRSPowerBeam
Source code in src/serval/containers.py
@classmethod
def from_airy(
    cls,
    D_eff: float,
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    pol_product: Literal["XX", "YY", "XY", "YX"],
    sky_pol: Literal["I", "Q", "U", "V"],
    normalisation: Literal[
        "unnormalised", "peak", "integral", "custom"
    ] = "unnormalised",
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    asymmetry_ratio: float = 1.0,
    asymmetry_angle: float = 0.0,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
) -> "TIRSPowerBeam":
    r"""Construct a TIRSPowerBeam from an Airy disk power beam model.

    The power pattern is $(2 J_1(x) / x)^2$ where
    $x = \pi D_\mathrm{eff} \sin\theta / \lambda$, the standard diffraction
    pattern for a uniformly-illuminated circular aperture.  The FWHM of the
    power beam is $\approx 1.029 \, \lambda / D_\mathrm{eff}$.  An optional
    elliptical asymmetry makes the effective aperture direction-dependent.

    Parameters
    ----------
    D_eff : float
        Effective dish diameter in metres.  Sets the major-axis power-beam
        FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$.
        Along the minor axis the effective diameter is
        $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    pol_product : {"XX", "YY", "XY", "YX"}
        Correlator polarisation product label.
    sky_pol : {"I", "Q", "U", "V"}
        Sky Stokes parameter this power beam projects onto.
    normalisation : {"unnormalised", "peak", "integral", "custom"}
        Normalisation convention.  Default ``"unnormalised"``.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$.
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$.
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    asymmetry_ratio : float
        Ratio of semi-major to semi-minor beam width ($\geq 1$).  Along the
        major axis the aperture is $D_\mathrm{eff}$; along the minor axis it
        is $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.  Default 1.0.
    asymmetry_angle : float
        Orientation of the major axis in the pointing frame, in radians
        measured from $\phi = 0$.  Default 0.0.
    apply_horizon : bool
        Apply a horizon taper before the SHT.  Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments for the horizon taper.
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.

    Returns
    -------
    TIRSPowerBeam
    """
    _airy_power_callable = partial(
        airy_pattern,
        D_eff=D_eff,
        asymmetry_ratio=asymmetry_ratio,
        asymmetry_angle=asymmetry_angle,
        power=True,
    )

    return cls.from_scalar_callable(
        _airy_power_callable,
        lmax,
        mmax,
        frequencies_MHz=frequencies_MHz,
        pol_product=pol_product,
        sky_pol=sky_pol,
        normalisation=normalisation,
        latitude=latitude,
        longitude=longitude,
        altitude=altitude,
        azimuth=azimuth,
        boresight=boresight,
        apply_horizon=apply_horizon,
        horizon_taper_kwargs=horizon_taper_kwargs,
        aberrate=aberrate,
    )

from_gaussian(D_eff, lmax, mmax=None, *, frequencies_MHz, pol_product, sky_pol, normalisation='unnormalised', latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, asymmetry_ratio=1.0, asymmetry_angle=0.0, use_sin_theta=False, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False) classmethod

Construct a TIRSPowerBeam from a Gaussian power beam model.

The power pattern is a Gaussian with major-axis FWHM equal to \(\approx 1.029 \, \lambda / D_\mathrm{eff}\), chosen to match the FWHM of the Airy disk for a uniformly-illuminated circular aperture. An optional elliptical asymmetry stretches the beam along a specified axis.

Parameters:

Name Type Description Default
D_eff float

Effective dish diameter in metres. Sets the major-axis power-beam FWHM via \(\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}\) (the Airy-disk FWHM for a uniformly-illuminated aperture).

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
pol_product (XX, YY, XY, YX)

Correlator polarisation product label.

"XX"
sky_pol (I, Q, U, V)

Sky Stokes parameter this power beam projects onto.

"I"
normalisation (unnormalised, peak, integral, custom)

Normalisation convention. Default "unnormalised".

"unnormalised"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
asymmetry_ratio float

Ratio of semi-major to semi-minor FWHM (\(\geq 1\)). The major axis FWHM equals the base FWHM from D_eff; the minor axis is reduced by this factor. Default 1.0 (circular).

1.0
asymmetry_angle float

Orientation of the major axis in the pointing frame, in radians measured from \(\phi = 0\). Default 0.0.

0.0
use_sin_theta bool

If True, use \(\sin\theta\) instead of \(\theta\) in the Gaussian exponent. The FWHM is then exact in \(\sin\theta\), matching the physically motivated from_airy. Default False.

False
apply_horizon bool

Apply a horizon taper before the SHT. Default False.

False
horizon_taper_kwargs dict

Keyword arguments for the horizon taper.

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False

Returns:

Type Description
TIRSPowerBeam
Source code in src/serval/containers.py
@classmethod
def from_gaussian(
    cls,
    D_eff: float,
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    pol_product: Literal["XX", "YY", "XY", "YX"],
    sky_pol: Literal["I", "Q", "U", "V"],
    normalisation: Literal[
        "unnormalised", "peak", "integral", "custom"
    ] = "unnormalised",
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    asymmetry_ratio: float = 1.0,
    asymmetry_angle: float = 0.0,
    use_sin_theta: bool = False,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
) -> "TIRSPowerBeam":
    r"""Construct a TIRSPowerBeam from a Gaussian power beam model.

    The power pattern is a Gaussian with major-axis FWHM equal to
    $\approx 1.029 \, \lambda / D_\mathrm{eff}$, chosen to match the FWHM
    of the Airy disk for a uniformly-illuminated circular aperture.  An
    optional elliptical asymmetry stretches the beam along a specified axis.

    Parameters
    ----------
    D_eff : float
        Effective dish diameter in metres.  Sets the major-axis power-beam
        FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$
        (the Airy-disk FWHM for a uniformly-illuminated aperture).
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    pol_product : {"XX", "YY", "XY", "YX"}
        Correlator polarisation product label.
    sky_pol : {"I", "Q", "U", "V"}
        Sky Stokes parameter this power beam projects onto.
    normalisation : {"unnormalised", "peak", "integral", "custom"}
        Normalisation convention.  Default ``"unnormalised"``.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$.
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$.
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    asymmetry_ratio : float
        Ratio of semi-major to semi-minor FWHM ($\geq 1$).  The major axis
        FWHM equals the base FWHM from ``D_eff``; the minor axis is
        reduced by this factor.  Default 1.0 (circular).
    asymmetry_angle : float
        Orientation of the major axis in the pointing frame, in radians
        measured from $\phi = 0$.  Default 0.0.
    use_sin_theta : bool
        If ``True``, use $\sin\theta$ instead of $\theta$ in the Gaussian exponent.
        The FWHM is then exact in $\sin\theta$, matching the physically motivated
        ``from_airy``.  Default ``False``.
    apply_horizon : bool
        Apply a horizon taper before the SHT.  Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments for the horizon taper.
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.

    Returns
    -------
    TIRSPowerBeam
    """
    _gaussian_power_callable = partial(
        gaussian_pattern,
        D_eff=D_eff,
        asymmetry_ratio=asymmetry_ratio,
        asymmetry_angle=asymmetry_angle,
        use_sin_theta=use_sin_theta,
        power=True,
    )

    return cls.from_scalar_callable(
        _gaussian_power_callable,
        lmax,
        mmax,
        frequencies_MHz=frequencies_MHz,
        pol_product=pol_product,
        sky_pol=sky_pol,
        normalisation=normalisation,
        latitude=latitude,
        longitude=longitude,
        altitude=altitude,
        azimuth=azimuth,
        boresight=boresight,
        apply_horizon=apply_horizon,
        horizon_taper_kwargs=horizon_taper_kwargs,
        aberrate=aberrate,
    )

from_scalar_callable(scalar_callable, lmax, mmax=None, *, frequencies_MHz, pol_product, sky_pol, normalisation='unnormalised', latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False) classmethod

Construct a TIRSPowerBeam from a scalar power beam callable.

Evaluates a scalar (real-valued) power pattern on the TIRS grid in pointing-frame coordinates, then decomposes it into spherical harmonics.

Parameters:

Name Type Description Default
scalar_callable Callable

A function (freq, theta, phi) -> beam evaluating the power pattern in pointing-frame coordinates. Input shapes:

  • freq: broadcastable to (n_freq, ntheta, nphi), in MHz.
  • theta: pointing-frame colatitude in radians, shape (nlat, nlon).
  • phi: pointing-frame azimuth in radians, shape (nlat, nlon).

Return shape: (n_freq, nlat, nlon), real-valued.

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
pol_product (XX, YY, XY, YX)

Correlator polarisation product label.

"XX"
sky_pol (I, Q, U, V)

Sky Stokes parameter this power beam projects onto.

"I"
normalisation (unnormalised, peak, integral, custom)

Normalisation convention. Default "unnormalised".

"unnormalised"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
apply_horizon bool

If True, apply a horizon taper to the beam map before the SHT. Default False.

False
horizon_taper_kwargs dict

Keyword arguments for the horizon taper: taper_width (float, default 0.0) and apply_sqrt (bool, default False — power beams are masked directly, not amplitude-tapered).

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False

Returns:

Type Description
TIRSPowerBeam

Power beam in the TIRS frame, alm shape (n_freq, lmax+1, 2*mmax+1).

Source code in src/serval/containers.py
@classmethod
def from_scalar_callable(
    cls,
    scalar_callable: Callable[
        [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]],
        npt.NDArray[np.float64],
    ],
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    pol_product: Literal["XX", "YY", "XY", "YX"],
    sky_pol: Literal["I", "Q", "U", "V"],
    normalisation: Literal[
        "unnormalised", "peak", "integral", "custom"
    ] = "unnormalised",
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
) -> "TIRSPowerBeam":
    r"""Construct a TIRSPowerBeam from a scalar power beam callable.

    Evaluates a scalar (real-valued) power pattern on the TIRS grid in
    pointing-frame coordinates, then decomposes it into spherical harmonics.

    Parameters
    ----------
    scalar_callable : Callable
        A function ``(freq, theta, phi) -> beam`` evaluating the power
        pattern in pointing-frame coordinates.  Input shapes:

        - ``freq``: broadcastable to ``(n_freq, ntheta, nphi)``, in MHz.
        - ``theta``: pointing-frame colatitude in radians, shape ``(nlat, nlon)``.
        - ``phi``: pointing-frame azimuth in radians, shape ``(nlat, nlon)``.

        Return shape: ``(n_freq, nlat, nlon)``, real-valued.
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    pol_product : {"XX", "YY", "XY", "YX"}
        Correlator polarisation product label.
    sky_pol : {"I", "Q", "U", "V"}
        Sky Stokes parameter this power beam projects onto.
    normalisation : {"unnormalised", "peak", "integral", "custom"}
        Normalisation convention.  Default ``"unnormalised"``.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$.
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$.
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    apply_horizon : bool
        If ``True``, apply a horizon taper to the beam map before the SHT.
        Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments for the horizon taper: ``taper_width`` (float,
        default 0.0) and ``apply_sqrt`` (bool, default ``False`` — power
        beams are masked directly, not amplitude-tapered).
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.

    Returns
    -------
    TIRSPowerBeam
        Power beam in the TIRS frame, ``alm`` shape
        ``(n_freq, lmax+1, 2*mmax+1)``.
    """
    if aberrate:
        grid = grid_template(lmax)
        tirs_theta, tirs_phi = grid_theta_phi(grid, meshgrid=True)
        tirs_normals = spherical_to_normal(tirs_theta, tirs_phi)
        tirs_normals_ab = diurnal_aberrate_tirs(tirs_normals, latitude, longitude)
        rot = tirs_to_pointing(
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
        ).as_matrix()
        pointing_normals = (rot @ tirs_normals_ab[..., None]).squeeze()
        pointing_theta, pointing_phi = normal_to_spherical(pointing_normals)
    else:
        pointing_theta, pointing_phi = cls._compute_pointing_coords(
            lmax, latitude, longitude, altitude, azimuth, boresight
        )
    power_map: npt.NDArray[np.float64] = scalar_callable(
        frequencies_MHz[:, None, None], pointing_theta, pointing_phi
    )
    if apply_horizon:
        _kw = horizon_taper_kwargs or {}
        power_map = power_map * _compute_horizon_mask(
            lmax, latitude, longitude,
            taper_width=_kw.get("taper_width", 0.0),
            apply_sqrt=_kw.get("apply_sqrt", False),
        )
    pbeam_alm: npt.NDArray[np.complex128] = np.asarray(
        batch_array_analysis(power_map.astype(np.complex128), lmax)
    )
    _mmax = mmax if mmax is not None else lmax
    if _mmax < lmax:
        pbeam_alm = set_bandlimits(pbeam_alm, lmax=lmax, mmax=_mmax)
    return cls(
        lmax=lmax,
        mmax=_mmax,
        frequencies_MHz=frequencies_MHz,
        alm=pbeam_alm,
        sht_basis="TIRS",
        pol_product=pol_product,
        sky_pol=sky_pol,
        normalisation=normalisation,
    )

from_voltage_beams(vbeam_i, vbeam_j, sky_pol='I', power_beam_lmax=None, power_beam_mmax=None, method='grid') classmethod

Construct a TIRSPowerBeam from two TIRSVoltageBeams.

The pol_product is inferred as vbeam_i.polarisation + vbeam_j.polarisation.

Parameters:

Name Type Description Default
vbeam_i TIRSVoltageBeam

Voltage beams for feeds i and j.

required
vbeam_j TIRSVoltageBeam

Voltage beams for feeds i and j.

required
sky_pol (I, Q, U, V)

Sky Stokes parameter to project onto.

"I"
power_beam_lmax int

Bandlimit of the output power beam. Defaults to 2 * vbeam_i.lmax.

None
power_beam_mmax int

Azimuthal bandlimit. Defaults to 2 * vbeam_i.mmax.

None
method (grid, gaunt)

SH product method. "grid" is faster; "gaunt" uses cached Gaunt coefficients.

"grid"

Returns:

Type Description
TIRSPowerBeam

Power beam in the TIRS frame with alm of shape (n_freq, lmax+1, 2*mmax+1).

Source code in src/serval/containers.py
@classmethod
def from_voltage_beams(
    cls,
    vbeam_i: "TIRSVoltageBeam",
    vbeam_j: "TIRSVoltageBeam",
    sky_pol: Literal["I", "Q", "U", "V"] = "I",
    power_beam_lmax: int | None = None,
    power_beam_mmax: int | None = None,
    method: Literal["grid", "gaunt"] = "grid",
) -> "TIRSPowerBeam":
    """Construct a TIRSPowerBeam from two TIRSVoltageBeams.

    The ``pol_product`` is inferred as
    ``vbeam_i.polarisation + vbeam_j.polarisation``.

    Parameters
    ----------
    vbeam_i, vbeam_j : TIRSVoltageBeam
        Voltage beams for feeds i and j.
    sky_pol : {"I", "Q", "U", "V"}
        Sky Stokes parameter to project onto.
    power_beam_lmax : int, optional
        Bandlimit of the output power beam. Defaults to 2 * vbeam_i.lmax.
    power_beam_mmax : int, optional
        Azimuthal bandlimit. Defaults to 2 * vbeam_i.mmax.
    method : {"grid", "gaunt"}
        SH product method. "grid" is faster; "gaunt" uses cached Gaunt
        coefficients.

    Returns
    -------
    TIRSPowerBeam
        Power beam in the TIRS frame with alm of shape
        ``(n_freq, lmax+1, 2*mmax+1)``.
    """

    # Capture voltage beam provenance for metadata
    vbeam_i_meta = {
        **vbeam_i.metadata,
        "lmax": vbeam_i.lmax,
        "mmax": vbeam_i.mmax,
        "polarisation": vbeam_i.polarisation,
    }
    vbeam_j_meta = {
        **vbeam_j.metadata,
        "lmax": vbeam_j.lmax,
        "mmax": vbeam_j.mmax,
        "polarisation": vbeam_j.polarisation,
    }

    pol_product = vbeam_i.polarisation + vbeam_j.polarisation
    if pol_product not in ["XX", "YY", "XY", "YX"]:
        raise ValueError(
            f"pol_product '{pol_product}' is not valid. "
            f"vbeam_i.polarisation='{vbeam_i.polarisation}' and "
            f"vbeam_j.polarisation='{vbeam_j.polarisation}' must together "
            f"form one of 'XX', 'YY', 'XY', 'YX'."
        )
    if not np.array_equal(vbeam_i.frequencies_MHz, vbeam_j.frequencies_MHz):
        raise ValueError(
            f"vbeam_i and vbeam_j frequencies_MHz do not match. "
            f"vbeam_i: {vbeam_i.frequencies_MHz}, vbeam_j: {vbeam_j.frequencies_MHz}."
        )
    if vbeam_i.sht_basis != vbeam_j.sht_basis:
        raise ValueError(
            f"vbeam_i and vbeam_j must have the same sht_basis, got "
            f"'{vbeam_i.sht_basis}' and '{vbeam_j.sht_basis}'."
        )

    if vbeam_i.lmax != vbeam_j.lmax or vbeam_i.mmax != vbeam_j.mmax:
        target_lmax = max(vbeam_i.lmax, vbeam_j.lmax)
        target_mmax = max(vbeam_i.mmax, vbeam_j.mmax)
        vbeam_i = vbeam_i.bandlimited_to(lmax=target_lmax, mmax=target_mmax)
        vbeam_j = vbeam_j.bandlimited_to(lmax=target_lmax, mmax=target_mmax)

    if power_beam_lmax is None:
        power_beam_lmax = 2 * vbeam_i.lmax
    if power_beam_mmax is None:
        power_beam_mmax = 2 * vbeam_i.mmax

    converter = PowerFromVoltageBeams(
        voltage_beam_lmax=vbeam_i.lmax,
        voltage_beam_mmax=vbeam_i.mmax,
        power_beam_lmax=power_beam_lmax,
        power_beam_mmax=power_beam_mmax,
        generate_cache_on_init=False,
    )

    stokes_factors = {
        "I": [[1.0, 0.0], [0.0, 1.0]],
        "Q": [[1.0, 0.0], [0.0, -1.0]],
        "U": [[0.0, 1.0], [1.0, 0.0]],
        "V": [[0.0, 1.0j], [-1.0j, 0.0]],
    }[sky_pol]

    n_freq = len(vbeam_i.frequencies_MHz)
    pbeam_alm = empty_complex_array(
        (n_freq, power_beam_lmax + 1, 2 * power_beam_mmax + 1))
    for i_index, j_index in np.argwhere(stokes_factors):
        pbeam_alm += stokes_factors[i_index][
            j_index
        ] * converter.grid_batch_power_beam_alm(
            vbeam_i.alm[i_index], vbeam_j.alm[j_index]
        )

    return cls(
        lmax=power_beam_lmax,
        mmax=power_beam_mmax,
        frequencies_MHz=vbeam_i.frequencies_MHz.copy(),
        alm=pbeam_alm,
        sht_basis=vbeam_i.sht_basis,
        pol_product=pol_product,
        sky_pol=sky_pol,
        metadata={"vbeam_i": vbeam_i_meta, "vbeam_j": vbeam_j_meta},
    )

integrals()

Per-frequency integral of the beam over the unit sphere.

Source code in src/serval/containers.py
def integrals(self) -> npt.NDArray[np.complex128]:
    """Per-frequency integral of the beam over the unit sphere."""
    return integrals_from_alm(self.alm)

normalise(normalisation, integrals=None)

Return a new TIRSPowerBeam with normalised alm coefficients.

Parameters:

Name Type Description Default
normalisation (peak, integral, custom)

Normalisation mode:

  • "peak" — each frequency channel divided by the maximum absolute value of its synthesised map.
  • "integral" — each frequency channel divided by its sphere integral so that integrals() == 1.
  • "custom" — divide so that integrals() equals the user-supplied target integrals array.
"peak"
integrals ndarray of shape (n_freq,)

Required when normalisation="custom". Target per-frequency sphere integrals for the returned beam.

None

Returns:

Type Description
TIRSPowerBeam

A new container with scaled alm and updated normalisation.

Source code in src/serval/containers.py
def normalise(
    self,
    normalisation: Literal["peak", "integral", "custom"],
    integrals: npt.NDArray[np.float64] | None = None,
) -> "TIRSPowerBeam":
    """Return a new TIRSPowerBeam with normalised alm coefficients.

    Parameters
    ----------
    normalisation : {"peak", "integral", "custom"}
        Normalisation mode:

        - ``"peak"`` — each frequency channel divided by the maximum
          absolute value of its synthesised map.
        - ``"integral"`` — each frequency channel divided by its sphere
          integral so that ``integrals() == 1``.
        - ``"custom"`` — divide so that ``integrals()`` equals the
          user-supplied target ``integrals`` array.

    integrals : ndarray of shape (n_freq,), optional
        Required when ``normalisation="custom"``.  Target per-frequency
        sphere integrals for the returned beam.

    Returns
    -------
    TIRSPowerBeam
        A new container with scaled ``alm`` and updated ``normalisation``.
    """
    if normalisation == "peak":
        factors = np.array(
            [
                np.max(np.abs(array_synthesis(self.alm[f]).data))
                for f in range(self.alm.shape[0])
            ]
        )
    elif normalisation == "integral":
        factors = self.integrals()
    elif normalisation == "custom":
        if integrals is None:
            raise ValueError(
                "'integrals' must be provided for normalisation='custom'."
            )
        factors = self.integrals() / np.asarray(integrals)
    else:
        raise ValueError(f"Unknown normalisation mode '{normalisation}'.")

    factors = np.asarray(factors)
    if np.any(factors == 0):
        raise ValueError("Cannot normalise: one or more factors are zero.")

    new_alm = self.alm / factors[:, np.newaxis, np.newaxis]
    return attrs.evolve(self, alm=new_alm, normalisation=normalisation)

to_pointed_basis(latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, mmax=None)

Rotate the SHT decomposition basis from TIRS to a pointing-aligned frame.

Useful for compactly representing beams with large amounts of azimuthal symmetry in the pointing frame (e.g. a zenith-pointing dish).

.. important:: This method only changes the SHT decomposition basis of the beam. Polarisation projections (the mapping from feed X/Y to Stokes parameters) remain defined in the TIRS frame and are unaffected by this rotation. Use mmax to truncate the azimuthal bandwidth of the output when the beam is compact in m in the new basis.

Parameters:

Name Type Description Default
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default π/2 (zenith).

pi / 2
azimuth float

Pointing azimuth in radians (measured from North through East). Default π (South).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
mmax int

If provided, truncate the output to this azimuthal bandlimit after rotation. Must be ≤ lmax. Useful when rotating to a basis where the beam is more compact in m-modes.

None

Returns:

Type Description
TIRSPowerBeam

A new TIRSPowerBeam with sht_basis="Pointing".

Source code in src/serval/containers.py
def to_pointed_basis(
    self,
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    mmax: int | None = None,
) -> "TIRSPowerBeam":
    """Rotate the SHT decomposition basis from TIRS to a pointing-aligned frame.

    Useful for compactly representing beams with large amounts of azimuthal
    symmetry in the pointing frame (e.g. a zenith-pointing dish).

    .. important::
        This method **only** changes the SHT decomposition basis of the beam.
        Polarisation projections (the mapping from feed X/Y to Stokes
        parameters) remain defined in the TIRS frame and are unaffected by
        this rotation.  Use ``mmax`` to truncate the azimuthal bandwidth of
        the output when the beam is compact in m in the new basis.

    Parameters
    ----------
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians. Default π/2 (zenith).
    azimuth : float
        Pointing azimuth in radians (measured from North through East).
        Default π (South).
    boresight : float
        Rotation about the pointing axis in radians. Default 0.
    mmax : int, optional
        If provided, truncate the output to this azimuthal bandlimit after
        rotation.  Must be ≤ ``lmax``.  Useful when rotating to a basis where
        the beam is more compact in m-modes.

    Returns
    -------
    TIRSPowerBeam
        A new TIRSPowerBeam with ``sht_basis="Pointing"``.
    """
    return _to_pointed_basis_impl(self, latitude, longitude, altitude, azimuth, boresight, mmax)

TIRSVoltageBeam

Bases: HarmonicContainer

Container for voltage beam alm data for a linearly polarised antenna in the TIRS frame.

The alm array has shape (2, n_freq, lmax+1, 2*mmax+1) where the leading axis indexes the theta (0) and phi (1) spherical polarisation components. Construct via from_ludwig3.

Parameters:

Name Type Description Default
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

<dynamic>
frequencies_MHz ndarray[tuple[Any, ...], dtype[float64]]

Frequency array in MHz.

required
alm ndarray[tuple[Any, ...], dtype[complex128]]

Spherical harmonic coefficients of shape (2, n_freq, lmax+1, 2*mmax+1).

required
polarisation Literal['X', 'Y']

Feed polarisation axis. X is aligned with the EW direction; Y with NS.

"X"
sht_basis Literal['Pointing', 'TIRS']

Basis used for the spherical harmonic decomposition. "TIRS" is the natural basis after construction; "Pointing" after a call to to_pointed_basis. Polarisation projections are always defined in the TIRS frame regardless of this value.

"Pointing"
normalisation Literal['unnormalised', 'power_integral', 'custom']
'unnormalised'
Source code in src/serval/containers.py
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
@define(eq=False)
class TIRSVoltageBeam(HarmonicContainer):
    """Container for voltage beam alm data for a linearly polarised antenna in the TIRS frame.

    The ``alm`` array has shape ``(2, n_freq, lmax+1, 2*mmax+1)`` where the
    leading axis indexes the theta (0) and phi (1) spherical polarisation
    components.  Construct via [from_ludwig3][serval.containers.TIRSVoltageBeam.from_ludwig3].

    Parameters
    ----------
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    alm : npt.NDArray[np.complex128]
        Spherical harmonic coefficients of shape ``(2, n_freq, lmax+1, 2*mmax+1)``.
    polarisation : {"X", "Y"}
        Feed polarisation axis.  X is aligned with the EW direction; Y with NS.
    sht_basis : {"Pointing", "TIRS"}
        Basis used for the spherical harmonic decomposition.  ``"TIRS"`` is
        the natural basis after construction; ``"Pointing"`` after a call to
        [to_pointed_basis][serval.containers.TIRSVoltageBeam.to_pointed_basis].
        Polarisation projections are always defined
        in the TIRS frame regardless of this value.
    """

    lmax: int = field(validator=lmax_validator)
    mmax: int = field(
        default=Factory(lambda self: self.lmax, takes_self=True),
        kw_only=True,
        validator=mmax_validator,
    )
    frequencies_MHz: npt.NDArray[np.float64] = field(
        validator=frequencies_MHz_validator
    )
    alm: npt.NDArray[np.complex128] = field(validator=tirsvbeam_alm_validator)
    polarisation: Literal["X", "Y"] = field(validator=attrs.validators.in_(["X", "Y"]))
    normalisation: Literal["unnormalised", "power_integral", "custom"] = field(
        default="unnormalised",
        kw_only=True,
        validator=attrs.validators.in_(["unnormalised", "power_integral", "custom"]),
    )
    sht_basis: Literal["Pointing", "TIRS"] = field(
        default="TIRS",
        kw_only=True,
        validator=attrs.validators.in_(["Pointing", "TIRS"]),
    )

    _to_attrs: ClassVar[list[str]] = [
        "lmax",
        "mmax",
        "polarisation",
        "normalisation",
        "sht_basis",
    ]
    _to_zarr_store: ClassVar[list[str]] = ["frequencies_MHz", "alm"]
    _freq_axis: ClassVar[int] = 1  # alm shape: (2, n_freq, lmax+1, 2*mmax+1)

    def power_integrals(self) -> npt.NDArray[np.float64]:
        """Per-frequency integral of the power pattern |E_theta|^2 + |E_phi|^2 over the unit sphere.

        Uses Parseval's theorem: for orthonormal spherical harmonics the integral equals
        the sum of squared alm magnitudes over both polarisation components.

        Returns
        -------
        npt.NDArray[np.float64]
            Array of shape ``(n_freq,)`` with the real-valued power integrals.
        """
        return np.sum(np.abs(self.alm) ** 2, axis=(0, 2, 3))

    def normalise(
        self,
        normalisation: Literal["power_integral", "custom"],
        power_integrals: npt.NDArray[np.float64] | None = None,
    ) -> "TIRSVoltageBeam":
        """Return a new TIRSVoltageBeam with normalised alm coefficients.

        Parameters
        ----------
        normalisation : {"power_integral", "custom"}
            Normalisation mode:

            - ``"power_integral"`` — each frequency channel divided by the square root of
              its power pattern integral so that ``power_integrals() == 1``.
            - ``"custom"`` — divide so that ``power_integrals()`` equals the
              user-supplied target ``power_integrals`` array.

        power_integrals : ndarray of shape (n_freq,), optional
            Required when ``normalisation="custom"``. Target per-frequency
            power pattern integrals for the returned beam.

        Returns
        -------
        TIRSVoltageBeam
            A new container with scaled ``alm`` and updated ``normalisation``.
        """
        if normalisation == "power_integral":
            factors = np.sqrt(self.power_integrals())
        elif normalisation == "custom":
            if power_integrals is None:
                raise ValueError(
                    "'power_integrals' must be provided for normalisation='custom'."
                )
            factors = np.sqrt(self.power_integrals() / np.asarray(power_integrals))
        else:
            raise ValueError(f"Unknown normalisation mode '{normalisation}'.")

        if np.any(factors == 0):
            raise ValueError("Cannot normalise: one or more factors are zero.")

        new_alm = self.alm / factors[np.newaxis, :, np.newaxis, np.newaxis]
        return attrs.evolve(self, alm=new_alm, normalisation=normalisation)

    def to_pointed_basis(
        self,
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        mmax: int | None = None,
    ) -> "TIRSVoltageBeam":
        """Rotate the SHT decomposition basis from TIRS to a pointing-aligned frame.

        Useful for compactly representing beams with large amounts of azimuthal
        symmetry in the pointing frame (e.g. a zenith-pointing dish).

        .. important::
            This method **only** changes the SHT decomposition basis of the beam.
            Polarisation projections (the mapping from feed X/Y to Stokes
            parameters) remain defined in the TIRS frame and are unaffected by
            this rotation.  Use ``mmax`` to truncate the azimuthal bandwidth of
            the output when the beam is compact in m in the new basis.

        Parameters
        ----------
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians. Default π/2 (zenith).
        azimuth : float
            Pointing azimuth in radians (measured from North through East).
            Default π (South).
        boresight : float
            Rotation about the pointing axis in radians. Default 0.
        mmax : int, optional
            If provided, truncate the output to this azimuthal bandlimit after
            rotation.  Must be ≤ ``lmax``.  Useful when rotating to a basis where
            the beam is more compact in m-modes.

        Returns
        -------
        TIRSVoltageBeam
            A new TIRSVoltageBeam with ``sht_basis="Pointing"``.
        """
        return _to_pointed_basis_impl(self, latitude, longitude, altitude, azimuth, boresight, mmax)

    def apply_horizon(
        self,
        latitude: float,
        longitude: float,
        taper_width: float = 0.0,
        apply_sqrt: bool = True,
    ) -> "TIRSVoltageBeam":
        """Apply a horizon mask to the voltage beam in map space.

        Multiplies each polarisation component and frequency channel's
        synthesised map by a mask that is 1 above the local horizon, tapers
        smoothly to 0 over ``taper_width`` radians approaching the horizon, and
        is exactly 0 below the horizon.

        By default (``apply_sqrt=True``) the square root of the power-beam
        horizon taper is applied, so that constructing a
        [TIRSPowerBeam][serval.containers.TIRSPowerBeam] from two voltage beams that have each had
        ``apply_horizon`` called with identical arguments gives the same result
        as calling ``apply_horizon`` on the resulting
        [TIRSPowerBeam][serval.containers.TIRSPowerBeam].

        Only supported for beams with ``sht_basis="TIRS"``.

        Parameters
        ----------
        latitude : float
            Observer latitude in radians.
        longitude : float
            Observer longitude in radians.
        taper_width : float
            Angular width of the sin²-taper region in radians.  Default 0
            gives a sharp horizon cutoff.
        apply_sqrt : bool
            If ``True`` (default), apply the square root of the horizon mask
            (amplitude taper).  If ``False``, apply the full mask (power taper).

        Returns
        -------
        TIRSVoltageBeam
            New beam with horizon mask applied; ``sht_basis`` unchanged.

        Raises
        ------
        ValueError
            If ``sht_basis`` is not ``"TIRS"``.
        """
        if self.sht_basis != "TIRS":
            raise ValueError(
                f"apply_horizon requires sht_basis='TIRS', got '{self.sht_basis}'."
            )

        mask = _compute_horizon_mask(
            self.lmax, latitude, longitude, taper_width=taper_width, apply_sqrt=apply_sqrt
        )

        # alm shape: (2, n_freq, lmax+1, 2*mmax+1)
        maps = np.array(
            [
                [array_synthesis(self.alm[p, f]).data for f in range(self.alm.shape[1])]
                for p in range(2)
            ]
        )  # (2, n_freq, nlat, nlon)
        tapered_maps = maps * mask  # broadcasts (nlat, nlon) over leading dims

        new_alm = batch_array_analysis(tapered_maps, self.lmax)
        new_alm = set_bandlimits(new_alm, lmax=self.lmax, mmax=self.mmax)
        return attrs.evolve(self, alm=new_alm)

    @classmethod
    @functools.lru_cache(maxsize=1)
    def _compute_pointing_weights(
        cls,
        lmax: int,
        latitude: float,
        longitude: float,
        altitude: float,
        azimuth: float,
        boresight: float,
        aberrate: bool = False,
    ) -> tuple[
        npt.NDArray[np.float64],
        npt.NDArray[np.float64],
        npt.NDArray[np.float64],
        npt.NDArray[np.float64],
    ]:
        """Compute and cache TIRS↔pointing coordinate maps and Ludwig-3 polarisation projection
        weights.

        Returns
        -------
        tuple
            ``(pointing_theta, pointing_phi, co_weights, cr_weights)`` where
            ``co_weights`` and ``cr_weights`` each have shape ``(2, nlat, nlon)``,
            giving the projection of the Ludwig-3 co/cross-pol basis onto the
            TIRS theta/phi components at each grid point.
        """
        grid = grid_template(lmax)
        tirs_theta, tirs_phi = grid_theta_phi(grid, meshgrid=True)
        tirs_theta_hat, tirs_phi_hat = spherical_tangent_vectors(tirs_theta, tirs_phi)
        if aberrate:
            tirs_normals = spherical_to_normal(tirs_theta, tirs_phi)
            tirs_normals_ab = diurnal_aberrate_tirs(tirs_normals, latitude, longitude)
            rot_tirs_to_pointing_ab = tirs_to_pointing(
                latitude=latitude, longitude=longitude,
                altitude=altitude, azimuth=azimuth, boresight=boresight,
            ).as_matrix()
            pointing_normals_ab = (rot_tirs_to_pointing_ab @ tirs_normals_ab[..., None]).squeeze()
            pointing_theta, pointing_phi = normal_to_spherical(pointing_normals_ab)
        else:
            pointing_theta, pointing_phi = pointed_theta_phi(
                lmax, latitude, longitude, altitude, azimuth, boresight
            )

        rot_tirs_to_pointing = tirs_to_pointing(
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
        ).as_matrix()
        rot_pointing_to_tirs = rot_tirs_to_pointing.T

        pointing_theta_hat, pointing_phi_hat = spherical_tangent_vectors(
            pointing_theta, pointing_phi
        )

        cos_pt_phi = np.cos(pointing_phi)
        sin_pt_phi = np.sin(pointing_phi)
        pointing_co_hat = (
            cos_pt_phi[..., None] * pointing_theta_hat
            - sin_pt_phi[..., None] * pointing_phi_hat
        )
        pointing_cr_hat = (
            sin_pt_phi[..., None] * pointing_theta_hat
            + cos_pt_phi[..., None] * pointing_phi_hat
        )

        tirs_co_hat = (rot_pointing_to_tirs @ pointing_co_hat[..., None]).squeeze()
        tirs_cr_hat = (rot_pointing_to_tirs @ pointing_cr_hat[..., None]).squeeze()

        co_weights = np.stack(
            [
                np.vecdot(tirs_co_hat, tirs_theta_hat),
                np.vecdot(tirs_co_hat, tirs_phi_hat),
            ],
            axis=0,
        )
        co_norm = np.linalg.norm(co_weights, axis=0)
        co_inds = co_norm > np.finfo(co_norm.dtype).eps
        co_weights[0, co_inds] /= co_norm[co_inds]
        co_weights[1, co_inds] /= co_norm[co_inds]

        cr_weights = np.stack(
            [
                np.vecdot(tirs_cr_hat, tirs_theta_hat),
                np.vecdot(tirs_cr_hat, tirs_phi_hat),
            ],
            axis=0,
        )
        cr_norm = np.linalg.norm(cr_weights, axis=0)
        cr_inds = cr_norm > np.finfo(cr_norm.dtype).eps
        cr_weights[0, cr_inds] /= cr_norm[cr_inds]
        cr_weights[1, cr_inds] /= cr_norm[cr_inds]

        return pointing_theta, pointing_phi, co_weights, cr_weights

    @classmethod
    def clear_cache(cls) -> None:
        """Clear this class's cached pointing weights."""
        cls._compute_pointing_weights.cache_clear()

    @classmethod
    def from_ludwig3(
        cls,
        ludwig3_callable: Callable[
            [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]],
            npt.NDArray[np.complex128],
        ],
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        polarisation: Literal["X", "Y"],
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        copol_phi_offset: float = 0.0,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
    ) -> "TIRSVoltageBeam":
        r"""Construct a TIRSVoltageBeam from a Ludwig-3 beam callable.

        Coordinate transform and polariastion projection computations are cached
        (``maxsize=1``) so that repeated calls with the same pointing parameters
        — e.g. a loop over many beam callables — are more efficient.

        Parameters
        ----------
        ludwig3_callable : Callable
            A function ``(freq, theta, phi) -> beam`` that evaluates the
            co-polarisation and cross-polarisation components in the Ludwig-3
            basis at pointing-frame coordinates.  Input shapes:

            - ``freq``: broadcastable to ``(n_freq, ntheta, nphi)``, in MHz.
            - ``theta``: pointing-frame colatitude in radians on :math:`[0, \pi)`,
              shape ``(ntheta, nphi)``.
            - ``phi``: pointing-frame azimuth in radians on :math:`[0, 2\pi)`,
              shape ``(ntheta, nphi)``. At ``boresight=0`` and
              ``copol_phi_offset=0``, ``phi=0`` is aligned with the local zenith
              meridian pointing toward the zenith from the boresight direction.
              ``phi`` increases right-handed about the outward boresight axis (Z).

            Return shape: ``(2, n_freq, ntheta, nphi)`` — index 0 is co-pol,
            index 1 is cross-pol.
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        polarisation : {"X", "Y"}
            Feed polarisation.  For ``"X"`` an additional $\pi/2$ boresight rotation
            aligns the Ludwig-3 co-pol axis with the EW feed direction.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$ (zenith).
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$ (South).
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        copol_phi_offset : float
            Offset angle in radians between the beam model's co-pol axis and
            SERVAL's $\phi = 0$ reference (the zenith meridian).  Positive values
            rotate the $\phi = 0$ reference right-handed about the boresight.
            Default 0.0.
        apply_horizon : bool
            If ``True``, apply a horizon taper to the beam map before the
            spherical harmonic transform, avoiding an extra alm→map round-trip.
            Uses the same sin²-profile as
            [apply_horizon][serval.containers.TIRSVoltageBeam.apply_horizon].  Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments forwarded to the horizon taper: ``taper_width``
            (float, default 0.0) and ``apply_sqrt`` (bool, default ``True``).
            Ignored when ``apply_horizon=False``.
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.

        Returns
        -------
        TIRSVoltageBeam
            Voltage beam in the TIRS frame, ``alm`` shape
            ``(2, n_freq, lmax+1, 2*mmax+1)``.
        """
        effective_boresight = boresight + copol_phi_offset
        if polarisation == "X":
            effective_boresight += np.pi / 2
        pointing_theta, pointing_phi, co_weights, cr_weights = (
            cls._compute_pointing_weights(
                lmax,
                latitude,
                longitude,
                altitude,
                azimuth,
                effective_boresight,
                aberrate,
            )
        )
        cocr_beams = ludwig3_callable(
            frequencies_MHz[:, None, None], pointing_theta, pointing_phi
        )
        tirs_theta_phi_beam = (
            cocr_beams[0] * co_weights[:, None, ...]
            + cocr_beams[1] * cr_weights[:, None, ...]
        )
        if apply_horizon:
            _kw = horizon_taper_kwargs or {}
            tirs_theta_phi_beam = tirs_theta_phi_beam * _compute_horizon_mask(
                lmax, latitude, longitude,
                taper_width=_kw.get("taper_width", 0.0),
                apply_sqrt=_kw.get("apply_sqrt", True),
            )
        vbeam_alm: npt.NDArray[np.complex128] = np.asarray(
            batch_array_analysis(tirs_theta_phi_beam, lmax)
        )
        _mmax = mmax if mmax is not None else lmax
        if _mmax < lmax:
            vbeam_alm = set_bandlimits(vbeam_alm, lmax=lmax, mmax=_mmax)
        return cls(
            lmax=lmax,
            mmax=_mmax,
            frequencies_MHz=frequencies_MHz,
            alm=vbeam_alm,
            polarisation=polarisation,
        )

    @classmethod
    def from_thetaphi(
        cls,
        thetaphi_callable: Callable[
            [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]],
            npt.NDArray[np.complex128],
        ],
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        polarisation: Literal["X", "Y"],
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        copol_phi_offset: float = 0.0,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
    ) -> "TIRSVoltageBeam":
        r"""Construct a TIRSVoltageBeam from a theta/phi beam callable.

        A thin wrapper around
        [from_ludwig3][serval.containers.TIRSVoltageBeam.from_ludwig3] that
        accepts a callable in the spherical $(\hat{\theta}, \hat{\phi})$ basis
        rather than the Ludwig-3 $(E_\mathrm{co}, E_\mathrm{cr})$ basis.  The
        conversion used is

        $$
        E_\mathrm{co} = \cos\phi \, E_\theta - \sin\phi \, E_\phi, \qquad
        E_\mathrm{cr} = \sin\phi \, E_\theta + \cos\phi \, E_\phi.
        $$

        Here $\phi$ is the pointing-frame azimuth measured from the co-pol
        axis.  If the theta/phi callable uses a different azimuth origin,
        supply that offset via ``copol_phi_offset``.

        Parameters
        ----------
        thetaphi_callable : Callable
            A function ``(freq, theta, phi) -> beam`` that evaluates the
            $\hat{\theta}$ and $\hat{\phi}$ components of the voltage beam in
            pointing-frame coordinates.  Input shapes:

            - ``freq``: broadcastable to ``(n_freq, ntheta, nphi)``, in MHz.
            - ``theta``: pointing-frame colatitude in radians, shape
              ``(ntheta, nphi)``.
            - ``phi``: pointing-frame azimuth in radians, shape
              ``(ntheta, nphi)``.

            Return shape: ``(2, n_freq, ntheta, nphi)`` — index 0 is
            $E_\theta$, index 1 is $E_\phi$.
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        polarisation : {"X", "Y"}
            Feed polarisation.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$.
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$.
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        copol_phi_offset : float
            Azimuth, in the theta/phi callable's native pointing-frame
            coordinates, at which the Ludwig-3 co-pol axis lies.  This same
            offset is passed through to
            [from_ludwig3][serval.containers.TIRSVoltageBeam.from_ludwig3] so
            that the callable evaluation coordinates and the subsequent
            co/cr-to-TIRS projection use the same co-pol reference.  Default
            0.0.
        apply_horizon : bool
            Apply a horizon taper before the SHT.  Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments for the horizon taper.
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.

        Returns
        -------
        TIRSVoltageBeam
            Voltage beam in the TIRS frame, ``alm`` shape
            ``(2, n_freq, lmax+1, 2*mmax+1)``.
        """

        def _ludwig3_from_thetaphi(freq, theta, phi):
            phi_native = phi + copol_phi_offset
            components = thetaphi_callable(freq, theta, phi_native)
            E_th, E_ph = components[0], components[1]
            E_co = np.cos(phi) * E_th - np.sin(phi) * E_ph
            E_cr = np.sin(phi) * E_th + np.cos(phi) * E_ph
            return np.stack([E_co, E_cr], axis=0)

        return cls.from_ludwig3(
            _ludwig3_from_thetaphi,
            lmax,
            mmax,
            frequencies_MHz=frequencies_MHz,
            polarisation=polarisation,
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
            copol_phi_offset=copol_phi_offset,
            apply_horizon=apply_horizon,
            horizon_taper_kwargs=horizon_taper_kwargs,
            aberrate=aberrate,
        )

    @classmethod
    def from_gaussian(
        cls,
        D_eff: float,
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        polarisation: Literal["X", "Y"],
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        copol_phi_offset: float = 0.0,
        cross_pol_factor: float = 0.0,
        asymmetry_ratio: float = 1.0,
        asymmetry_angle: float = 0.0,
        use_sin_theta: bool = False,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
    ) -> "TIRSVoltageBeam":
        r"""Construct a TIRSVoltageBeam from a Gaussian beam model.

        The co-pol voltage amplitude is a Gaussian whose power-beam FWHM along
        the major axis is $\approx 1.029 \, \lambda / D_\mathrm{eff}$, chosen
        to match the Airy-disk FWHM for a uniformly-illuminated circular
        aperture.  An optional elliptical asymmetry stretches the beam along a
        specified axis.

        Parameters
        ----------
        D_eff : float
            Effective dish diameter in metres.  Sets the major-axis power-beam
            FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$
            (the Airy-disk FWHM for a uniformly-illuminated aperture).
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        polarisation : {"X", "Y"}
            Feed polarisation.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$.
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$.
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        copol_phi_offset : float
            Offset between beam co-pol axis and $\phi = 0$.  Default 0.0.
        cross_pol_factor : float
            Multiplicative factor relating cross-pol to co-pol.  Default 0.0
            (no cross-polarisation).
        asymmetry_ratio : float
            Ratio of semi-major to semi-minor FWHM ($\geq 1$).  The major axis
            FWHM equals the base FWHM from ``D_eff``; the minor axis is
            reduced by this factor.  Default 1.0 (circular).
        asymmetry_angle : float
            Orientation of the major axis in the pointing frame, in radians
            measured from $\phi = 0$.  Default 0.0.
        use_sin_theta : bool
            If ``True``, use $\sin\theta$ instead of $\theta$ in the Gaussian exponent.
            The FWHM is then exact in $\sin\theta$, matching the physically motivated
            ``from_airy``.  Default ``False``.
        apply_horizon : bool
            Apply a horizon taper before the SHT.  Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments for the horizon taper.
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.

        Returns
        -------
        TIRSVoltageBeam
        """

        def _gaussian_callable(freq, theta, phi):
            co_pol = gaussian_pattern(
                freq=freq,
                theta=theta,
                phi=phi,
                D_eff=D_eff,
                asymmetry_ratio=asymmetry_ratio,
                asymmetry_angle=asymmetry_angle,
                use_sin_theta=use_sin_theta,
                power=False,
            )
            cr_pol = cross_pol_factor * co_pol
            return np.stack([co_pol, cr_pol], axis=0)

        return cls.from_ludwig3(
            _gaussian_callable,
            lmax,
            mmax,
            frequencies_MHz=frequencies_MHz,
            polarisation=polarisation,
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
            copol_phi_offset=copol_phi_offset,
            apply_horizon=apply_horizon,
            horizon_taper_kwargs=horizon_taper_kwargs,
            aberrate=aberrate,
        )

    @classmethod
    def from_airy(
        cls,
        D_eff: float,
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        polarisation: Literal["X", "Y"],
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        copol_phi_offset: float = 0.0,
        cross_pol_factor: float = 0.0,
        asymmetry_ratio: float = 1.0,
        asymmetry_angle: float = 0.0,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
    ) -> "TIRSVoltageBeam":
        r"""Construct a TIRSVoltageBeam from an Airy disk beam model.

        The co-pol voltage amplitude is $2 J_1(x) / x$ where
        $x = \pi D_\mathrm{eff} \sin\theta / \lambda$, the standard diffraction
        pattern for a uniformly-illuminated circular aperture.  The power-beam
        FWHM is $\approx 1.029 \, \lambda / D_\mathrm{eff}$.  An optional
        elliptical asymmetry makes the effective aperture direction-dependent.

        Parameters
        ----------
        D_eff : float
            Effective dish diameter in metres.  Sets the major-axis power-beam
            FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$.
            Along the minor axis the effective diameter is
            $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz.
        polarisation : {"X", "Y"}
            Feed polarisation.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$.
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$.
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        copol_phi_offset : float
            Offset between beam co-pol axis and $\phi = 0$.  Default 0.0.
        cross_pol_factor : float
            Multiplicative factor relating cross-pol to co-pol.  Default 0.0.
        asymmetry_ratio : float
            Ratio of semi-major to semi-minor beam width ($\geq 1$).  Along the
            major axis the aperture is $D_\mathrm{eff}$; along the minor axis it
            is $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.  Default 1.0.
        asymmetry_angle : float
            Orientation of the major axis in the pointing frame, in radians
            measured from $\phi = 0$.  Default 0.0.
        apply_horizon : bool
            Apply a horizon taper before the SHT.  Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments for the horizon taper.
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.

        Returns
        -------
        TIRSVoltageBeam
        """
        def _airy_callable(freq, theta, phi):
            co_pol = airy_pattern(
                freq=freq,
                theta=theta,
                phi=phi,
                D_eff=D_eff,
                asymmetry_ratio=asymmetry_ratio,
                asymmetry_angle=asymmetry_angle,
                power=False,
            )
            cr_pol = cross_pol_factor * co_pol
            return np.stack([co_pol, cr_pol], axis=0)

        return cls.from_ludwig3(
            _airy_callable,
            lmax,
            mmax,
            frequencies_MHz=frequencies_MHz,
            polarisation=polarisation,
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
            copol_phi_offset=copol_phi_offset,
            apply_horizon=apply_horizon,
            horizon_taper_kwargs=horizon_taper_kwargs,
            aberrate=aberrate,
        )

    @classmethod
    def from_cst_farfields(
        cls,
        file_mapping: dict[float, str],
        lmax: int,
        mmax: int | None = None,
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        polarisation: Literal["X", "Y"],
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
        boresight: float = 0.0,
        copol_phi_offset: float = 0.0,
        apply_horizon: bool = False,
        horizon_taper_kwargs: dict | None = None,
        aberrate: bool = False,
        cst_kwargs: dict | None = None,
    ) -> "TIRSVoltageBeam":
        r"""Construct a TIRSVoltageBeam from CST farfield export files.

        Builds a [CSTBeamInterpolator][serval.containers.CSTBeamInterpolator] from the
        given files and passes it as the Ludwig-3 callable to
        [from_ludwig3][serval.containers.TIRSVoltageBeam.from_ludwig3].

        Parameters
        ----------
        file_mapping : dict[float, str]
            ``{freq_MHz: filepath}`` dict mapping each sampled frequency (in MHz)
            to the path of the corresponding CST farfield text file.
        lmax : int
            Maximum spherical harmonic degree.
        mmax : int, optional
            Maximum spherical harmonic order.  Defaults to ``lmax``.
        frequencies_MHz : npt.NDArray[np.float64]
            Frequency array in MHz at which to evaluate the beam.  Must lie within
            the range spanned by ``file_mapping`` keys.
        polarisation : {"X", "Y"}
            Feed polarisation.
        latitude : float
            Observer latitude in the TIRS frame, in radians.
        longitude : float
            Observer longitude in the TIRS frame, in radians.
        altitude : float
            Pointing altitude above the horizon in radians.  Default $\pi/2$.
        azimuth : float
            Pointing azimuth in radians (North through East).  Default $\pi$.
        boresight : float
            Rotation about the pointing axis in radians.  Default 0.
        copol_phi_offset : float
            Offset between beam co-pol axis and $\phi = 0$.  Default 0.0.
        apply_horizon : bool
            Apply a horizon taper before the SHT.  Default ``False``.
        horizon_taper_kwargs : dict, optional
            Keyword arguments for the horizon taper.
        aberrate : bool
            If ``True``, apply diurnal aberration to the TIRS grid coordinates
            before projecting into the pointing frame.  This evaluates the beam
            pattern at apparent source directions (matching Astropy's AltAz),
            consistent with ``aberrate_baseline=True`` in the integrator cache.
            Default ``False``.
        cst_kwargs : dict, optional
            Extra keyword arguments forwarded to
            [CSTBeamInterpolator][serval.containers.CSTBeamInterpolator]
            (e.g. ``linearize_from_dB``, ``freq_interp_kind``).

        Returns
        -------
        TIRSVoltageBeam
        """
        interpolator = CSTBeamInterpolator(
            file_mapping=file_mapping,
            **(cst_kwargs or {}),
        )
        return cls.from_ludwig3(
            interpolator,
            lmax,
            mmax,
            frequencies_MHz=frequencies_MHz,
            polarisation=polarisation,
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
            copol_phi_offset=copol_phi_offset,
            apply_horizon=apply_horizon,
            horizon_taper_kwargs=horizon_taper_kwargs,
            aberrate=aberrate,
        )

apply_horizon(latitude, longitude, taper_width=0.0, apply_sqrt=True)

Apply a horizon mask to the voltage beam in map space.

Multiplies each polarisation component and frequency channel's synthesised map by a mask that is 1 above the local horizon, tapers smoothly to 0 over taper_width radians approaching the horizon, and is exactly 0 below the horizon.

By default (apply_sqrt=True) the square root of the power-beam horizon taper is applied, so that constructing a TIRSPowerBeam from two voltage beams that have each had apply_horizon called with identical arguments gives the same result as calling apply_horizon on the resulting TIRSPowerBeam.

Only supported for beams with sht_basis="TIRS".

Parameters:

Name Type Description Default
latitude float

Observer latitude in radians.

required
longitude float

Observer longitude in radians.

required
taper_width float

Angular width of the sin²-taper region in radians. Default 0 gives a sharp horizon cutoff.

0.0
apply_sqrt bool

If True (default), apply the square root of the horizon mask (amplitude taper). If False, apply the full mask (power taper).

True

Returns:

Type Description
TIRSVoltageBeam

New beam with horizon mask applied; sht_basis unchanged.

Raises:

Type Description
ValueError

If sht_basis is not "TIRS".

Source code in src/serval/containers.py
def apply_horizon(
    self,
    latitude: float,
    longitude: float,
    taper_width: float = 0.0,
    apply_sqrt: bool = True,
) -> "TIRSVoltageBeam":
    """Apply a horizon mask to the voltage beam in map space.

    Multiplies each polarisation component and frequency channel's
    synthesised map by a mask that is 1 above the local horizon, tapers
    smoothly to 0 over ``taper_width`` radians approaching the horizon, and
    is exactly 0 below the horizon.

    By default (``apply_sqrt=True``) the square root of the power-beam
    horizon taper is applied, so that constructing a
    [TIRSPowerBeam][serval.containers.TIRSPowerBeam] from two voltage beams that have each had
    ``apply_horizon`` called with identical arguments gives the same result
    as calling ``apply_horizon`` on the resulting
    [TIRSPowerBeam][serval.containers.TIRSPowerBeam].

    Only supported for beams with ``sht_basis="TIRS"``.

    Parameters
    ----------
    latitude : float
        Observer latitude in radians.
    longitude : float
        Observer longitude in radians.
    taper_width : float
        Angular width of the sin²-taper region in radians.  Default 0
        gives a sharp horizon cutoff.
    apply_sqrt : bool
        If ``True`` (default), apply the square root of the horizon mask
        (amplitude taper).  If ``False``, apply the full mask (power taper).

    Returns
    -------
    TIRSVoltageBeam
        New beam with horizon mask applied; ``sht_basis`` unchanged.

    Raises
    ------
    ValueError
        If ``sht_basis`` is not ``"TIRS"``.
    """
    if self.sht_basis != "TIRS":
        raise ValueError(
            f"apply_horizon requires sht_basis='TIRS', got '{self.sht_basis}'."
        )

    mask = _compute_horizon_mask(
        self.lmax, latitude, longitude, taper_width=taper_width, apply_sqrt=apply_sqrt
    )

    # alm shape: (2, n_freq, lmax+1, 2*mmax+1)
    maps = np.array(
        [
            [array_synthesis(self.alm[p, f]).data for f in range(self.alm.shape[1])]
            for p in range(2)
        ]
    )  # (2, n_freq, nlat, nlon)
    tapered_maps = maps * mask  # broadcasts (nlat, nlon) over leading dims

    new_alm = batch_array_analysis(tapered_maps, self.lmax)
    new_alm = set_bandlimits(new_alm, lmax=self.lmax, mmax=self.mmax)
    return attrs.evolve(self, alm=new_alm)

clear_cache() classmethod

Clear this class's cached pointing weights.

Source code in src/serval/containers.py
@classmethod
def clear_cache(cls) -> None:
    """Clear this class's cached pointing weights."""
    cls._compute_pointing_weights.cache_clear()

from_airy(D_eff, lmax, mmax=None, *, frequencies_MHz, polarisation, latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, copol_phi_offset=0.0, cross_pol_factor=0.0, asymmetry_ratio=1.0, asymmetry_angle=0.0, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False) classmethod

Construct a TIRSVoltageBeam from an Airy disk beam model.

The co-pol voltage amplitude is \(2 J_1(x) / x\) where \(x = \pi D_\mathrm{eff} \sin\theta / \lambda\), the standard diffraction pattern for a uniformly-illuminated circular aperture. The power-beam FWHM is \(\approx 1.029 \, \lambda / D_\mathrm{eff}\). An optional elliptical asymmetry makes the effective aperture direction-dependent.

Parameters:

Name Type Description Default
D_eff float

Effective dish diameter in metres. Sets the major-axis power-beam FWHM via \(\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}\). Along the minor axis the effective diameter is \(D_\mathrm{eff} / \mathrm{asymmetry\_ratio}\).

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
polarisation (X, Y)

Feed polarisation.

"X"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
copol_phi_offset float

Offset between beam co-pol axis and \(\phi = 0\). Default 0.0.

0.0
cross_pol_factor float

Multiplicative factor relating cross-pol to co-pol. Default 0.0.

0.0
asymmetry_ratio float

Ratio of semi-major to semi-minor beam width (\(\geq 1\)). Along the major axis the aperture is \(D_\mathrm{eff}\); along the minor axis it is \(D_\mathrm{eff} / \mathrm{asymmetry\_ratio}\). Default 1.0.

1.0
asymmetry_angle float

Orientation of the major axis in the pointing frame, in radians measured from \(\phi = 0\). Default 0.0.

0.0
apply_horizon bool

Apply a horizon taper before the SHT. Default False.

False
horizon_taper_kwargs dict

Keyword arguments for the horizon taper.

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False

Returns:

Type Description
TIRSVoltageBeam
Source code in src/serval/containers.py
@classmethod
def from_airy(
    cls,
    D_eff: float,
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    polarisation: Literal["X", "Y"],
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    copol_phi_offset: float = 0.0,
    cross_pol_factor: float = 0.0,
    asymmetry_ratio: float = 1.0,
    asymmetry_angle: float = 0.0,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
) -> "TIRSVoltageBeam":
    r"""Construct a TIRSVoltageBeam from an Airy disk beam model.

    The co-pol voltage amplitude is $2 J_1(x) / x$ where
    $x = \pi D_\mathrm{eff} \sin\theta / \lambda$, the standard diffraction
    pattern for a uniformly-illuminated circular aperture.  The power-beam
    FWHM is $\approx 1.029 \, \lambda / D_\mathrm{eff}$.  An optional
    elliptical asymmetry makes the effective aperture direction-dependent.

    Parameters
    ----------
    D_eff : float
        Effective dish diameter in metres.  Sets the major-axis power-beam
        FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$.
        Along the minor axis the effective diameter is
        $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    polarisation : {"X", "Y"}
        Feed polarisation.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$.
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$.
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    copol_phi_offset : float
        Offset between beam co-pol axis and $\phi = 0$.  Default 0.0.
    cross_pol_factor : float
        Multiplicative factor relating cross-pol to co-pol.  Default 0.0.
    asymmetry_ratio : float
        Ratio of semi-major to semi-minor beam width ($\geq 1$).  Along the
        major axis the aperture is $D_\mathrm{eff}$; along the minor axis it
        is $D_\mathrm{eff} / \mathrm{asymmetry\_ratio}$.  Default 1.0.
    asymmetry_angle : float
        Orientation of the major axis in the pointing frame, in radians
        measured from $\phi = 0$.  Default 0.0.
    apply_horizon : bool
        Apply a horizon taper before the SHT.  Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments for the horizon taper.
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.

    Returns
    -------
    TIRSVoltageBeam
    """
    def _airy_callable(freq, theta, phi):
        co_pol = airy_pattern(
            freq=freq,
            theta=theta,
            phi=phi,
            D_eff=D_eff,
            asymmetry_ratio=asymmetry_ratio,
            asymmetry_angle=asymmetry_angle,
            power=False,
        )
        cr_pol = cross_pol_factor * co_pol
        return np.stack([co_pol, cr_pol], axis=0)

    return cls.from_ludwig3(
        _airy_callable,
        lmax,
        mmax,
        frequencies_MHz=frequencies_MHz,
        polarisation=polarisation,
        latitude=latitude,
        longitude=longitude,
        altitude=altitude,
        azimuth=azimuth,
        boresight=boresight,
        copol_phi_offset=copol_phi_offset,
        apply_horizon=apply_horizon,
        horizon_taper_kwargs=horizon_taper_kwargs,
        aberrate=aberrate,
    )

from_cst_farfields(file_mapping, lmax, mmax=None, *, frequencies_MHz, polarisation, latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, copol_phi_offset=0.0, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False, cst_kwargs=None) classmethod

Construct a TIRSVoltageBeam from CST farfield export files.

Builds a CSTBeamInterpolator from the given files and passes it as the Ludwig-3 callable to from_ludwig3.

Parameters:

Name Type Description Default
file_mapping dict[float, str]

{freq_MHz: filepath} dict mapping each sampled frequency (in MHz) to the path of the corresponding CST farfield text file.

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz at which to evaluate the beam. Must lie within the range spanned by file_mapping keys.

required
polarisation (X, Y)

Feed polarisation.

"X"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
copol_phi_offset float

Offset between beam co-pol axis and \(\phi = 0\). Default 0.0.

0.0
apply_horizon bool

Apply a horizon taper before the SHT. Default False.

False
horizon_taper_kwargs dict

Keyword arguments for the horizon taper.

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False
cst_kwargs dict

Extra keyword arguments forwarded to CSTBeamInterpolator (e.g. linearize_from_dB, freq_interp_kind).

None

Returns:

Type Description
TIRSVoltageBeam
Source code in src/serval/containers.py
@classmethod
def from_cst_farfields(
    cls,
    file_mapping: dict[float, str],
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    polarisation: Literal["X", "Y"],
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    copol_phi_offset: float = 0.0,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
    cst_kwargs: dict | None = None,
) -> "TIRSVoltageBeam":
    r"""Construct a TIRSVoltageBeam from CST farfield export files.

    Builds a [CSTBeamInterpolator][serval.containers.CSTBeamInterpolator] from the
    given files and passes it as the Ludwig-3 callable to
    [from_ludwig3][serval.containers.TIRSVoltageBeam.from_ludwig3].

    Parameters
    ----------
    file_mapping : dict[float, str]
        ``{freq_MHz: filepath}`` dict mapping each sampled frequency (in MHz)
        to the path of the corresponding CST farfield text file.
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz at which to evaluate the beam.  Must lie within
        the range spanned by ``file_mapping`` keys.
    polarisation : {"X", "Y"}
        Feed polarisation.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$.
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$.
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    copol_phi_offset : float
        Offset between beam co-pol axis and $\phi = 0$.  Default 0.0.
    apply_horizon : bool
        Apply a horizon taper before the SHT.  Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments for the horizon taper.
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.
    cst_kwargs : dict, optional
        Extra keyword arguments forwarded to
        [CSTBeamInterpolator][serval.containers.CSTBeamInterpolator]
        (e.g. ``linearize_from_dB``, ``freq_interp_kind``).

    Returns
    -------
    TIRSVoltageBeam
    """
    interpolator = CSTBeamInterpolator(
        file_mapping=file_mapping,
        **(cst_kwargs or {}),
    )
    return cls.from_ludwig3(
        interpolator,
        lmax,
        mmax,
        frequencies_MHz=frequencies_MHz,
        polarisation=polarisation,
        latitude=latitude,
        longitude=longitude,
        altitude=altitude,
        azimuth=azimuth,
        boresight=boresight,
        copol_phi_offset=copol_phi_offset,
        apply_horizon=apply_horizon,
        horizon_taper_kwargs=horizon_taper_kwargs,
        aberrate=aberrate,
    )

from_gaussian(D_eff, lmax, mmax=None, *, frequencies_MHz, polarisation, latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, copol_phi_offset=0.0, cross_pol_factor=0.0, asymmetry_ratio=1.0, asymmetry_angle=0.0, use_sin_theta=False, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False) classmethod

Construct a TIRSVoltageBeam from a Gaussian beam model.

The co-pol voltage amplitude is a Gaussian whose power-beam FWHM along the major axis is \(\approx 1.029 \, \lambda / D_\mathrm{eff}\), chosen to match the Airy-disk FWHM for a uniformly-illuminated circular aperture. An optional elliptical asymmetry stretches the beam along a specified axis.

Parameters:

Name Type Description Default
D_eff float

Effective dish diameter in metres. Sets the major-axis power-beam FWHM via \(\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}\) (the Airy-disk FWHM for a uniformly-illuminated aperture).

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
polarisation (X, Y)

Feed polarisation.

"X"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
copol_phi_offset float

Offset between beam co-pol axis and \(\phi = 0\). Default 0.0.

0.0
cross_pol_factor float

Multiplicative factor relating cross-pol to co-pol. Default 0.0 (no cross-polarisation).

0.0
asymmetry_ratio float

Ratio of semi-major to semi-minor FWHM (\(\geq 1\)). The major axis FWHM equals the base FWHM from D_eff; the minor axis is reduced by this factor. Default 1.0 (circular).

1.0
asymmetry_angle float

Orientation of the major axis in the pointing frame, in radians measured from \(\phi = 0\). Default 0.0.

0.0
use_sin_theta bool

If True, use \(\sin\theta\) instead of \(\theta\) in the Gaussian exponent. The FWHM is then exact in \(\sin\theta\), matching the physically motivated from_airy. Default False.

False
apply_horizon bool

Apply a horizon taper before the SHT. Default False.

False
horizon_taper_kwargs dict

Keyword arguments for the horizon taper.

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False

Returns:

Type Description
TIRSVoltageBeam
Source code in src/serval/containers.py
@classmethod
def from_gaussian(
    cls,
    D_eff: float,
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    polarisation: Literal["X", "Y"],
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    copol_phi_offset: float = 0.0,
    cross_pol_factor: float = 0.0,
    asymmetry_ratio: float = 1.0,
    asymmetry_angle: float = 0.0,
    use_sin_theta: bool = False,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
) -> "TIRSVoltageBeam":
    r"""Construct a TIRSVoltageBeam from a Gaussian beam model.

    The co-pol voltage amplitude is a Gaussian whose power-beam FWHM along
    the major axis is $\approx 1.029 \, \lambda / D_\mathrm{eff}$, chosen
    to match the Airy-disk FWHM for a uniformly-illuminated circular
    aperture.  An optional elliptical asymmetry stretches the beam along a
    specified axis.

    Parameters
    ----------
    D_eff : float
        Effective dish diameter in metres.  Sets the major-axis power-beam
        FWHM via $\mathrm{FWHM} \approx 1.029 \, \lambda / D_\mathrm{eff}$
        (the Airy-disk FWHM for a uniformly-illuminated aperture).
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    polarisation : {"X", "Y"}
        Feed polarisation.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$.
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$.
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    copol_phi_offset : float
        Offset between beam co-pol axis and $\phi = 0$.  Default 0.0.
    cross_pol_factor : float
        Multiplicative factor relating cross-pol to co-pol.  Default 0.0
        (no cross-polarisation).
    asymmetry_ratio : float
        Ratio of semi-major to semi-minor FWHM ($\geq 1$).  The major axis
        FWHM equals the base FWHM from ``D_eff``; the minor axis is
        reduced by this factor.  Default 1.0 (circular).
    asymmetry_angle : float
        Orientation of the major axis in the pointing frame, in radians
        measured from $\phi = 0$.  Default 0.0.
    use_sin_theta : bool
        If ``True``, use $\sin\theta$ instead of $\theta$ in the Gaussian exponent.
        The FWHM is then exact in $\sin\theta$, matching the physically motivated
        ``from_airy``.  Default ``False``.
    apply_horizon : bool
        Apply a horizon taper before the SHT.  Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments for the horizon taper.
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.

    Returns
    -------
    TIRSVoltageBeam
    """

    def _gaussian_callable(freq, theta, phi):
        co_pol = gaussian_pattern(
            freq=freq,
            theta=theta,
            phi=phi,
            D_eff=D_eff,
            asymmetry_ratio=asymmetry_ratio,
            asymmetry_angle=asymmetry_angle,
            use_sin_theta=use_sin_theta,
            power=False,
        )
        cr_pol = cross_pol_factor * co_pol
        return np.stack([co_pol, cr_pol], axis=0)

    return cls.from_ludwig3(
        _gaussian_callable,
        lmax,
        mmax,
        frequencies_MHz=frequencies_MHz,
        polarisation=polarisation,
        latitude=latitude,
        longitude=longitude,
        altitude=altitude,
        azimuth=azimuth,
        boresight=boresight,
        copol_phi_offset=copol_phi_offset,
        apply_horizon=apply_horizon,
        horizon_taper_kwargs=horizon_taper_kwargs,
        aberrate=aberrate,
    )

from_ludwig3(ludwig3_callable, lmax, mmax=None, *, frequencies_MHz, polarisation, latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, copol_phi_offset=0.0, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False) classmethod

Construct a TIRSVoltageBeam from a Ludwig-3 beam callable.

Coordinate transform and polariastion projection computations are cached (maxsize=1) so that repeated calls with the same pointing parameters — e.g. a loop over many beam callables — are more efficient.

Parameters:

Name Type Description Default
ludwig3_callable Callable

A function (freq, theta, phi) -> beam that evaluates the co-polarisation and cross-polarisation components in the Ludwig-3 basis at pointing-frame coordinates. Input shapes:

  • freq: broadcastable to (n_freq, ntheta, nphi), in MHz.
  • theta: pointing-frame colatitude in radians on :math:[0, \pi), shape (ntheta, nphi).
  • phi: pointing-frame azimuth in radians on :math:[0, 2\pi), shape (ntheta, nphi). At boresight=0 and copol_phi_offset=0, phi=0 is aligned with the local zenith meridian pointing toward the zenith from the boresight direction. phi increases right-handed about the outward boresight axis (Z).

Return shape: (2, n_freq, ntheta, nphi) — index 0 is co-pol, index 1 is cross-pol.

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
polarisation (X, Y)

Feed polarisation. For "X" an additional \(\pi/2\) boresight rotation aligns the Ludwig-3 co-pol axis with the EW feed direction.

"X"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\) (zenith).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\) (South).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
copol_phi_offset float

Offset angle in radians between the beam model's co-pol axis and SERVAL's \(\phi = 0\) reference (the zenith meridian). Positive values rotate the \(\phi = 0\) reference right-handed about the boresight. Default 0.0.

0.0
apply_horizon bool

If True, apply a horizon taper to the beam map before the spherical harmonic transform, avoiding an extra alm→map round-trip. Uses the same sin²-profile as apply_horizon. Default False.

False
horizon_taper_kwargs dict

Keyword arguments forwarded to the horizon taper: taper_width (float, default 0.0) and apply_sqrt (bool, default True). Ignored when apply_horizon=False.

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False

Returns:

Type Description
TIRSVoltageBeam

Voltage beam in the TIRS frame, alm shape (2, n_freq, lmax+1, 2*mmax+1).

Source code in src/serval/containers.py
@classmethod
def from_ludwig3(
    cls,
    ludwig3_callable: Callable[
        [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]],
        npt.NDArray[np.complex128],
    ],
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    polarisation: Literal["X", "Y"],
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    copol_phi_offset: float = 0.0,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
) -> "TIRSVoltageBeam":
    r"""Construct a TIRSVoltageBeam from a Ludwig-3 beam callable.

    Coordinate transform and polariastion projection computations are cached
    (``maxsize=1``) so that repeated calls with the same pointing parameters
    — e.g. a loop over many beam callables — are more efficient.

    Parameters
    ----------
    ludwig3_callable : Callable
        A function ``(freq, theta, phi) -> beam`` that evaluates the
        co-polarisation and cross-polarisation components in the Ludwig-3
        basis at pointing-frame coordinates.  Input shapes:

        - ``freq``: broadcastable to ``(n_freq, ntheta, nphi)``, in MHz.
        - ``theta``: pointing-frame colatitude in radians on :math:`[0, \pi)`,
          shape ``(ntheta, nphi)``.
        - ``phi``: pointing-frame azimuth in radians on :math:`[0, 2\pi)`,
          shape ``(ntheta, nphi)``. At ``boresight=0`` and
          ``copol_phi_offset=0``, ``phi=0`` is aligned with the local zenith
          meridian pointing toward the zenith from the boresight direction.
          ``phi`` increases right-handed about the outward boresight axis (Z).

        Return shape: ``(2, n_freq, ntheta, nphi)`` — index 0 is co-pol,
        index 1 is cross-pol.
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    polarisation : {"X", "Y"}
        Feed polarisation.  For ``"X"`` an additional $\pi/2$ boresight rotation
        aligns the Ludwig-3 co-pol axis with the EW feed direction.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$ (zenith).
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$ (South).
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    copol_phi_offset : float
        Offset angle in radians between the beam model's co-pol axis and
        SERVAL's $\phi = 0$ reference (the zenith meridian).  Positive values
        rotate the $\phi = 0$ reference right-handed about the boresight.
        Default 0.0.
    apply_horizon : bool
        If ``True``, apply a horizon taper to the beam map before the
        spherical harmonic transform, avoiding an extra alm→map round-trip.
        Uses the same sin²-profile as
        [apply_horizon][serval.containers.TIRSVoltageBeam.apply_horizon].  Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments forwarded to the horizon taper: ``taper_width``
        (float, default 0.0) and ``apply_sqrt`` (bool, default ``True``).
        Ignored when ``apply_horizon=False``.
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.

    Returns
    -------
    TIRSVoltageBeam
        Voltage beam in the TIRS frame, ``alm`` shape
        ``(2, n_freq, lmax+1, 2*mmax+1)``.
    """
    effective_boresight = boresight + copol_phi_offset
    if polarisation == "X":
        effective_boresight += np.pi / 2
    pointing_theta, pointing_phi, co_weights, cr_weights = (
        cls._compute_pointing_weights(
            lmax,
            latitude,
            longitude,
            altitude,
            azimuth,
            effective_boresight,
            aberrate,
        )
    )
    cocr_beams = ludwig3_callable(
        frequencies_MHz[:, None, None], pointing_theta, pointing_phi
    )
    tirs_theta_phi_beam = (
        cocr_beams[0] * co_weights[:, None, ...]
        + cocr_beams[1] * cr_weights[:, None, ...]
    )
    if apply_horizon:
        _kw = horizon_taper_kwargs or {}
        tirs_theta_phi_beam = tirs_theta_phi_beam * _compute_horizon_mask(
            lmax, latitude, longitude,
            taper_width=_kw.get("taper_width", 0.0),
            apply_sqrt=_kw.get("apply_sqrt", True),
        )
    vbeam_alm: npt.NDArray[np.complex128] = np.asarray(
        batch_array_analysis(tirs_theta_phi_beam, lmax)
    )
    _mmax = mmax if mmax is not None else lmax
    if _mmax < lmax:
        vbeam_alm = set_bandlimits(vbeam_alm, lmax=lmax, mmax=_mmax)
    return cls(
        lmax=lmax,
        mmax=_mmax,
        frequencies_MHz=frequencies_MHz,
        alm=vbeam_alm,
        polarisation=polarisation,
    )

from_thetaphi(thetaphi_callable, lmax, mmax=None, *, frequencies_MHz, polarisation, latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, copol_phi_offset=0.0, apply_horizon=False, horizon_taper_kwargs=None, aberrate=False) classmethod

Construct a TIRSVoltageBeam from a theta/phi beam callable.

A thin wrapper around from_ludwig3 that accepts a callable in the spherical \((\hat{\theta}, \hat{\phi})\) basis rather than the Ludwig-3 \((E_\mathrm{co}, E_\mathrm{cr})\) basis. The conversion used is

\[ E_\mathrm{co} = \cos\phi \, E_\theta - \sin\phi \, E_\phi, \qquad E_\mathrm{cr} = \sin\phi \, E_\theta + \cos\phi \, E_\phi. \]

Here \(\phi\) is the pointing-frame azimuth measured from the co-pol axis. If the theta/phi callable uses a different azimuth origin, supply that offset via copol_phi_offset.

Parameters:

Name Type Description Default
thetaphi_callable Callable

A function (freq, theta, phi) -> beam that evaluates the \(\hat{\theta}\) and \(\hat{\phi}\) components of the voltage beam in pointing-frame coordinates. Input shapes:

  • freq: broadcastable to (n_freq, ntheta, nphi), in MHz.
  • theta: pointing-frame colatitude in radians, shape (ntheta, nphi).
  • phi: pointing-frame azimuth in radians, shape (ntheta, nphi).

Return shape: (2, n_freq, ntheta, nphi) — index 0 is \(E_\theta\), index 1 is \(E_\phi\).

required
lmax int

Maximum spherical harmonic degree.

required
mmax int

Maximum spherical harmonic order. Defaults to lmax.

None
frequencies_MHz NDArray[float64]

Frequency array in MHz.

required
polarisation (X, Y)

Feed polarisation.

"X"
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default \(\pi/2\).

pi / 2
azimuth float

Pointing azimuth in radians (North through East). Default \(\pi\).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
copol_phi_offset float

Azimuth, in the theta/phi callable's native pointing-frame coordinates, at which the Ludwig-3 co-pol axis lies. This same offset is passed through to from_ludwig3 so that the callable evaluation coordinates and the subsequent co/cr-to-TIRS projection use the same co-pol reference. Default 0.0.

0.0
apply_horizon bool

Apply a horizon taper before the SHT. Default False.

False
horizon_taper_kwargs dict

Keyword arguments for the horizon taper.

None
aberrate bool

If True, apply diurnal aberration to the TIRS grid coordinates before projecting into the pointing frame. This evaluates the beam pattern at apparent source directions (matching Astropy's AltAz), consistent with aberrate_baseline=True in the integrator cache. Default False.

False

Returns:

Type Description
TIRSVoltageBeam

Voltage beam in the TIRS frame, alm shape (2, n_freq, lmax+1, 2*mmax+1).

Source code in src/serval/containers.py
@classmethod
def from_thetaphi(
    cls,
    thetaphi_callable: Callable[
        [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]],
        npt.NDArray[np.complex128],
    ],
    lmax: int,
    mmax: int | None = None,
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    polarisation: Literal["X", "Y"],
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    copol_phi_offset: float = 0.0,
    apply_horizon: bool = False,
    horizon_taper_kwargs: dict | None = None,
    aberrate: bool = False,
) -> "TIRSVoltageBeam":
    r"""Construct a TIRSVoltageBeam from a theta/phi beam callable.

    A thin wrapper around
    [from_ludwig3][serval.containers.TIRSVoltageBeam.from_ludwig3] that
    accepts a callable in the spherical $(\hat{\theta}, \hat{\phi})$ basis
    rather than the Ludwig-3 $(E_\mathrm{co}, E_\mathrm{cr})$ basis.  The
    conversion used is

    $$
    E_\mathrm{co} = \cos\phi \, E_\theta - \sin\phi \, E_\phi, \qquad
    E_\mathrm{cr} = \sin\phi \, E_\theta + \cos\phi \, E_\phi.
    $$

    Here $\phi$ is the pointing-frame azimuth measured from the co-pol
    axis.  If the theta/phi callable uses a different azimuth origin,
    supply that offset via ``copol_phi_offset``.

    Parameters
    ----------
    thetaphi_callable : Callable
        A function ``(freq, theta, phi) -> beam`` that evaluates the
        $\hat{\theta}$ and $\hat{\phi}$ components of the voltage beam in
        pointing-frame coordinates.  Input shapes:

        - ``freq``: broadcastable to ``(n_freq, ntheta, nphi)``, in MHz.
        - ``theta``: pointing-frame colatitude in radians, shape
          ``(ntheta, nphi)``.
        - ``phi``: pointing-frame azimuth in radians, shape
          ``(ntheta, nphi)``.

        Return shape: ``(2, n_freq, ntheta, nphi)`` — index 0 is
        $E_\theta$, index 1 is $E_\phi$.
    lmax : int
        Maximum spherical harmonic degree.
    mmax : int, optional
        Maximum spherical harmonic order.  Defaults to ``lmax``.
    frequencies_MHz : npt.NDArray[np.float64]
        Frequency array in MHz.
    polarisation : {"X", "Y"}
        Feed polarisation.
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians.  Default $\pi/2$.
    azimuth : float
        Pointing azimuth in radians (North through East).  Default $\pi$.
    boresight : float
        Rotation about the pointing axis in radians.  Default 0.
    copol_phi_offset : float
        Azimuth, in the theta/phi callable's native pointing-frame
        coordinates, at which the Ludwig-3 co-pol axis lies.  This same
        offset is passed through to
        [from_ludwig3][serval.containers.TIRSVoltageBeam.from_ludwig3] so
        that the callable evaluation coordinates and the subsequent
        co/cr-to-TIRS projection use the same co-pol reference.  Default
        0.0.
    apply_horizon : bool
        Apply a horizon taper before the SHT.  Default ``False``.
    horizon_taper_kwargs : dict, optional
        Keyword arguments for the horizon taper.
    aberrate : bool
        If ``True``, apply diurnal aberration to the TIRS grid coordinates
        before projecting into the pointing frame.  This evaluates the beam
        pattern at apparent source directions (matching Astropy's AltAz),
        consistent with ``aberrate_baseline=True`` in the integrator cache.
        Default ``False``.

    Returns
    -------
    TIRSVoltageBeam
        Voltage beam in the TIRS frame, ``alm`` shape
        ``(2, n_freq, lmax+1, 2*mmax+1)``.
    """

    def _ludwig3_from_thetaphi(freq, theta, phi):
        phi_native = phi + copol_phi_offset
        components = thetaphi_callable(freq, theta, phi_native)
        E_th, E_ph = components[0], components[1]
        E_co = np.cos(phi) * E_th - np.sin(phi) * E_ph
        E_cr = np.sin(phi) * E_th + np.cos(phi) * E_ph
        return np.stack([E_co, E_cr], axis=0)

    return cls.from_ludwig3(
        _ludwig3_from_thetaphi,
        lmax,
        mmax,
        frequencies_MHz=frequencies_MHz,
        polarisation=polarisation,
        latitude=latitude,
        longitude=longitude,
        altitude=altitude,
        azimuth=azimuth,
        boresight=boresight,
        copol_phi_offset=copol_phi_offset,
        apply_horizon=apply_horizon,
        horizon_taper_kwargs=horizon_taper_kwargs,
        aberrate=aberrate,
    )

normalise(normalisation, power_integrals=None)

Return a new TIRSVoltageBeam with normalised alm coefficients.

Parameters:

Name Type Description Default
normalisation (power_integral, custom)

Normalisation mode:

  • "power_integral" — each frequency channel divided by the square root of its power pattern integral so that power_integrals() == 1.
  • "custom" — divide so that power_integrals() equals the user-supplied target power_integrals array.
"power_integral"
power_integrals ndarray of shape (n_freq,)

Required when normalisation="custom". Target per-frequency power pattern integrals for the returned beam.

None

Returns:

Type Description
TIRSVoltageBeam

A new container with scaled alm and updated normalisation.

Source code in src/serval/containers.py
def normalise(
    self,
    normalisation: Literal["power_integral", "custom"],
    power_integrals: npt.NDArray[np.float64] | None = None,
) -> "TIRSVoltageBeam":
    """Return a new TIRSVoltageBeam with normalised alm coefficients.

    Parameters
    ----------
    normalisation : {"power_integral", "custom"}
        Normalisation mode:

        - ``"power_integral"`` — each frequency channel divided by the square root of
          its power pattern integral so that ``power_integrals() == 1``.
        - ``"custom"`` — divide so that ``power_integrals()`` equals the
          user-supplied target ``power_integrals`` array.

    power_integrals : ndarray of shape (n_freq,), optional
        Required when ``normalisation="custom"``. Target per-frequency
        power pattern integrals for the returned beam.

    Returns
    -------
    TIRSVoltageBeam
        A new container with scaled ``alm`` and updated ``normalisation``.
    """
    if normalisation == "power_integral":
        factors = np.sqrt(self.power_integrals())
    elif normalisation == "custom":
        if power_integrals is None:
            raise ValueError(
                "'power_integrals' must be provided for normalisation='custom'."
            )
        factors = np.sqrt(self.power_integrals() / np.asarray(power_integrals))
    else:
        raise ValueError(f"Unknown normalisation mode '{normalisation}'.")

    if np.any(factors == 0):
        raise ValueError("Cannot normalise: one or more factors are zero.")

    new_alm = self.alm / factors[np.newaxis, :, np.newaxis, np.newaxis]
    return attrs.evolve(self, alm=new_alm, normalisation=normalisation)

power_integrals()

Per-frequency integral of the power pattern |E_theta|^2 + |E_phi|^2 over the unit sphere.

Uses Parseval's theorem: for orthonormal spherical harmonics the integral equals the sum of squared alm magnitudes over both polarisation components.

Returns:

Type Description
NDArray[float64]

Array of shape (n_freq,) with the real-valued power integrals.

Source code in src/serval/containers.py
def power_integrals(self) -> npt.NDArray[np.float64]:
    """Per-frequency integral of the power pattern |E_theta|^2 + |E_phi|^2 over the unit sphere.

    Uses Parseval's theorem: for orthonormal spherical harmonics the integral equals
    the sum of squared alm magnitudes over both polarisation components.

    Returns
    -------
    npt.NDArray[np.float64]
        Array of shape ``(n_freq,)`` with the real-valued power integrals.
    """
    return np.sum(np.abs(self.alm) ** 2, axis=(0, 2, 3))

to_pointed_basis(latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0, mmax=None)

Rotate the SHT decomposition basis from TIRS to a pointing-aligned frame.

Useful for compactly representing beams with large amounts of azimuthal symmetry in the pointing frame (e.g. a zenith-pointing dish).

.. important:: This method only changes the SHT decomposition basis of the beam. Polarisation projections (the mapping from feed X/Y to Stokes parameters) remain defined in the TIRS frame and are unaffected by this rotation. Use mmax to truncate the azimuthal bandwidth of the output when the beam is compact in m in the new basis.

Parameters:

Name Type Description Default
latitude float

Observer latitude in the TIRS frame, in radians.

required
longitude float

Observer longitude in the TIRS frame, in radians.

required
altitude float

Pointing altitude above the horizon in radians. Default π/2 (zenith).

pi / 2
azimuth float

Pointing azimuth in radians (measured from North through East). Default π (South).

pi
boresight float

Rotation about the pointing axis in radians. Default 0.

0.0
mmax int

If provided, truncate the output to this azimuthal bandlimit after rotation. Must be ≤ lmax. Useful when rotating to a basis where the beam is more compact in m-modes.

None

Returns:

Type Description
TIRSVoltageBeam

A new TIRSVoltageBeam with sht_basis="Pointing".

Source code in src/serval/containers.py
def to_pointed_basis(
    self,
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
    mmax: int | None = None,
) -> "TIRSVoltageBeam":
    """Rotate the SHT decomposition basis from TIRS to a pointing-aligned frame.

    Useful for compactly representing beams with large amounts of azimuthal
    symmetry in the pointing frame (e.g. a zenith-pointing dish).

    .. important::
        This method **only** changes the SHT decomposition basis of the beam.
        Polarisation projections (the mapping from feed X/Y to Stokes
        parameters) remain defined in the TIRS frame and are unaffected by
        this rotation.  Use ``mmax`` to truncate the azimuthal bandwidth of
        the output when the beam is compact in m in the new basis.

    Parameters
    ----------
    latitude : float
        Observer latitude in the TIRS frame, in radians.
    longitude : float
        Observer longitude in the TIRS frame, in radians.
    altitude : float
        Pointing altitude above the horizon in radians. Default π/2 (zenith).
    azimuth : float
        Pointing azimuth in radians (measured from North through East).
        Default π (South).
    boresight : float
        Rotation about the pointing axis in radians. Default 0.
    mmax : int, optional
        If provided, truncate the output to this azimuthal bandlimit after
        rotation.  Must be ≤ ``lmax``.  Useful when rotating to a basis where
        the beam is more compact in m-modes.

    Returns
    -------
    TIRSVoltageBeam
        A new TIRSVoltageBeam with ``sht_basis="Pointing"``.
    """
    return _to_pointed_basis_impl(self, latitude, longitude, altitude, azimuth, boresight, mmax)

Visibilities

Container for drift-scan visibility and m-mode data.

Stores per-(baseline, beam) visibility output from the SERVAL pipeline.

Parameters:

Name Type Description Default
frequencies_MHz ndarray[tuple[Any, ...], dtype[float64]]

Frequency axis.

required
era_deg ndarray[tuple[Any, ...], dtype[float64]]

Absolute Greenwich Earth Rotation Angle in degrees (longitude=0), as defined by erfa.era00. Spans [0, 360) for a full sidereal rotation, but may hold arbitrary values after resampling to UT1 or RA.

required
vis ndarray[tuple[Any, ...], dtype[complex128]]

Complex visibility timestream.

required
mmodes ndarray[tuple[Any, ...], dtype[complex128]]

M-mode decomposition of the visibilities. Columns outside the set of active m-values (see m_values) are zero.

required
m_values ndarray[tuple[Any, ...], dtype[int64]]

The sky m-values that contribute non-zero entries to mmodes.

required
metadata dict

Arbitrary provenance information (beam attrs, integrator cache attrs, etc.).

<class 'dict'>
sky_pol Literal['I', 'Q', 'U', 'V']

Sky Stokes parameter this visibility stream projects onto.

"I"
pol_product Literal['XX', 'YY', 'XY', 'YX']

Correlator polarisation product of the two beams used to form this visibility.

"XX"
ra_deg ndarray[tuple[Any, ...], dtype[float64]] | None
None
ut1_time ndarray[tuple[Any, ...], dtype[float64]] | None
None
Source code in src/serval/containers.py
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
@define(eq=False)
class Visibilities:
    """Container for drift-scan visibility and m-mode data.

    Stores per-(baseline, beam) visibility output from the SERVAL pipeline.

    Parameters
    ----------
    frequencies_MHz : ndarray, shape (n_freq,)
        Frequency axis.
    era_deg : ndarray, shape (n_era,)
        Absolute Greenwich Earth Rotation Angle in degrees (longitude=0),
        as defined by ``erfa.era00``.  Spans ``[0, 360)`` for a full sidereal
        rotation, but may hold arbitrary values after resampling to UT1 or RA.
    vis : ndarray or zarr.Array, shape (n_freq, n_era)
        Complex visibility timestream.
    mmodes : ndarray or zarr.Array, shape (n_freq, n_era)
        M-mode decomposition of the visibilities. Columns outside the set of
        active m-values (see ``m_values``) are zero.
    m_values : ndarray of int, shape (n_m,)
        The sky m-values that contribute non-zero entries to ``mmodes``.
    metadata : dict, optional
        Arbitrary provenance information (beam attrs, integrator cache attrs, etc.).
    sky_pol : {"I", "Q", "U", "V"}
        Sky Stokes parameter this visibility stream projects onto.
    pol_product : {"XX", "YY", "XY", "YX"}
        Correlator polarisation product of the two beams used to form this
        visibility.
    """

    frequencies_MHz: npt.NDArray[np.float64] = field()
    era_deg: npt.NDArray[np.float64] = field()
    vis: npt.NDArray[np.complex128] = field()
    mmodes: npt.NDArray[np.complex128] = field()
    m_values: npt.NDArray[np.int_] = field()
    metadata: dict = field(factory=dict, kw_only=True)
    sky_pol: Literal["I", "Q", "U", "V"] = field(
        kw_only=True,
        validator=attrs.validators.in_(["I", "Q", "U", "V"]),
    )
    pol_product: Literal["XX", "YY", "XY", "YX"] = field(
        kw_only=True,
        validator=attrs.validators.in_(["XX", "YY", "XY", "YX"]),
    )
    ra_deg: npt.NDArray[np.float64] | None = field(default=None, kw_only=True)
    ut1_time: npt.NDArray[np.float64] | None = field(default=None, kw_only=True)

    _to_attrs: ClassVar[list[str]] = ["sky_pol", "pol_product"]
    _to_zarr_store: ClassVar[list[str]] = [
        "frequencies_MHz",
        "era_deg",
        "vis",
        "mmodes",
        "m_values",
    ]

    @classmethod
    def setup_zarr_store(
        cls,
        store_location: str | Path,
        group_path: str = r"/",
        *,
        frequencies_MHz: npt.NDArray[np.float64],
        era_deg: npt.NDArray[np.float64],
        m_values: npt.NDArray[np.int_],
        n_freq: int,
        n_era: int,
        chunks: tuple[int, int],
        sky_pol: str,
        pol_product: str,
        metadata: dict | None = None,
    ) -> None:
        """Create the zarr group structure with pre-allocated empty vis/mmodes arrays.

        The ``vis`` and ``mmodes`` arrays are allocated but left unfilled; they
        are written incrementally by the visibility generation pipeline.

        Parameters
        ----------
        store_location : str or Path
        group_path : str
        frequencies_MHz : ndarray, shape (n_freq,)
        era_deg : ndarray, shape (n_era,)
        m_values : ndarray of int, shape (n_m,)
        n_freq : int
        n_era : int
        chunks : tuple[int, int]
            Chunk shape ``(freq_chunk, era_chunk)`` for ``vis`` and ``mmodes``.
        sky_pol : str
            Sky Stokes parameter (written to group attrs).
        pol_product : str
            Correlator polarisation product (written to group attrs).
        metadata : dict, optional
            Written to ``grp.attrs["metadata"]``.
        """
        store = zarr.storage.LocalStore(Path(store_location))
        grp = zarr.create_group(store=store, path=group_path, overwrite=True)
        grp.attrs["sky_pol"] = sky_pol
        grp.attrs["pol_product"] = pol_product
        grp.attrs["metadata"] = metadata or {}
        grp.create_array(name="frequencies_MHz", data=frequencies_MHz, overwrite=True)
        grp.create_array(name="era_deg", data=era_deg, overwrite=True)
        grp.create_array(name="m_values", data=m_values, overwrite=True)
        for name in ("vis", "mmodes"):
            grp.create_array(
                name=name,
                shape=(n_freq, n_era),
                chunks=chunks,
                dtype=np.complex128,
                overwrite=True,
                compressors=zarr.codecs.BloscCodec(**ZARR_COMPRESSORS),
            )

    @classmethod
    def from_zarr_store(
        cls,
        store_location: str | Path,
        group_path: str = r"/",
    ) -> "Visibilities":
        """Load a Visibilities container from a zarr store.

        ``vis`` and ``mmodes`` are kept as lazy ``zarr.Array`` objects;
        data is read only when accessed.
        """
        store = zarr.storage.LocalStore(Path(store_location), read_only=True)
        grp = zarr.open_group(store=store, path=group_path, mode="r")
        try:
            attrs_dict = grp.attrs.asdict()
            metadata = attrs_dict.get("metadata", {})
            sky_pol = attrs_dict["sky_pol"]
            pol_product = attrs_dict["pol_product"]
            frequencies_MHz = np.asarray(grp["frequencies_MHz"])
            era_deg = np.asarray(grp["era_deg"])
            m_values = np.asarray(grp["m_values"])
            vis = typing.cast(zarr.Array, grp["vis"])  # lazy
            mmodes = typing.cast(zarr.Array, grp["mmodes"])  # lazy
        except KeyError as e:
            raise ValueError(
                f"Could not load Visibilities from '{store_location}' "
                f"(group '{group_path}'): missing attribute or array {e}."
            ) from e
        ra_deg = np.asarray(grp["ra_deg"]) if "ra_deg" in grp else None
        ut1_time = np.asarray(grp["ut1_time"]) if "ut1_time" in grp else None
        return cls(
            frequencies_MHz=frequencies_MHz,
            era_deg=era_deg,
            vis=vis,  # type: ignore[arg-type]  # zarr.Array is lazy
            mmodes=mmodes,  # type: ignore[arg-type]  # zarr.Array is lazy
            m_values=m_values,
            metadata=metadata,
            sky_pol=sky_pol,
            pol_product=pol_product,
            ra_deg=ra_deg,
            ut1_time=ut1_time,
        )

    def to_zarr_store(
        self,
        store_location: str | Path,
        group_path: str = r"/",
        metadata: dict | None = None,
    ) -> None:
        """Persist the container to a zarr store on disk."""
        store = zarr.storage.LocalStore(Path(store_location))
        grp = zarr.create_group(store=store, path=group_path, overwrite=True)
        for k in self._to_attrs:
            grp.attrs[k] = getattr(self, k)
        combined_metadata = dict(self.metadata)
        if metadata is not None:
            combined_metadata.update(metadata)
        grp.attrs["metadata"] = combined_metadata
        grp.create_array(
            name="frequencies_MHz",
            data=np.asarray(self.frequencies_MHz),
            overwrite=True,
        )
        grp.create_array(name="era_deg", data=np.asarray(self.era_deg), overwrite=True)
        grp.create_array(
            name="m_values", data=np.asarray(self.m_values), overwrite=True
        )
        for name in ("vis", "mmodes"):
            grp.create_array(
                name=name,
                data=np.asarray(getattr(self, name)),
                overwrite=True,
                compressors=zarr.codecs.BloscCodec(**ZARR_COMPRESSORS),
            )
        if self.ra_deg is not None:
            grp.create_array(
                name="ra_deg", data=np.asarray(self.ra_deg), overwrite=True
            )
        if self.ut1_time is not None:
            grp.create_array(
                name="ut1_time", data=np.asarray(self.ut1_time), overwrite=True
            )

    def resample(self, n_era: int) -> "Visibilities":
        """Resample to a new ERA grid by padding or trimming in m-mode space.

        Uses the stored m-mode decomposition to synthesise visibilities at a
        different number of ERA samples without returning to the sky.
        Upsampling zero-pads m-modes beyond the original Nyquist limit;
        downsampling discards m-modes above the new Nyquist limit
        ``(n_era - 1) // 2``.

        The output ERA count is always ``2 * ((n_era - 1) // 2) + 1``; for
        odd ``n_era`` this equals ``n_era`` exactly.  For even ``n_era`` the
        asymmetric Nyquist bin is dropped and the output has ``n_era - 1``
        samples.

        Parameters
        ----------
        n_era : int
            Desired number of output ERA samples.

        Returns
        -------
        Visibilities
            New container with resampled ``vis``, updated ``era_deg``, and
            freshly computed ``mmodes`` and ``m_values`` spanning the full
            new Nyquist range.
        """
        n_era_old = len(self.era_deg)
        mmodes_full = np.asarray(self.mmodes)  # (n_freq, n_era_old); triggers zarr load

        # Extract the amplitude for each active m-value.  The stored mmodes
        # array is in fftshifted order: m-mode m lives at column index mmax + m,
        # where mmax = (n_era_old - 1) // 2 (consistent with the CLI convention
        # from gaunt.cpp: global_m1_index = m + l1max).
        mmax_old = (n_era_old - 1) // 2
        sky_m_pos = self.m_values + mmax_old  # column indices in mmodes_full
        sparse = mmodes_full[:, sky_m_pos]  # (n_freq, n_m)

        # Determine new Nyquist limit and drop m-modes that alias on the new grid.
        mmax_new = (n_era - 1) // 2
        mask = np.abs(self.m_values) <= mmax_new
        active_m = self.m_values[mask]

        n_era_out = 2 * mmax_new + 1
        new_vis = mmodes_to_visibilities(sparse[:, mask], m1max=mmax_new, ms=active_m)

        new_era_deg = np.linspace(0, 360, n_era_out, endpoint=False, dtype=np.float64)

        # Recompute mmodes from the new vis so the returned container is
        # self-consistent: all m-values in [-mmax_new, mmax_new] are populated.
        new_mmodes = visibilities_to_mmodes(new_vis)
        new_m_values = np.arange(-mmax_new, mmax_new + 1, dtype=np.int_)

        return attrs.evolve(
            self,
            vis=new_vis,
            era_deg=new_era_deg,
            mmodes=new_mmodes,
            m_values=new_m_values,
        )

    def resample_to_ra(
        self,
        ra_deg: npt.NDArray[np.float64],
        *,
        latitude: float,
        longitude: float,
        altitude: float = np.pi / 2,
        azimuth: float = np.pi,
    ) -> "Visibilities":
        """Evaluate visibilities at CIRS Right Ascension positions.

        Computes the ERA at which each RA transits the beam centre, then
        evaluates the visibility timestream at those ERAs via NUDFT.  The
        ``mmodes`` are left unchanged — they remain the Fourier transform of
        the original ERA-indexed stream.

        The ERA-RA relationship has unit slope: as the Earth rotates by δ°, the
        CIRS RA of any TIRS-fixed direction increases by exactly δ°.  The
        offset is determined solely by the beam pointing geometry.

        Parameters
        ----------
        ra_deg : ndarray, shape (n_t,)
            Target CIRS Right Ascension values in degrees.
        latitude : float
            Site geodetic latitude in radians.
        longitude : float
            Site geodetic longitude in radians.
        altitude : float, optional
            Pointing altitude in radians, by default ``np.pi / 2`` (zenith).
        azimuth : float, optional
            Pointing azimuth in radians, by default ``np.pi`` (North).

        Returns
        -------
        Visibilities
            New container with ``vis`` evaluated at the requested RAs,
            ``era_deg`` set to the corresponding ERA values, ``ra_deg`` set to
            the input array, and ``mmodes``/``m_values`` unchanged.
        """
        # Beam centre is the z-axis of the pointing frame.  tirs_to_pointing
        # maps TIRS → pointing frame; its inverse maps pointing → TIRS.
        # At ERA=0, tirs_to_cirs(0) is the identity so CIRS = TIRS.
        v_tirs = (
            tirs_to_pointing(latitude, longitude, altitude, azimuth)
            .inv()
            .apply(np.array([0.0, 0.0, 1.0]))
        )
        ra_0_deg = np.degrees(np.arctan2(v_tirs[1], v_tirs[0])) % 360.0

        era_target = (np.asarray(ra_deg, dtype=np.float64) - ra_0_deg) % 360.0
        new_vis = visibilities_at_eras(self.mmodes, self.m_values, era_target)
        return attrs.evolve(
            self, vis=new_vis, era_deg=era_target, ra_deg=np.asarray(ra_deg)
        )

    def resample_to_ut1(
        self,
        ut1_jd: npt.NDArray[np.float64],
    ) -> "Visibilities":
        """Evaluate visibilities at UT1 times.

        Converts each UT1 Julian date to an absolute Greenwich ERA using
        ERFA's ``era00`` function, then evaluates the visibility timestream at
        those ERAs via NUDFT.  The ``mmodes`` are left unchanged.

        The container ERA axis is the absolute Greenwich ERA as defined by
        ERFA: the same ERA value that ``erfa.era00(dj1, dj2)`` returns for the
        corresponding UT1 Julian date.

        Parameters
        ----------
        ut1_jd : ndarray, shape (n_t,)
            UT1 Julian dates at which to evaluate.

        Returns
        -------
        Visibilities
            New container with ``vis`` evaluated at the requested UT1 times,
            ``era_deg`` set to the corresponding ERA values in degrees,
            ``ut1_time`` set to the input array, and ``mmodes``/``m_values``
            unchanged.
        """
        ut1_jd = np.asarray(ut1_jd, dtype=np.float64)
        dj1, dj2 = np.divmod(ut1_jd, 1.0)
        era_target = np.degrees(erfa.era00(dj1, dj2))
        new_vis = visibilities_at_eras(self.mmodes, self.m_values, era_target)
        return attrs.evolve(self, vis=new_vis, era_deg=era_target, ut1_time=ut1_jd)

    def resample_to_times(self, times: Time) -> "Visibilities":
        """Evaluate visibilities at arbitrary observation times.

        Converts the input times to the UT1 scale and delegates to
        [resample_to_ut1][serval.containers.Visibilities.resample_to_ut1].
        All time scales supported by
        ``astropy.time.Time`` are accepted.

        Parameters
        ----------
        times : astropy.time.Time
            Observation times at which to evaluate.

        Returns
        -------
        Visibilities
            New container with ``vis`` evaluated at the requested times,
            ``era_deg`` set to the corresponding ERA values in degrees,
            ``ut1_time`` set to the UT1 Julian dates, and
            ``mmodes``/``m_values`` unchanged.
        """
        return self.resample_to_ut1(times.ut1.jd)

from_zarr_store(store_location, group_path='/') classmethod

Load a Visibilities container from a zarr store.

vis and mmodes are kept as lazy zarr.Array objects; data is read only when accessed.

Source code in src/serval/containers.py
@classmethod
def from_zarr_store(
    cls,
    store_location: str | Path,
    group_path: str = r"/",
) -> "Visibilities":
    """Load a Visibilities container from a zarr store.

    ``vis`` and ``mmodes`` are kept as lazy ``zarr.Array`` objects;
    data is read only when accessed.
    """
    store = zarr.storage.LocalStore(Path(store_location), read_only=True)
    grp = zarr.open_group(store=store, path=group_path, mode="r")
    try:
        attrs_dict = grp.attrs.asdict()
        metadata = attrs_dict.get("metadata", {})
        sky_pol = attrs_dict["sky_pol"]
        pol_product = attrs_dict["pol_product"]
        frequencies_MHz = np.asarray(grp["frequencies_MHz"])
        era_deg = np.asarray(grp["era_deg"])
        m_values = np.asarray(grp["m_values"])
        vis = typing.cast(zarr.Array, grp["vis"])  # lazy
        mmodes = typing.cast(zarr.Array, grp["mmodes"])  # lazy
    except KeyError as e:
        raise ValueError(
            f"Could not load Visibilities from '{store_location}' "
            f"(group '{group_path}'): missing attribute or array {e}."
        ) from e
    ra_deg = np.asarray(grp["ra_deg"]) if "ra_deg" in grp else None
    ut1_time = np.asarray(grp["ut1_time"]) if "ut1_time" in grp else None
    return cls(
        frequencies_MHz=frequencies_MHz,
        era_deg=era_deg,
        vis=vis,  # type: ignore[arg-type]  # zarr.Array is lazy
        mmodes=mmodes,  # type: ignore[arg-type]  # zarr.Array is lazy
        m_values=m_values,
        metadata=metadata,
        sky_pol=sky_pol,
        pol_product=pol_product,
        ra_deg=ra_deg,
        ut1_time=ut1_time,
    )

resample(n_era)

Resample to a new ERA grid by padding or trimming in m-mode space.

Uses the stored m-mode decomposition to synthesise visibilities at a different number of ERA samples without returning to the sky. Upsampling zero-pads m-modes beyond the original Nyquist limit; downsampling discards m-modes above the new Nyquist limit (n_era - 1) // 2.

The output ERA count is always 2 * ((n_era - 1) // 2) + 1; for odd n_era this equals n_era exactly. For even n_era the asymmetric Nyquist bin is dropped and the output has n_era - 1 samples.

Parameters:

Name Type Description Default
n_era int

Desired number of output ERA samples.

required

Returns:

Type Description
Visibilities

New container with resampled vis, updated era_deg, and freshly computed mmodes and m_values spanning the full new Nyquist range.

Source code in src/serval/containers.py
def resample(self, n_era: int) -> "Visibilities":
    """Resample to a new ERA grid by padding or trimming in m-mode space.

    Uses the stored m-mode decomposition to synthesise visibilities at a
    different number of ERA samples without returning to the sky.
    Upsampling zero-pads m-modes beyond the original Nyquist limit;
    downsampling discards m-modes above the new Nyquist limit
    ``(n_era - 1) // 2``.

    The output ERA count is always ``2 * ((n_era - 1) // 2) + 1``; for
    odd ``n_era`` this equals ``n_era`` exactly.  For even ``n_era`` the
    asymmetric Nyquist bin is dropped and the output has ``n_era - 1``
    samples.

    Parameters
    ----------
    n_era : int
        Desired number of output ERA samples.

    Returns
    -------
    Visibilities
        New container with resampled ``vis``, updated ``era_deg``, and
        freshly computed ``mmodes`` and ``m_values`` spanning the full
        new Nyquist range.
    """
    n_era_old = len(self.era_deg)
    mmodes_full = np.asarray(self.mmodes)  # (n_freq, n_era_old); triggers zarr load

    # Extract the amplitude for each active m-value.  The stored mmodes
    # array is in fftshifted order: m-mode m lives at column index mmax + m,
    # where mmax = (n_era_old - 1) // 2 (consistent with the CLI convention
    # from gaunt.cpp: global_m1_index = m + l1max).
    mmax_old = (n_era_old - 1) // 2
    sky_m_pos = self.m_values + mmax_old  # column indices in mmodes_full
    sparse = mmodes_full[:, sky_m_pos]  # (n_freq, n_m)

    # Determine new Nyquist limit and drop m-modes that alias on the new grid.
    mmax_new = (n_era - 1) // 2
    mask = np.abs(self.m_values) <= mmax_new
    active_m = self.m_values[mask]

    n_era_out = 2 * mmax_new + 1
    new_vis = mmodes_to_visibilities(sparse[:, mask], m1max=mmax_new, ms=active_m)

    new_era_deg = np.linspace(0, 360, n_era_out, endpoint=False, dtype=np.float64)

    # Recompute mmodes from the new vis so the returned container is
    # self-consistent: all m-values in [-mmax_new, mmax_new] are populated.
    new_mmodes = visibilities_to_mmodes(new_vis)
    new_m_values = np.arange(-mmax_new, mmax_new + 1, dtype=np.int_)

    return attrs.evolve(
        self,
        vis=new_vis,
        era_deg=new_era_deg,
        mmodes=new_mmodes,
        m_values=new_m_values,
    )

resample_to_ra(ra_deg, *, latitude, longitude, altitude=np.pi / 2, azimuth=np.pi)

Evaluate visibilities at CIRS Right Ascension positions.

Computes the ERA at which each RA transits the beam centre, then evaluates the visibility timestream at those ERAs via NUDFT. The mmodes are left unchanged — they remain the Fourier transform of the original ERA-indexed stream.

The ERA-RA relationship has unit slope: as the Earth rotates by δ°, the CIRS RA of any TIRS-fixed direction increases by exactly δ°. The offset is determined solely by the beam pointing geometry.

Parameters:

Name Type Description Default
ra_deg (ndarray, shape(n_t))

Target CIRS Right Ascension values in degrees.

required
latitude float

Site geodetic latitude in radians.

required
longitude float

Site geodetic longitude in radians.

required
altitude float

Pointing altitude in radians, by default np.pi / 2 (zenith).

pi / 2
azimuth float

Pointing azimuth in radians, by default np.pi (North).

pi

Returns:

Type Description
Visibilities

New container with vis evaluated at the requested RAs, era_deg set to the corresponding ERA values, ra_deg set to the input array, and mmodes/m_values unchanged.

Source code in src/serval/containers.py
def resample_to_ra(
    self,
    ra_deg: npt.NDArray[np.float64],
    *,
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
) -> "Visibilities":
    """Evaluate visibilities at CIRS Right Ascension positions.

    Computes the ERA at which each RA transits the beam centre, then
    evaluates the visibility timestream at those ERAs via NUDFT.  The
    ``mmodes`` are left unchanged — they remain the Fourier transform of
    the original ERA-indexed stream.

    The ERA-RA relationship has unit slope: as the Earth rotates by δ°, the
    CIRS RA of any TIRS-fixed direction increases by exactly δ°.  The
    offset is determined solely by the beam pointing geometry.

    Parameters
    ----------
    ra_deg : ndarray, shape (n_t,)
        Target CIRS Right Ascension values in degrees.
    latitude : float
        Site geodetic latitude in radians.
    longitude : float
        Site geodetic longitude in radians.
    altitude : float, optional
        Pointing altitude in radians, by default ``np.pi / 2`` (zenith).
    azimuth : float, optional
        Pointing azimuth in radians, by default ``np.pi`` (North).

    Returns
    -------
    Visibilities
        New container with ``vis`` evaluated at the requested RAs,
        ``era_deg`` set to the corresponding ERA values, ``ra_deg`` set to
        the input array, and ``mmodes``/``m_values`` unchanged.
    """
    # Beam centre is the z-axis of the pointing frame.  tirs_to_pointing
    # maps TIRS → pointing frame; its inverse maps pointing → TIRS.
    # At ERA=0, tirs_to_cirs(0) is the identity so CIRS = TIRS.
    v_tirs = (
        tirs_to_pointing(latitude, longitude, altitude, azimuth)
        .inv()
        .apply(np.array([0.0, 0.0, 1.0]))
    )
    ra_0_deg = np.degrees(np.arctan2(v_tirs[1], v_tirs[0])) % 360.0

    era_target = (np.asarray(ra_deg, dtype=np.float64) - ra_0_deg) % 360.0
    new_vis = visibilities_at_eras(self.mmodes, self.m_values, era_target)
    return attrs.evolve(
        self, vis=new_vis, era_deg=era_target, ra_deg=np.asarray(ra_deg)
    )

resample_to_times(times)

Evaluate visibilities at arbitrary observation times.

Converts the input times to the UT1 scale and delegates to resample_to_ut1. All time scales supported by astropy.time.Time are accepted.

Parameters:

Name Type Description Default
times Time

Observation times at which to evaluate.

required

Returns:

Type Description
Visibilities

New container with vis evaluated at the requested times, era_deg set to the corresponding ERA values in degrees, ut1_time set to the UT1 Julian dates, and mmodes/m_values unchanged.

Source code in src/serval/containers.py
def resample_to_times(self, times: Time) -> "Visibilities":
    """Evaluate visibilities at arbitrary observation times.

    Converts the input times to the UT1 scale and delegates to
    [resample_to_ut1][serval.containers.Visibilities.resample_to_ut1].
    All time scales supported by
    ``astropy.time.Time`` are accepted.

    Parameters
    ----------
    times : astropy.time.Time
        Observation times at which to evaluate.

    Returns
    -------
    Visibilities
        New container with ``vis`` evaluated at the requested times,
        ``era_deg`` set to the corresponding ERA values in degrees,
        ``ut1_time`` set to the UT1 Julian dates, and
        ``mmodes``/``m_values`` unchanged.
    """
    return self.resample_to_ut1(times.ut1.jd)

resample_to_ut1(ut1_jd)

Evaluate visibilities at UT1 times.

Converts each UT1 Julian date to an absolute Greenwich ERA using ERFA's era00 function, then evaluates the visibility timestream at those ERAs via NUDFT. The mmodes are left unchanged.

The container ERA axis is the absolute Greenwich ERA as defined by ERFA: the same ERA value that erfa.era00(dj1, dj2) returns for the corresponding UT1 Julian date.

Parameters:

Name Type Description Default
ut1_jd (ndarray, shape(n_t))

UT1 Julian dates at which to evaluate.

required

Returns:

Type Description
Visibilities

New container with vis evaluated at the requested UT1 times, era_deg set to the corresponding ERA values in degrees, ut1_time set to the input array, and mmodes/m_values unchanged.

Source code in src/serval/containers.py
def resample_to_ut1(
    self,
    ut1_jd: npt.NDArray[np.float64],
) -> "Visibilities":
    """Evaluate visibilities at UT1 times.

    Converts each UT1 Julian date to an absolute Greenwich ERA using
    ERFA's ``era00`` function, then evaluates the visibility timestream at
    those ERAs via NUDFT.  The ``mmodes`` are left unchanged.

    The container ERA axis is the absolute Greenwich ERA as defined by
    ERFA: the same ERA value that ``erfa.era00(dj1, dj2)`` returns for the
    corresponding UT1 Julian date.

    Parameters
    ----------
    ut1_jd : ndarray, shape (n_t,)
        UT1 Julian dates at which to evaluate.

    Returns
    -------
    Visibilities
        New container with ``vis`` evaluated at the requested UT1 times,
        ``era_deg`` set to the corresponding ERA values in degrees,
        ``ut1_time`` set to the input array, and ``mmodes``/``m_values``
        unchanged.
    """
    ut1_jd = np.asarray(ut1_jd, dtype=np.float64)
    dj1, dj2 = np.divmod(ut1_jd, 1.0)
    era_target = np.degrees(erfa.era00(dj1, dj2))
    new_vis = visibilities_at_eras(self.mmodes, self.m_values, era_target)
    return attrs.evolve(self, vis=new_vis, era_deg=era_target, ut1_time=ut1_jd)

setup_zarr_store(store_location, group_path='/', *, frequencies_MHz, era_deg, m_values, n_freq, n_era, chunks, sky_pol, pol_product, metadata=None) classmethod

Create the zarr group structure with pre-allocated empty vis/mmodes arrays.

The vis and mmodes arrays are allocated but left unfilled; they are written incrementally by the visibility generation pipeline.

Parameters:

Name Type Description Default
store_location str or Path
required
group_path str
'/'
frequencies_MHz (ndarray, shape(n_freq))
required
era_deg (ndarray, shape(n_era))
required
m_values ndarray of int, shape (n_m,)
required
n_freq int
required
n_era int
required
chunks tuple[int, int]

Chunk shape (freq_chunk, era_chunk) for vis and mmodes.

required
sky_pol str

Sky Stokes parameter (written to group attrs).

required
pol_product str

Correlator polarisation product (written to group attrs).

required
metadata dict

Written to grp.attrs["metadata"].

None
Source code in src/serval/containers.py
@classmethod
def setup_zarr_store(
    cls,
    store_location: str | Path,
    group_path: str = r"/",
    *,
    frequencies_MHz: npt.NDArray[np.float64],
    era_deg: npt.NDArray[np.float64],
    m_values: npt.NDArray[np.int_],
    n_freq: int,
    n_era: int,
    chunks: tuple[int, int],
    sky_pol: str,
    pol_product: str,
    metadata: dict | None = None,
) -> None:
    """Create the zarr group structure with pre-allocated empty vis/mmodes arrays.

    The ``vis`` and ``mmodes`` arrays are allocated but left unfilled; they
    are written incrementally by the visibility generation pipeline.

    Parameters
    ----------
    store_location : str or Path
    group_path : str
    frequencies_MHz : ndarray, shape (n_freq,)
    era_deg : ndarray, shape (n_era,)
    m_values : ndarray of int, shape (n_m,)
    n_freq : int
    n_era : int
    chunks : tuple[int, int]
        Chunk shape ``(freq_chunk, era_chunk)`` for ``vis`` and ``mmodes``.
    sky_pol : str
        Sky Stokes parameter (written to group attrs).
    pol_product : str
        Correlator polarisation product (written to group attrs).
    metadata : dict, optional
        Written to ``grp.attrs["metadata"]``.
    """
    store = zarr.storage.LocalStore(Path(store_location))
    grp = zarr.create_group(store=store, path=group_path, overwrite=True)
    grp.attrs["sky_pol"] = sky_pol
    grp.attrs["pol_product"] = pol_product
    grp.attrs["metadata"] = metadata or {}
    grp.create_array(name="frequencies_MHz", data=frequencies_MHz, overwrite=True)
    grp.create_array(name="era_deg", data=era_deg, overwrite=True)
    grp.create_array(name="m_values", data=m_values, overwrite=True)
    for name in ("vis", "mmodes"):
        grp.create_array(
            name=name,
            shape=(n_freq, n_era),
            chunks=chunks,
            dtype=np.complex128,
            overwrite=True,
            compressors=zarr.codecs.BloscCodec(**ZARR_COMPRESSORS),
        )

to_zarr_store(store_location, group_path='/', metadata=None)

Persist the container to a zarr store on disk.

Source code in src/serval/containers.py
def to_zarr_store(
    self,
    store_location: str | Path,
    group_path: str = r"/",
    metadata: dict | None = None,
) -> None:
    """Persist the container to a zarr store on disk."""
    store = zarr.storage.LocalStore(Path(store_location))
    grp = zarr.create_group(store=store, path=group_path, overwrite=True)
    for k in self._to_attrs:
        grp.attrs[k] = getattr(self, k)
    combined_metadata = dict(self.metadata)
    if metadata is not None:
        combined_metadata.update(metadata)
    grp.attrs["metadata"] = combined_metadata
    grp.create_array(
        name="frequencies_MHz",
        data=np.asarray(self.frequencies_MHz),
        overwrite=True,
    )
    grp.create_array(name="era_deg", data=np.asarray(self.era_deg), overwrite=True)
    grp.create_array(
        name="m_values", data=np.asarray(self.m_values), overwrite=True
    )
    for name in ("vis", "mmodes"):
        grp.create_array(
            name=name,
            data=np.asarray(getattr(self, name)),
            overwrite=True,
            compressors=zarr.codecs.BloscCodec(**ZARR_COMPRESSORS),
        )
    if self.ra_deg is not None:
        grp.create_array(
            name="ra_deg", data=np.asarray(self.ra_deg), overwrite=True
        )
    if self.ut1_time is not None:
        grp.create_array(
            name="ut1_time", data=np.asarray(self.ut1_time), overwrite=True
        )

alm_shape_validator(instance, attribute, value)

Validate that the last two axes of alm are compatible with lmax and mmax.

Source code in src/serval/containers.py
def alm_shape_validator(instance, attribute, value) -> None:
    """Validate that the last two axes of alm are compatible with lmax and mmax."""
    if not (
        (value.shape[-2] <= (instance.lmax + 1))
        and (value.shape[-1] <= (2 * instance.mmax + 1))
    ):
        raise ValueError(
            f"alm shape {value.shape} incompatible with "
            f"lmax={instance.lmax}, mmax={instance.mmax}. "
            f"Expected shape[-2] <= {instance.lmax + 1} "
            f"and shape[-1] <= {2 * instance.mmax + 1}."
        )

frequencies_MHz_validator(instance, attribute, value)

Validate that frequencies_MHz is a floating-point numpy ndarray.

Source code in src/serval/containers.py
def frequencies_MHz_validator(instance, attribute, value) -> None:
    """Validate that frequencies_MHz is a floating-point numpy ndarray."""
    if not isinstance(value, np.ndarray):
        raise TypeError(
            f"frequencies_MHz must be a numpy ndarray, got {type(value).__name__}."
        )
    if not np.issubdtype(value.dtype, np.floating):
        raise TypeError(
            f"frequencies_MHz must have a floating-point dtype, got {value.dtype}."
        )

lmax_validator(instance, attribute, value)

Validate that lmax is an integer greater than 1.

Source code in src/serval/containers.py
def lmax_validator(instance, attribute, value) -> None:
    """Validate that lmax is an integer greater than 1."""
    if not isinstance(value, int):
        raise TypeError(f"lmax must be an int, got {type(value).__name__}.")
    if value <= 1:
        raise ValueError(f"lmax must be > 1, got {value}.")

mmax_validator(instance, attribute, value)

Validate that mmax is a positive integer.

Source code in src/serval/containers.py
def mmax_validator(instance, attribute, value) -> None:
    """Validate that mmax is a positive integer."""
    if not isinstance(value, int):
        raise TypeError(f"mmax must be an int, got {type(value).__name__}.")
    if value <= 0:
        raise ValueError(f"mmax must be a positive integer, got {value}.")

powerbeam_alm_validator(instance, attribute, value)

Validate that alm has shape (n_freq, lmax+1, 2*mmax+1) for TIRSPowerBeam.

Source code in src/serval/containers.py
def powerbeam_alm_validator(instance, attribute, value) -> None:
    """Validate that alm has shape (n_freq, lmax+1, 2*mmax+1) for TIRSPowerBeam."""
    alm_shape_validator(instance, attribute, value)
    n_freq = instance.frequencies_MHz.size
    if value.ndim != 3:
        raise ValueError(
            f"TIRSPowerBeam alm must have shape (n_freq, lmax+1, 2*mmax+1), got {value.shape}."
        )
    if value.shape[0] != n_freq:
        raise ValueError(
            f"TIRSPowerBeam alm.shape[0]={value.shape[0]} does not match n_freq={n_freq}."
        )

skymodel_alm_validator(instance, attribute, value)

Validate alm shape and leading polarisation dimension for SkyModel.

Source code in src/serval/containers.py
def skymodel_alm_validator(instance, attribute, value) -> None:
    """Validate alm shape and leading polarisation dimension for SkyModel."""
    alm_shape_validator(instance, attribute, value)
    expected_ndim = _POL_INFO[instance.polarisation]["ndim"]
    if value.ndim != expected_ndim:
        raise ValueError(
            f"alm ndim {value.ndim} incompatible with polarisation='{instance.polarisation}'. "
            f"Expected ndim={expected_ndim}."
        )
    expected_npol = _POL_INFO[instance.polarisation]["npol"]
    if expected_npol is not None and value.shape[0] != expected_npol:
        raise ValueError(
            f"alm leading dimension {value.shape[0]} incompatible with "
            f"polarisation='{instance.polarisation}'. Expected shape[0]={expected_npol}."
        )

tirsvbeam_alm_validator(instance, attribute, value)

Validate that alm has shape (2, n_freq, lmax+1, 2*mmax+1) for TIRSVoltageBeam.

Source code in src/serval/containers.py
def tirsvbeam_alm_validator(instance, attribute, value) -> None:
    """Validate that alm has shape (2, n_freq, lmax+1, 2*mmax+1) for TIRSVoltageBeam."""
    alm_shape_validator(instance, attribute, value)
    if value.ndim != 4 or value.shape[0] != 2:
        raise ValueError(
            f"TIRSVoltageBeam alm must have shape (2, n_freq, lmax+1, 2*mmax+1), got {value.shape}."
        )

batch_rotate_alm(alm, lmax, mmax, eulers_or_rotation)

Rotate a batch of SERVAL alm arrays using two calls to ducc0.sht.rotate_alm.

Converts the input to the ducc0 real-field decomposition, applies the rotation in batch, and converts back. The output always has mmax = lmax because a rotation generically populates all m-modes.

Parameters:

Name Type Description Default
alm NDArray[complex128]

SERVAL alm array of shape (..., lmax+1, 2*mmax+1).

required
lmax int

Maximum degree.

required
mmax int

Maximum order of the input.

required
eulers_or_rotation tuple[float, float, float] | Rotation

Passive ZYZ Euler angles (alpha, beta, gamma) in radians, or a scipy.spatial.transform.Rotation object.

required

Returns:

Type Description
NDArray[complex128]

SERVAL alm of shape (..., lmax+1, 2*lmax+1).

Source code in src/serval/rotate.py
def batch_rotate_alm(
    alm: npt.NDArray[np.complex128],
    lmax: int,
    mmax: int,
    eulers_or_rotation: EulersType | Rotation,
) -> npt.NDArray[np.complex128]:
    r"""Rotate a batch of SERVAL alm arrays using two calls to ``ducc0.sht.rotate_alm``.

    Converts the input to the ducc0 real-field decomposition, applies the rotation
    in batch, and converts back.  The output always has ``mmax = lmax`` because a
    rotation generically populates all m-modes.

    Parameters
    ----------
    alm : npt.NDArray[np.complex128]
        SERVAL alm array of shape ``(..., lmax+1, 2*mmax+1)``.
    lmax : int
        Maximum degree.
    mmax : int
        Maximum order of the input.
    eulers_or_rotation : tuple[float, float, float] | Rotation
        Passive ZYZ Euler angles ``(alpha, beta, gamma)`` in radians, or a
        ``scipy.spatial.transform.Rotation`` object.

    Returns
    -------
    npt.NDArray[np.complex128]
        SERVAL alm of shape ``(..., lmax+1, 2*lmax+1)``.
    """
    if isinstance(eulers_or_rotation, Rotation):
        alpha, beta, gamma = eulers_from_rotation(eulers_or_rotation)
    else:
        alpha, beta, gamma = eulers_or_rotation

    batch_shape = alm.shape[:-2]
    n_batch = int(np.prod(batch_shape)) if batch_shape else 1
    alm_2d = alm.reshape(n_batch, lmax + 1, 2 * mmax + 1)

    aR, aI = serval_to_ducc0_alm(alm_2d, lmax, mmax)
    # ducc0 uses an active rotation convention; negate angles to match passive ZYZ
    rR = ducc0.sht.rotate_alm(aR, lmax, -alpha, -beta, -gamma)
    rI = ducc0.sht.rotate_alm(aI, lmax, -alpha, -beta, -gamma) if np.any(aI) else aI

    rotated = ducc0_to_serval_alm(rR, rI, lmax)
    return rotated.reshape(*batch_shape, lmax + 1, 2 * lmax + 1)

boresight_for_fixed_pol_ref(alt, az, ref_alt=np.pi / 2, ref_az=np.pi, ref_boresight=0.0)

Boresight angle that aligns the feed co-pol axis with a reference pointing orientation.

When a dish is tilted away from a nominal pointing direction, the ZYZ Euler parameterisation induces an apparent rotation of the X/Y polarisation axes in TIRS. This function computes the boresight correction that eliminates that rotation, so that boresight = boresight_for_fixed_pol_ref(alt, az) + chi gives a pointing whose feed orientation differs from the reference only by the controllable roll chi.

Parameters:

Name Type Description Default
alt float

Pointing altitude in radians.

required
az float

Pointing azimuth in radians.

required
ref_alt float

Reference pointing altitude in radians. Defaults to pi/2 (zenith).

pi / 2
ref_az float

Reference pointing azimuth in radians. Defaults to pi (South).

pi
ref_boresight float

Reference boresight in radians. Defaults to 0.

0.0

Returns:

Type Description
float

Boresight angle in radians to pass to :func:zenith_to_pointing or :meth:TIRSVoltageBeam.from_ludwig3.

Source code in src/serval/rotate.py
def boresight_for_fixed_pol_ref(
    alt: float,
    az: float,
    ref_alt: float = np.pi / 2,
    ref_az: float = np.pi,
    ref_boresight: float = 0.0,
) -> float:
    r"""Boresight angle that aligns the feed co-pol axis with a reference pointing orientation.

    When a dish is tilted away from a nominal pointing direction, the ZYZ Euler
    parameterisation induces an apparent rotation of the X/Y polarisation axes in TIRS.
    This function computes the boresight correction that eliminates that rotation, so
    that ``boresight = boresight_for_fixed_pol_ref(alt, az) + chi`` gives a pointing whose
    feed orientation differs from the reference only by the controllable roll ``chi``.

    Parameters
    ----------
    alt : float
        Pointing altitude in radians.
    az : float
        Pointing azimuth in radians.
    ref_alt : float
        Reference pointing altitude in radians.  Defaults to ``pi/2`` (zenith).
    ref_az : float
        Reference pointing azimuth in radians.  Defaults to ``pi`` (South).
    ref_boresight : float
        Reference boresight in radians.  Defaults to ``0``.

    Returns
    -------
    float
        Boresight angle in radians to pass to :func:`zenith_to_pointing` or
        :meth:`TIRSVoltageBeam.from_ludwig3`.
    """
    x_ref = zenith_to_pointing(ref_alt, ref_az, ref_boresight).inv().apply(
        np.array([1.0, 0.0, 0.0])
    )
    R_new_active = zenith_to_pointing(alt, az, 0.0).inv()
    x_new0 = R_new_active.apply(np.array([1.0, 0.0, 0.0]))
    y_new0 = R_new_active.apply(np.array([0.0, 1.0, 0.0]))
    return float(np.arctan2(float(np.dot(x_ref, y_new0)), float(np.dot(x_ref, x_new0))))

cirs_to_cirs(source_epoch, target_epoch)

Return a passive rotation from CIRS at one epoch to CIRS at another.

Routes through GCRS using both CIO-based matrices: CIRS(source) \(\to\) GCRS \(\to\) CIRS(target).

Parameters:

Name Type Description Default
source_epoch str

The source epoch string (e.g. "J2000"), parsed by astropy.time.Time.

required
target_epoch str

The target epoch string, parsed by astropy.time.Time.

required

Returns:

Type Description
Rotation

A Rotation from CIRS(source) to CIRS(target).

Source code in src/serval/rotate.py
def cirs_to_cirs(source_epoch: str, target_epoch: str) -> Rotation:
    r"""Return a passive rotation from CIRS at one epoch to CIRS at another.

    Routes through GCRS using both CIO-based matrices:
    CIRS(source) $\to$ GCRS $\to$ CIRS(target).

    Parameters
    ----------
    source_epoch : str
        The source epoch string (e.g. ``"J2000"``), parsed by
        ``astropy.time.Time``.
    target_epoch : str
        The target epoch string, parsed by ``astropy.time.Time``.

    Returns
    -------
    Rotation
        A ``Rotation`` from CIRS(source) to CIRS(target).
    """
    c2i_target = gcrs_to_cirs(target_epoch).as_matrix()
    c2i_source = gcrs_to_cirs(source_epoch).as_matrix()
    R = c2i_target @ c2i_source.T
    return Rotation.from_matrix(R)

diurnal_aberrate_tirs(n, latitude, longitude)

Apply diurnal-aberration to TIRS unit directions.

Transforms unit direction vector(s) \(\hat{n}\) in the TIRS frame to account for the special-relativistic aberration caused by Earth's diurnal rotation. The observer velocity \(\boldsymbol{\beta} = \mathbf{v}/c\) in TIRS is exactly constant (independent of ERA or epoch), given by \(\boldsymbol{\beta} = (-\omega r_y,\; \omega r_x,\; 0) / c\) where \((r_x, r_y)\) are the WGS84 coordinates of the observer.

Parameters:

Name Type Description Default
n NDArray[float64]

Unit direction vector(s) in the TIRS frame. Shape (..., 3).

required
latitude float

Geodetic latitude in radians.

required
longitude float

Geodetic longitude in radians.

required

Returns:

Type Description
NDArray[float64]

Aberrated unit direction vector(s). Same shape as n.

Source code in src/serval/rotate.py
def diurnal_aberrate_tirs(
    n: npt.NDArray[np.float64],
    latitude: float,
    longitude: float,
) -> npt.NDArray[np.float64]:
    r"""Apply diurnal-aberration to TIRS unit directions.

    Transforms unit direction vector(s) $\hat{n}$ in the TIRS frame to account
    for the special-relativistic aberration caused by Earth's diurnal rotation.
    The observer velocity $\boldsymbol{\beta} = \mathbf{v}/c$ in TIRS is exactly
    constant (independent of ERA or epoch), given by
    $\boldsymbol{\beta} = (-\omega r_y,\; \omega r_x,\; 0) / c$
    where $(r_x, r_y)$ are the WGS84 coordinates of the observer.

    Parameters
    ----------
    n : npt.NDArray[np.float64]
        Unit direction vector(s) in the TIRS frame.  Shape ``(..., 3)``.
    latitude : float
        Geodetic latitude in radians.
    longitude : float
        Geodetic longitude in radians.

    Returns
    -------
    npt.NDArray[np.float64]
        Aberrated unit direction vector(s).  Same shape as ``n``.
    """
    # Earth rotation rate — IERS 2010 nominal value, consistent with ERFA/Astropy
    _OMEGA_EARTH_RAD_S = 7.292115085e-5
    loc = coords.EarthLocation.from_geodetic(
        lon=longitude * units.rad,
        lat=latitude * units.rad,
        height=0.0 * units.m,
    )
    rx = loc.x.to_value(units.m)
    ry = loc.y.to_value(units.m)
    beta = np.array([-_OMEGA_EARTH_RAD_S * ry, _OMEGA_EARTH_RAD_S * rx, 0.0])
    beta /= consts.c.to("m/s").value

    n = np.asarray(n, dtype=float)
    beta_sq = float(beta @ beta)
    gamma = 1.0 / np.sqrt(1.0 - beta_sq)
    dot = n @ beta
    scale = ((gamma - 1.0) * dot / beta_sq + gamma)[..., np.newaxis]
    aberrated = (n + scale * beta) / (gamma * (1.0 + dot))[..., np.newaxis]
    return aberrated / np.linalg.norm(aberrated, axis=-1, keepdims=True)

enu_to_tirs(latitude, longitude)

Creates a rotation object for basis rotations from the ENU coordinate frame of an observer at the specified latitude and longitude to the TIRS frame.

Parameters:

Name Type Description Default
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required

Returns:

Type Description
Rotation

A Rotation object representing the ENU to TIRS basis rotation.

Source code in src/serval/rotate.py
def enu_to_tirs(latitude: float, longitude: float) -> Rotation:
    r"""Creates a rotation object for basis rotations from the ENU coordinate frame of an observer
    at the specified latitude and longitude to the TIRS frame.
    Parameters
    ----------
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    Returns
    -------
    Rotation
        A Rotation object representing the ENU to TIRS basis rotation.
    """
    zenith_to_tirs = tirs_to_zenith(latitude=latitude, longitude=longitude).inv()
    return zenith_to_tirs * enu_to_zenith()

enu_to_zenith()

Provides a rotation object converting from the ENU basis to the local Zenith basis.

Returns:

Type Description
Rotation

A Rotation object.

Source code in src/serval/rotate.py
def enu_to_zenith() -> Rotation:
    r"""Provides a rotation object converting from the ENU basis
    to the local Zenith basis.
    Returns
    -------
    Rotation
        A Rotation object.
    """
    # fmt: off
    return Rotation.from_matrix([
        [0, -1, 0],
        [1,  0, 0],
        [0,  0, 1]]
    )

eulers_from_rotation(rotation)

Extracts the ZYZ Euler angles from a rotation object.

The convention used here and throughout is that of ZYZ passive (basis) rotations.

Parameters:

Name Type Description Default
rotation Rotation

A Rotation object.

required

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the rotation.

Source code in src/serval/rotate.py
def eulers_from_rotation(rotation: Rotation) -> EulersType:
    r"""Extracts the ZYZ Euler angles from a rotation object.

    The convention used here and throughout is that of
    ZYZ passive (basis) rotations.

    Parameters
    ----------
    rotation : Rotation
        A Rotation object.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the rotation.
    """
    # .inv() for passive convention
    return tuple(rotation.inv().as_euler("ZYZ"))

frame_rotation_to_cirs(source_basis, epoch)

Return a passive rotation from a celestial coordinate frame to CIRS.

Dispatches to :func:icrs_to_cirs, :func:galactic_to_cirs, or :func:cirs_to_cirs based on source_basis.

Parameters:

Name Type Description Default
source_basis ``"ICRS"``, ``"Galactic"``, or ``"CIRS:<epoch>"``

The source coordinate frame. For CIRS input, append the old epoch after a colon, e.g. "CIRS:J2000".

required
epoch str

The target epoch string (e.g. "J2000"), parsed by astropy.time.Time.

required

Returns:

Type Description
Rotation

A Rotation from the source frame to CIRS at the requested epoch.

Raises:

Type Description
ValueError

If source_basis is not recognised.

Source code in src/serval/rotate.py
def frame_rotation_to_cirs(
    source_basis: str,
    epoch: str,
) -> Rotation:
    r"""Return a passive rotation from a celestial coordinate frame to CIRS.

    Dispatches to :func:`icrs_to_cirs`, :func:`galactic_to_cirs`, or
    :func:`cirs_to_cirs` based on ``source_basis``.

    Parameters
    ----------
    source_basis : ``"ICRS"``, ``"Galactic"``, or ``"CIRS:<epoch>"``
        The source coordinate frame. For CIRS input, append the old
        epoch after a colon, e.g. ``"CIRS:J2000"``.
    epoch : str
        The target epoch string (e.g. ``"J2000"``), parsed by
        ``astropy.time.Time``.

    Returns
    -------
    Rotation
        A ``Rotation`` from the source frame to CIRS at the
        requested epoch.

    Raises
    ------
    ValueError
        If ``source_basis`` is not recognised.
    """
    if source_basis == "ICRS":
        return icrs_to_cirs(epoch)
    elif source_basis == "Galactic":
        return galactic_to_cirs(epoch)
    elif source_basis.startswith("CIRS:"):
        source_epoch = source_basis.split(":", 1)[1]
        return cirs_to_cirs(source_epoch, epoch)
    else:
        raise ValueError(
            f"Unsupported source_basis '{source_basis}'. "
            "Expected 'ICRS', 'Galactic', or 'CIRS:<epoch>'."
        )

galactic_to_cirs(epoch)

Return a passive rotation from Galactic coordinates to CIRS.

The Galactic-to-ICRS rotation (from astropy) is composed with the CIO-based celestial-to-intermediate matrix. Aberration is not included.

Parameters:

Name Type Description Default
epoch str

The target epoch string (e.g. "J2000"), parsed by astropy.time.Time.

required

Returns:

Type Description
Rotation

A Rotation from Galactic to CIRS.

Source code in src/serval/rotate.py
def galactic_to_cirs(epoch: str) -> Rotation:
    r"""Return a passive rotation from Galactic coordinates to CIRS.

    The Galactic-to-ICRS rotation (from astropy) is composed with the
    CIO-based celestial-to-intermediate matrix. Aberration is not included.

    Parameters
    ----------
    epoch : str
        The target epoch string (e.g. ``"J2000"``), parsed by
        ``astropy.time.Time``.

    Returns
    -------
    Rotation
        A ``Rotation`` from Galactic to CIRS.
    """
    from astropy.coordinates import ICRS, CartesianRepresentation, Galactic

    basis_xyz = CartesianRepresentation(
        x=[1, 0, 0],
        y=[0, 1, 0],
        z=[0, 0, 1],
    )
    gal_coords = Galactic().realize_frame(basis_xyz)
    icrs_coords = gal_coords.transform_to(ICRS())
    gal_to_icrs = np.array(icrs_coords.cartesian.xyz.value)

    return Rotation.from_matrix(gcrs_to_cirs(epoch).as_matrix() @ gal_to_icrs)

gcrs_to_cirs(epoch)

Creates a rotation object for basis rotations from the GCRS frame to CIRS.

Uses the IAU 2006/2000A CIO-based celestial-to-intermediate matrix (via ERFA's c2i06a).

Parameters:

Name Type Description Default
epoch str

The epoch string (e.g. "J2000"), parsed by astropy.time.Time.

required

Returns:

Type Description
Rotation

A Rotation from GCRS to CIRS.

Source code in src/serval/rotate.py
def gcrs_to_cirs(epoch: str) -> Rotation:
    r"""Creates a rotation object for basis rotations from the GCRS frame to CIRS.

    Uses the IAU 2006/2000A CIO-based celestial-to-intermediate matrix
    (via ERFA's ``c2i06a``).

    Parameters
    ----------
    epoch : str
        The epoch string (e.g. ``"J2000"``), parsed by
        ``astropy.time.Time``.

    Returns
    -------
    Rotation
        A ``Rotation`` from GCRS to CIRS.
    """

    obstime = Time(epoch)
    jd1, jd2 = obstime.tt.jd1, obstime.tt.jd2
    c2i = erfa.c2i06a(jd1, jd2)
    return Rotation.from_matrix(c2i)

generate_telescope_wigner_ds(beam_lmax, beam_mmax, baseline_lmax, latitude, longitude, altitude, azimuth, baseline_enu)

Generates Wigner-D matrices for the TIRS to telescope pointing frame and T IRS to baseline frame basis rotations.

Parameters:

Name Type Description Default
beam_lmax int

The maximum degree of the beam Wigner-D matrix.

required
beam_mmax int

The maximum order of m' for the beam Wigner-D matrix.

required
baseline_lmax int

The maximum degree of the baseline Wigner-D matrix.

required
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required
altitude float

The altitude angle in radians.

required
azimuth float

The azimuth angle in radians.

required
baseline_enu tuple[float, float, float]

The baseline vector components in the ENU frame (east, north, up).

required

Returns:

Type Description
tuple[NDArray[complex128], NDArray[complex128]]

The Wigner-D matrices for the TIRS to telescope pointing frame and TIRS to baseline frame basis rotations.

Source code in src/serval/rotate.py
def generate_telescope_wigner_ds(
    beam_lmax: int,
    beam_mmax: int,
    baseline_lmax: int,
    latitude: float,
    longitude: float,
    altitude: float,
    azimuth: float,
    baseline_enu: tuple[float, float, float],
) -> tuple[npt.NDArray[np.complex128], npt.NDArray[np.complex128]]:
    r"""Generates Wigner-D matrices for the TIRS to telescope pointing frame and T
    IRS to baseline frame basis rotations.
    Parameters
    ----------
    beam_lmax : int
        The maximum degree of the beam Wigner-D matrix.
    beam_mmax : int
        The maximum order of m' for the beam Wigner-D matrix.
    baseline_lmax : int
        The maximum degree of the baseline Wigner-D matrix.
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    altitude : float
        The altitude angle in radians.
    azimuth : float
        The azimuth angle in radians.
    baseline_enu : tuple[float, float, float]
        The baseline vector components in the ENU frame (east, north, up).
    Returns
    -------
    tuple[npt.NDArray[np.complex128], npt.NDArray[np.complex128]]
        The Wigner-D matrices for the TIRS to telescope pointing frame and TIRS to baseline
        frame basis rotations.
    """
    print("Generating Pointing Wigner-D...")
    wigner_d_pointing = wigner_d(
        beam_lmax,
        beam_mmax,
        tirs_to_pointing(
            latitude=latitude, longitude=longitude, altitude=altitude, azimuth=azimuth
        ),
    )
    print("Generating Baseline Wigner-D...")
    wigner_d_baseline = wigner_d(
        baseline_lmax,
        0,
        tirs_to_baseline_direc(
            latitude=latitude,
            longitude=longitude,
            east=baseline_enu[0],
            north=baseline_enu[1],
            up=baseline_enu[2],
        ),
    )
    return wigner_d_pointing, wigner_d_baseline

icrs_to_cirs(epoch)

Return a passive rotation from ICRS to CIRS at the given epoch.

Delegates to :func:gcrs_to_cirs as for the purposes of this code, the GCRS to ICRS transformation is neglible, mainly aberration is not ignored limiting the accuracy to ~20.5 arcsec.

Parameters:

Name Type Description Default
epoch str

The target epoch string (e.g. "J2000"), parsed by astropy.time.Time.

required

Returns:

Type Description
Rotation

A Rotation from ICRS to CIRS.

Source code in src/serval/rotate.py
def icrs_to_cirs(epoch: str) -> Rotation:
    r"""Return a passive rotation from ICRS to CIRS at the given epoch.

    Delegates to :func:`gcrs_to_cirs` as for the purposes of this code,
    the GCRS to ICRS transformation is neglible, mainly aberration is
    not ignored limiting the accuracy to ~20.5 arcsec.

    Parameters
    ----------
    epoch : str
        The target epoch string (e.g. ``"J2000"``), parsed by
        ``astropy.time.Time``.

    Returns
    -------
    Rotation
        A ``Rotation`` from ICRS to CIRS.
    """
    return gcrs_to_cirs(epoch)

inv_eulers(eulers)

Provides the Euler angles corresponding to the inverse rotation of those given.

Parameters:

Name Type Description Default
eulers tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the rotation.

required

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the inverse rotation.

Source code in src/serval/rotate.py
def inv_eulers(eulers: EulersType) -> EulersType:
    r"""Provides the Euler angles corresponding to the inverse rotation of those given.
    Parameters
    ----------
    eulers : tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the rotation.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the inverse rotation.
    """
    return (-eulers[2], -eulers[1], -eulers[0])

itrs_enu_to_tirs_enu(geodetic_lat, geodetic_lon, enu, epoch)

Convert an ENU vector from the ITRS frame to its TIRS-frame ENU components.

Computes \(W^\top \mathbf{v}_\mathrm{ITRS}\) (the exact TIRS Cartesian vector) then re-expresses it as ENU components in the TIRS frame at the polar-motion-corrected (lat_tirs, lon_tirs). The result satisfies:

enu_to_tirs(lat_tirs, lon_tirs).apply(enu_tirs) == W^T @ enu_to_tirs(lat, lon).apply(enu)

so that passing (lat_tirs, lon_tirs, enu_tirs) to SERVAL functions that internally call enu_to_tirs yields the physically correct TIRS Cartesian vector.

Parameters:

Name Type Description Default
geodetic_lat float

Observer WGS84 geodetic latitude in radians.

required
geodetic_lon float

Observer WGS84 geodetic longitude in radians.

required
enu (array - like, shape(3))

ENU vector in the ITRS frame (e.g. baseline in metres).

required
epoch Time

Epoch at which the IERS polar motion is evaluated.

required

Returns:

Type Description
(ndarray, shape(3))

ENU components in the TIRS frame at (lat_tirs, lon_tirs).

Source code in src/serval/rotate.py
def itrs_enu_to_tirs_enu(
    geodetic_lat: float,
    geodetic_lon: float,
    enu: npt.ArrayLike,
    epoch: Time,
) -> npt.NDArray[np.float64]:
    r"""Convert an ENU vector from the ITRS frame to its TIRS-frame ENU components.

    Computes $W^\top \mathbf{v}_\mathrm{ITRS}$ (the exact TIRS Cartesian vector) then
    re-expresses it as ENU components in the TIRS frame at the polar-motion-corrected
    ``(lat_tirs, lon_tirs)``.  The result satisfies:

    ``enu_to_tirs(lat_tirs, lon_tirs).apply(enu_tirs) == W^T @ enu_to_tirs(lat, lon).apply(enu)``

    so that passing ``(lat_tirs, lon_tirs, enu_tirs)`` to SERVAL functions that internally
    call [enu_to_tirs][serval.rotate.enu_to_tirs] yields the physically correct TIRS
    Cartesian vector.

    Parameters
    ----------
    geodetic_lat : float
        Observer WGS84 geodetic latitude in radians.
    geodetic_lon : float
        Observer WGS84 geodetic longitude in radians.
    enu : array-like, shape (3,)
        ENU vector in the ITRS frame (e.g. baseline in metres).
    epoch : astropy.time.Time
        Epoch at which the IERS polar motion is evaluated.

    Returns
    -------
    ndarray, shape (3,)
        ENU components in the TIRS frame at ``(lat_tirs, lon_tirs)``.
    """
    R = itrs_to_tirs(epoch)
    bl_itrs = enu_to_tirs(geodetic_lat, geodetic_lon).apply(np.asarray(enu, dtype=float))
    bl_tirs = R.apply(bl_itrs)

    # Can't use spherical_to_normal due to circular imports...
    up_itrs = np.array([
        np.cos(geodetic_lat) * np.cos(geodetic_lon),
        np.cos(geodetic_lat) * np.sin(geodetic_lon),
        np.sin(geodetic_lat),
    ])
    up_tirs = R.apply(up_itrs)
    # Can't use normal_to_spherical due to circular imports...
    lat_tirs = float(np.arcsin(up_tirs[2]))
    lon_tirs = float(np.arctan2(up_tirs[1], up_tirs[0]))

    return enu_to_tirs(lat_tirs, lon_tirs).inv().apply(bl_tirs)

itrs_pointing_to_tirs_pointing(geodetic_lat, geodetic_lon, altitude, azimuth, boresight, epoch)

Convert an ITRS-frame pointing direction and boresight to their TIRS equivalents.

Converts (altitude, azimuth) by expressing the pointing unit vector in ITRS ENU, applying itrs_enu_to_tirs_enu, and extracting TIRS spherical coordinates. The boresight is corrected by tracking the ITRS pointing-frame x-axis into TIRS ENU and extracting the equivalent angle — the same technique used by boresight_for_fixed_pol_ref.

Parameters:

Name Type Description Default
geodetic_lat float

Observer WGS84 geodetic latitude in radians.

required
geodetic_lon float

Observer WGS84 geodetic longitude in radians.

required
altitude float

Pointing altitude in the ITRS ENU frame, in radians.

required
azimuth float

Pointing azimuth (North through East) in the ITRS ENU frame, in radians.

required
boresight float

Feed boresight rotation around the pointing axis in radians.

required
epoch Time

Epoch at which the IERS polar motion is evaluated.

required

Returns:

Type Description
tuple[float, float, float]

(altitude_tirs, azimuth_tirs, boresight_tirs) in radians.

Source code in src/serval/rotate.py
def itrs_pointing_to_tirs_pointing(
    geodetic_lat: float,
    geodetic_lon: float,
    altitude: float,
    azimuth: float,
    boresight: float,
    epoch: Time,
) -> tuple[float, float, float]:
    r"""Convert an ITRS-frame pointing direction and boresight to their TIRS equivalents.

    Converts ``(altitude, azimuth)`` by expressing the pointing unit vector in ITRS ENU,
    applying [itrs_enu_to_tirs_enu][serval.rotate.itrs_enu_to_tirs_enu], and extracting
    TIRS spherical coordinates.  The boresight is corrected by tracking the ITRS
    pointing-frame x-axis into TIRS ENU and extracting the equivalent angle — the same
    technique used by [boresight_for_fixed_pol_ref][serval.rotate.boresight_for_fixed_pol_ref].

    Parameters
    ----------
    geodetic_lat : float
        Observer WGS84 geodetic latitude in radians.
    geodetic_lon : float
        Observer WGS84 geodetic longitude in radians.
    altitude : float
        Pointing altitude in the ITRS ENU frame, in radians.
    azimuth : float
        Pointing azimuth (North through East) in the ITRS ENU frame, in radians.
    boresight : float
        Feed boresight rotation around the pointing axis in radians.
    epoch : astropy.time.Time
        Epoch at which the IERS polar motion is evaluated.

    Returns
    -------
    tuple[float, float, float]
        ``(altitude_tirs, azimuth_tirs, boresight_tirs)`` in radians.
    """
    # Can't use spherical_to_normal due to circular imports...
    enu = np.array([
        np.sin(azimuth) * np.cos(altitude),
        np.cos(azimuth) * np.cos(altitude),
        np.sin(altitude),
    ])
    enu_tirs = itrs_enu_to_tirs_enu(geodetic_lat, geodetic_lon, enu, epoch)
    # Can't use normal_to_spherical due to circular imports...
    alt_tirs = float(np.arcsin(np.clip(enu_tirs[2], -1.0, 1.0)))
    az_tirs = float(np.arctan2(enu_tirs[0], enu_tirs[1]))

    x_ref_itrs = zenith_to_pointing(altitude, azimuth, boresight).inv().apply(
        np.array([1.0, 0.0, 0.0])
    )
    x_ref_tirs = itrs_enu_to_tirs_enu(geodetic_lat, geodetic_lon, x_ref_itrs, epoch)
    R_tirs_zero = zenith_to_pointing(alt_tirs, az_tirs, 0.0).inv()
    x_tirs0 = R_tirs_zero.apply(np.array([1.0, 0.0, 0.0]))
    y_tirs0 = R_tirs_zero.apply(np.array([0.0, 1.0, 0.0]))
    boresight_tirs = float(np.arctan2(np.dot(x_ref_tirs, y_tirs0), np.dot(x_ref_tirs, x_tirs0)))

    return alt_tirs, az_tirs, boresight_tirs

itrs_to_tirs(epoch)

Rotation from ITRS to TIRS at a given epoch, applying IERS polar motion.

Parameters:

Name Type Description Default
epoch Time

Epoch at which the IERS polar motion is evaluated.

required

Returns:

Type Description
Rotation

Rotation that maps ITRS Cartesian vectors to TIRS Cartesian vectors.

Source code in src/serval/rotate.py
def itrs_to_tirs(epoch: Time) -> Rotation:
    """Rotation from ITRS to TIRS at a given epoch, applying IERS polar motion.

    Parameters
    ----------
    epoch : astropy.time.Time
        Epoch at which the IERS polar motion is evaluated.

    Returns
    -------
    Rotation
        Rotation that maps ITRS Cartesian vectors to TIRS Cartesian vectors.
    """
    xp, yp = get_polar_motion(epoch)
    sp = erfa.sp00(epoch.tt.jd1, epoch.tt.jd2)
    return Rotation.from_matrix(erfa.pom00(xp, yp, sp).T)

itrs_to_tirs_inputs(geodetic_lat, geodetic_lon, altitude, azimuth, boresight, baselines_enu, epoch)

Convert all ITRS observer inputs to their TIRS equivalents in one call.

Applies the IERS polar motion matrix \(W^\top\) to the site location, each baseline ENU vector, and the pointing direction.

Delegates to itrs_to_tirs_latlon, itrs_pointing_to_tirs_pointing, and itrs_enu_to_tirs_enu.

Parameters:

Name Type Description Default
geodetic_lat float

Observer WGS84 geodetic latitude in radians.

required
geodetic_lon float

Observer WGS84 geodetic longitude in radians.

required
altitude float

Pointing altitude in the ITRS ENU frame, in radians.

required
azimuth float

Pointing azimuth (North through East) in the ITRS ENU frame, in radians.

required
boresight float

Feed boresight rotation around the pointing axis, in radians.

required
baselines_enu list of array-like, each shape (3,)

Baseline ENU vectors in the ITRS frame.

required
epoch Time

Epoch at which the IERS polar motion is evaluated.

required

Returns:

Type Description
tuple

(latitude, longitude, altitude, azimuth, boresight, baselines_enu) all expressed in the TIRS frame.

Source code in src/serval/rotate.py
def itrs_to_tirs_inputs(
    geodetic_lat: float,
    geodetic_lon: float,
    altitude: float,
    azimuth: float,
    boresight: float,
    baselines_enu: list[npt.ArrayLike],
    epoch: Time,
) -> tuple[float, float, float, float, float, list[npt.NDArray[np.float64]]]:
    r"""Convert all ITRS observer inputs to their TIRS equivalents in one call.

    Applies the IERS polar motion matrix $W^\top$ to the site location, each baseline
    ENU vector, and the pointing direction.

    Delegates to [itrs_to_tirs_latlon][serval.rotate.itrs_to_tirs_latlon],
    [itrs_pointing_to_tirs_pointing][serval.rotate.itrs_pointing_to_tirs_pointing],
    and [itrs_enu_to_tirs_enu][serval.rotate.itrs_enu_to_tirs_enu].

    Parameters
    ----------
    geodetic_lat : float
        Observer WGS84 geodetic latitude in radians.
    geodetic_lon : float
        Observer WGS84 geodetic longitude in radians.
    altitude : float
        Pointing altitude in the ITRS ENU frame, in radians.
    azimuth : float
        Pointing azimuth (North through East) in the ITRS ENU frame, in radians.
    boresight : float
        Feed boresight rotation around the pointing axis, in radians.
    baselines_enu : list of array-like, each shape (3,)
        Baseline ENU vectors in the ITRS frame.
    epoch : astropy.time.Time
        Epoch at which the IERS polar motion is evaluated.

    Returns
    -------
    tuple
        ``(latitude, longitude, altitude, azimuth, boresight, baselines_enu)``
        all expressed in the TIRS frame.
    """
    lat_tirs, lon_tirs = itrs_to_tirs_latlon(geodetic_lat, geodetic_lon, epoch)
    alt_tirs, az_tirs, boresight_tirs = itrs_pointing_to_tirs_pointing(
        geodetic_lat, geodetic_lon, altitude, azimuth, boresight, epoch
    )
    bls_tirs = [
        itrs_enu_to_tirs_enu(geodetic_lat, geodetic_lon, bl, epoch)
        for bl in baselines_enu
    ]
    return lat_tirs, lon_tirs, alt_tirs, az_tirs, boresight_tirs, bls_tirs

itrs_to_tirs_latlon(geodetic_lat, geodetic_lon, epoch)

TIRS geocentric latitude and longitude for an ITRS observer at a given epoch.

Applies the IERS polar motion matrix \(W = \mathrm{erfa.pom00}\) to the geodetic zenith direction in ITRS, then returns its TIRS spherical coordinates. Passing the returned angles to enu_to_tirs or tirs_to_zenith makes those functions consistent with Astropy's ITRS coordinate chain.

Parameters:

Name Type Description Default
geodetic_lat float

Observer WGS84 geodetic latitude in radians.

required
geodetic_lon float

Observer WGS84 geodetic longitude in radians.

required
epoch Time

Epoch at which the IERS polar motion is evaluated.

required

Returns:

Type Description
tuple[float, float]

(lat_tirs, lon_tirs) in radians.

Source code in src/serval/rotate.py
def itrs_to_tirs_latlon(
    geodetic_lat: float,
    geodetic_lon: float,
    epoch: Time,
) -> tuple[float, float]:
    r"""TIRS geocentric latitude and longitude for an ITRS observer at a given epoch.

    Applies the IERS polar motion matrix $W = \mathrm{erfa.pom00}$ to the geodetic
    zenith direction in ITRS, then returns its TIRS spherical coordinates.  Passing
    the returned angles to [enu_to_tirs][serval.rotate.enu_to_tirs] or
    [tirs_to_zenith][serval.rotate.tirs_to_zenith] makes those functions consistent
    with Astropy's ITRS coordinate chain.

    Parameters
    ----------
    geodetic_lat : float
        Observer WGS84 geodetic latitude in radians.
    geodetic_lon : float
        Observer WGS84 geodetic longitude in radians.
    epoch : astropy.time.Time
        Epoch at which the IERS polar motion is evaluated.

    Returns
    -------
    tuple[float, float]
        ``(lat_tirs, lon_tirs)`` in radians.
    """

    # Can't use spherical_to_normal due to circular imports...
    up_itrs = np.array([
        np.cos(geodetic_lat) * np.cos(geodetic_lon),
        np.cos(geodetic_lat) * np.sin(geodetic_lon),
        np.sin(geodetic_lat),
    ])
    up_tirs = itrs_to_tirs(epoch).apply(up_itrs)
    return float(np.arcsin(up_tirs[2])), float(np.arctan2(up_tirs[1], up_tirs[0]))

offset_pointing(offset_magnitude, offset_direction, offset_boresight=0.0, ref_alt=np.pi / 2, ref_az=np.pi, ref_boresight=0.0)

Convert a pointing offset to an absolute (alt, az, boresight).

The position angle offset_direction uses geographic North/East as reference, which is well-defined at the zenith (over-the-top convention).

Parameters:

Name Type Description Default
offset_magnitude float

Angular offset magnitude from the reference pointing, in radians.

required
offset_direction float

Position angle of the offset in radians, measured from North (0) toward East (\(\pi/2\)).

required
offset_boresight float

Boresight roll in radians, relative to the polarisation-stabilised reference. offset_boresight=0 keeps the X/Y feed axes aligned with the reference orientation.

0.0
ref_alt float

Reference pointing altitude and azimuth in radians. Default: zenith (\(\pi/2\), \(\pi\)).

pi / 2
ref_az float

Reference pointing altitude and azimuth in radians. Default: zenith (\(\pi/2\), \(\pi\)).

pi / 2
ref_boresight float

Reference boresight in radians. Default: 0.

0.0

Returns:

Type Description
alt, az, boresight : tuple[float, float, float]

New pointing parameters in radians, suitable for :func:zenith_to_pointing or :meth:TIRSVoltageBeam.from_ludwig3.

Notes

The (alt, az) computation uses the spherical law of cosines on the triangle formed by the zenith P (alt = \(\pi/2\)), the reference pointing A, and the new pointing B. The sides are the co-altitudes \(b = \pi/2 - \text{ref\_alt}\) and \(a = \pi/2 - \text{alt}\), and the offset magnitude \(\rho\). The angle at A is the position angle \(\psi\) (offset_direction), measured from the great circle toward the zenith (North) toward increasing azimuth (East):

\[\sin(\mathrm{alt}) = \sin(\mathrm{ref\_alt})\cos\rho + \cos(\mathrm{ref\_alt})\sin\rho\cos\psi\]
\[\Delta\mathrm{az} = \mathrm{arctan2}\!\bigl(\sin\rho\sin\psi,\; \cos(\mathrm{ref\_alt})\cos\rho - \sin(\mathrm{ref\_alt})\sin\rho\cos\psi\bigr)\]

arctan2 handles the over-the-top case naturally.

Source code in src/serval/rotate.py
def offset_pointing(
    offset_magnitude: float,
    offset_direction: float,
    offset_boresight: float = 0.0,
    ref_alt: float = np.pi / 2,
    ref_az: float = np.pi,
    ref_boresight: float = 0.0,
) -> tuple[float, float, float]:
    r"""Convert a pointing offset to an absolute (alt, az, boresight).

    The position angle ``offset_direction`` uses geographic North/East as reference,
    which is well-defined at the zenith (over-the-top convention).

    Parameters
    ----------
    offset_magnitude : float
        Angular offset magnitude from the reference pointing, in radians.
    offset_direction : float
        Position angle of the offset in radians, measured from North (0) toward East ($\pi/2$).
    offset_boresight : float
        Boresight roll in radians, relative to the polarisation-stabilised reference.
        ``offset_boresight=0`` keeps the X/Y feed axes aligned with the reference orientation.
    ref_alt, ref_az : float
        Reference pointing altitude and azimuth in radians. Default: zenith ($\pi/2$, $\pi$).
    ref_boresight : float
        Reference boresight in radians. Default: 0.

    Returns
    -------
    alt, az, boresight : tuple[float, float, float]
        New pointing parameters in radians, suitable for :func:`zenith_to_pointing`
        or :meth:`TIRSVoltageBeam.from_ludwig3`.

    Notes
    -----
    The (alt, az) computation uses the spherical law of cosines on the triangle formed by
    the zenith **P** (alt = $\pi/2$), the reference pointing **A**, and the new pointing **B**.
    The sides are the co-altitudes $b = \pi/2 - \text{ref\_alt}$ and $a = \pi/2 - \text{alt}$,
    and the offset magnitude $\rho$. The angle at **A** is the position angle $\psi$
    (``offset_direction``), measured from the great circle toward the zenith (North) toward
    increasing azimuth (East):

    $$\sin(\mathrm{alt}) = \sin(\mathrm{ref\_alt})\cos\rho
    + \cos(\mathrm{ref\_alt})\sin\rho\cos\psi$$

    $$\Delta\mathrm{az} = \mathrm{arctan2}\!\bigl(\sin\rho\sin\psi,\;
        \cos(\mathrm{ref\_alt})\cos\rho - \sin(\mathrm{ref\_alt})\sin\rho\cos\psi\bigr)$$

    ``arctan2`` handles the over-the-top case naturally.
    """
    if offset_magnitude == 0.0:
        return ref_alt, ref_az, ref_boresight

    sin_alt = (
        np.sin(ref_alt) * np.cos(offset_magnitude)
        + np.cos(ref_alt) * np.sin(offset_magnitude) * np.cos(offset_direction)
    )
    alt = float(np.arcsin(np.clip(sin_alt, -1.0, 1.0)))
    daz = np.arctan2(
        np.sin(offset_magnitude) * np.sin(offset_direction),
        np.cos(ref_alt) * np.cos(offset_magnitude)
        - np.sin(ref_alt) * np.sin(offset_magnitude) * np.cos(offset_direction),
    )
    az = float((ref_az + daz + np.pi) % (2 * np.pi) - np.pi)
    boresight = (
        boresight_for_fixed_pol_ref(alt, az, ref_alt, ref_az, ref_boresight) + offset_boresight
    )
    return alt, az, float(boresight)

rotation_from_eulers(eulers, degrees=False)

Creates a rotation object from a set of Euler angles.

The convention used here and throughout is that of ZYZ passive (basis) rotations.

Parameters:

Name Type Description Default
eulers tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the rotation.

required
degrees bool

Whether the input angles are in degrees, by default False.

False

Returns:

Type Description
Rotation

The Rotation object.

Source code in src/serval/rotate.py
def rotation_from_eulers(
    eulers: EulersType,
    degrees: None | bool = False,
) -> Rotation:
    r"""Creates a rotation object from a set of Euler angles.

    The convention used here and throughout is that of
    ZYZ passive (basis) rotations.

    Parameters
    ----------
    eulers : tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the rotation.
    degrees : bool, optional
        Whether the input angles are in degrees, by default False.
    Returns
    -------
    Rotation
        The Rotation object.
    """
    # .inv() for passive convention
    return Rotation.from_euler("ZYZ", eulers, degrees=degrees).inv()

tirs_to_baseline_direc(latitude, longitude, east, north, up)

Creates a rotation object for basis rotations from the TIRS frame to a frame aligned with the direction of the baseline vector.

Parameters:

Name Type Description Default
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required
east float

The East component of the baseline vector.

required
north float

The North component of the baseline vector.

required
up float

The Up component of the baseline vector.

required

Returns:

Type Description
Rotation

A Rotation object representing the TIRS to baseline direction frame basis rotation.

Source code in src/serval/rotate.py
def tirs_to_baseline_direc(
    latitude: float, longitude: float, east: float, north: float, up: float
) -> Rotation:
    r"""Creates a rotation object for basis rotations from the TIRS frame to a
    frame aligned with the direction of the baseline vector.
    Parameters
    ----------
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    east : float
        The East component of the baseline vector.
    north : float
        The North component of the baseline vector.
    up : float
        The Up component of the baseline vector.
    Returns
    -------
    Rotation
        A Rotation object representing the TIRS to baseline direction frame basis rotation.
    """
    return zenith_to_baseline_direc(east=east, north=north, up=up) * tirs_to_zenith(
        latitude=latitude, longitude=longitude
    )

tirs_to_baseline_direc_eulers(latitude, longitude, east, north, up)

Returns the Euler angles for basis rotations from the TIRS frame to a frame aligned with the direction of the baseline vector.

Parameters:

Name Type Description Default
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required
east float

The East component of the baseline vector.

required
north float

The North component of the baseline vector.

required
up float

The Up component of the baseline vector.

required

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the TIRS to baseline frame basis rotation.

Source code in src/serval/rotate.py
def tirs_to_baseline_direc_eulers(
    latitude: float, longitude: float, east: float, north: float, up: float
) -> EulersType:
    r"""Returns the Euler angles for basis rotations from the TIRS frame to a
    frame aligned with the direction of the baseline vector.

    Parameters
    ----------
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    east : float
        The East component of the baseline vector.
    north : float
        The North component of the baseline vector.
    up : float
        The Up component of the baseline vector.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the TIRS to baseline frame
         basis rotation.
    """
    return eulers_from_rotation(
        tirs_to_baseline_direc(
            latitude=latitude, longitude=longitude, east=east, north=north, up=up
        )
    )

tirs_to_cirs(era)

Creates a rotation object corresponding to the basis rotation from the TIRS frame to the CIRS frame.

Parameters:

Name Type Description Default
era float

The Earth Rotation Angle in radians.

required

Returns:

Type Description
Rotation

A Rotation object representing the TIRS to CIRS basis rotation.

Source code in src/serval/rotate.py
def tirs_to_cirs(era: float) -> Rotation:
    r"""Creates a rotation object corresponding to the basis rotation
    from the TIRS frame to the CIRS frame.
    Parameters
    ----------
    era : float
        The Earth Rotation Angle in radians.
    Returns
    -------
    Rotation
        A Rotation object representing the TIRS to CIRS basis rotation.
    """
    return rotation_from_eulers(tirs_to_cirs_eulers(era))

tirs_to_cirs_eulers(era)

Provides the Euler angles corresponding to the basis rotation from the TIRS frame to the CIRS frame.

The convention used here and throughout is that of ZYZ passive (basis) rotations.

Parameters:

Name Type Description Default
era float

The Earth Rotation Angle in radians.

required

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the TIRS to CIRS basis rotation.

Source code in src/serval/rotate.py
def tirs_to_cirs_eulers(era: float) -> EulersType:
    r"""Provides the Euler angles corresponding to the basis rotation
    from the TIRS frame to the CIRS frame.

    The convention used here and throughout is that of
    ZYZ passive (basis) rotations.

    Parameters
    ----------
    era : float
        The Earth Rotation Angle in radians.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the TIRS to
        CIRS basis rotation.
    """
    return (-era, 0, 0)  # See USNO Circular 179

tirs_to_pointing(latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0)

Creates a rotation object for basis rotations from the TIRS frame to a frame aligned with the telescope pointing direction.

Parameters:

Name Type Description Default
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required
altitude float

The altitude angle in radians, by default np.pi / 2.

pi / 2
azimuth float

The azimuth angle in radians, by default np.pi.

pi
boresight float

The boresight angle in radians, by default 0.0.

0.0

Returns:

Type Description
Rotation

A Rotation object representing the TIRS to telescope pointing frame basis rotation.

Source code in src/serval/rotate.py
def tirs_to_pointing(
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
) -> Rotation:
    r"""Creates a rotation object for basis rotations from the TIRS frame to a frame aligned with
    the telescope pointing direction.
    Parameters
    ----------
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    altitude : float, optional
        The altitude angle in radians, by default np.pi / 2.
    azimuth : float, optional
        The azimuth angle in radians, by default np.pi.
    boresight : float, optional
        The boresight angle in radians, by default 0.0.
    Returns
    -------
    Rotation
        A Rotation object representing the TIRS to telescope pointing frame basis rotation.
    """
    return zenith_to_pointing(
        altitude=altitude, azimuth=azimuth, boresight=boresight
    ) * tirs_to_zenith(latitude=latitude, longitude=longitude)

tirs_to_pointing_eulers(latitude, longitude, altitude=np.pi / 2, azimuth=np.pi, boresight=0.0)

Returns the Euler angles for basis rotations from the TIRS frame to a frame aligned with the telescope pointing direction.

The convention used here and throughout is that of ZYZ passive (basis) rotations.

Parameters:

Name Type Description Default
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required
altitude float

The altitude angle in radians, by default np.pi / 2.

pi / 2
azimuth float

The azimuth angle in radians, by default np.pi.

pi
boresight float

The boresight angle in radians, by default 0.0.

0.0

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the TIRS to telescope pointing basis rotation.

Source code in src/serval/rotate.py
def tirs_to_pointing_eulers(
    latitude: float,
    longitude: float,
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
) -> EulersType:
    r"""Returns the Euler angles for basis rotations from the TIRS frame to a frame aligned
    with the telescope pointing direction.

    The convention used here and throughout is that of
    ZYZ passive (basis) rotations.

    Parameters
    ----------
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    altitude : float, optional
        The altitude angle in radians, by default np.pi / 2.
    azimuth : float, optional
        The azimuth angle in radians, by default np.pi.
    boresight : float, optional
        The boresight angle in radians, by default 0.0.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the TIRS to telescope pointing
        basis rotation.
    """
    return eulers_from_rotation(
        tirs_to_pointing(
            latitude=latitude,
            longitude=longitude,
            altitude=altitude,
            azimuth=azimuth,
            boresight=boresight,
        )
    )

tirs_to_zenith(latitude, longitude)

Creates a rotation object for basis rotations from the TIRS frame to the local Zenith frame for an observer at a given latitude and longitude.

Parameters:

Name Type Description Default
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required

Returns:

Type Description
Rotation

A Rotation object representing the TIRS to local Zenith basis rotation.

Source code in src/serval/rotate.py
def tirs_to_zenith(latitude: float, longitude: float) -> Rotation:
    r"""Creates a rotation object for basis rotations from the TIRS frame to the local Zenith frame
    for an observer at a given latitude and longitude.

    Parameters
    ----------
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    Returns
    -------
    Rotation
        A Rotation object representing the TIRS to local Zenith basis rotation.
    """
    return rotation_from_eulers(
        tirs_to_zenith_eulers(latitude=latitude, longitude=longitude),
    )

tirs_to_zenith_eulers(latitude, longitude)

Returns the Euler angles for basis rotations from the TIRS frame to the local Zenith frame for an observer at a given latitude and longitude.

The convention used here and throughout is that of ZYZ passive (basis) rotations.

Parameters:

Name Type Description Default
latitude float

The latitude in radians.

required
longitude float

The longitude in radians.

required

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the TIRS to local Zenith basis rotation.

Source code in src/serval/rotate.py
def tirs_to_zenith_eulers(latitude: float, longitude: float) -> EulersType:
    r"""Returns the Euler angles for basis rotations from the TIRS frame to the local Zenith frame
    for an observer at a given latitude and longitude.

    The convention used here and throughout is that of
    ZYZ passive (basis) rotations.

    Parameters
    ----------
    latitude : float
        The latitude in radians.
    longitude : float
        The longitude in radians.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the TIRS to local Zenith
        basis rotation.
    """
    return (longitude, np.pi / 2 - latitude, 0.0)

wigner_d(lmax, mprime_max, eulers_or_rotation)

Generates Wigner-D matrices up to a given lmax for specified Euler angles or a Rotation object.

Parameters:

Name Type Description Default
lmax int

The maximum degree of the Wigner-D matrices.

required
mprime_max int

The maximum order of m' for the Wigner-D matrices.

required
eulers_or_rotation tuple[float, float, float] | Rotation

The Euler angles (alpha, beta, gamma) specifying the rotation, or a Rotation object.

required

Returns:

Type Description
NDArray[complex128]

An array of shape (lmax+1, 2*mprime_max+1, 2*lmax+1) containing the Wigner-D matrices.

Source code in src/serval/rotate.py
def wigner_d(
    lmax: int,
    mprime_max: int,
    eulers_or_rotation: EulersType | Rotation,
) -> npt.NDArray[np.complex128]:
    r"""Generates Wigner-D matrices up to a given lmax for specified Euler angles or
    a Rotation object.

    Parameters
    ----------
    lmax : int
        The maximum degree of the Wigner-D matrices.
    mprime_max : int
        The maximum order of m' for the Wigner-D matrices.
    eulers_or_rotation : tuple[float, float, float] | Rotation
        The Euler angles (alpha, beta, gamma) specifying the rotation,
        or a Rotation object.

    Returns
    -------
    npt.NDArray[np.complex128]
        An array of shape ``(lmax+1, 2*mprime_max+1, 2*lmax+1)`` containing the
        Wigner-D matrices.
    """
    if isinstance(eulers_or_rotation, Rotation):
        eulers = eulers_from_rotation(eulers_or_rotation)
    else:
        eulers = eulers_or_rotation

    # One-hot-dot reconstruction of wigner matrix through per-mprime transforming.
    # To rotate, you contract on the first axis of the wigner-d matrix.
    # Allocate the final output shape directly to avoid large intermediates and
    # concatenation copies. Axes: (l, mprime, m) with mprime in [-mprime_max, mprime_max]
    # and m in [-lmax, lmax].
    out = empty_complex_array((lmax + 1, 2 * mprime_max + 1, 2 * lmax + 1))
    # Precomputed sign vector for the symmetry fill: (-1)^(mprime - m) for each m column.
    # D^l_{-m', m} = (-1)^{m'-m} * conj(D^l_{m', -m})
    m_vals = np.arange(-lmax, lmax + 1)
    one_hot = pysh.SHCoeffs.from_zeros(lmax=lmax, kind="complex", **SHT_CONVENTIONS)
    for mprime in range(0, mprime_max + 1):
        one_hot.coeffs[:] = 0.0
        one_hot.coeffs[0, mprime:, mprime] = 1.0
        rotted = one_hot.rotate(*eulers, **ROTATE_CONVENTIONS)
        # rotted.coeffs shape: (2, lmax+1, lmax+1) = (sign_m, l, |m|)
        # Fill positive-m columns (m=0..lmax → output columns lmax..2*lmax)
        out[:, mprime_max + mprime, lmax:] = rotted.coeffs[0]
        # Fill negative-m columns (m=-lmax..-1 → output columns 0..lmax-1)
        out[:, mprime_max + mprime, :lmax] = rotted.coeffs[1, :, lmax:0:-1]
        # Fill negative-mprime row via symmetry
        if mprime > 0:
            sign_factor = (-1.0) ** (mprime - m_vals)
            out[:, mprime_max - mprime, :] = sign_factor * out[:, mprime_max + mprime, ::-1].conj()

    return out

zenith_to_baseline_direc(east, north, up)

Creates a rotation object for basis rotations from the local Zenith frame to a frame aligned with the direction of the baseline vector.

Parameters:

Name Type Description Default
east float

The East component of the baseline vector.

required
north float

The North component of the baseline vector.

required
up float

The Up component of the baseline vector.

required

Returns:

Type Description
Rotation

A Rotation object representing the local Zenith to baseline direction frame basis rotation.

Source code in src/serval/rotate.py
def zenith_to_baseline_direc(east: float, north: float, up: float) -> Rotation:
    r"""Creates a rotation object for basis rotations from the local Zenith frame
    to a frame aligned with the direction of the baseline vector.

    Parameters
    ----------
    east : float
        The East component of the baseline vector.
    north : float
        The North component of the baseline vector.
    up : float
        The Up component of the baseline vector.
    Returns
    -------
    Rotation
        A Rotation object representing the local Zenith to baseline direction
        frame basis rotation.
    """
    return rotation_from_eulers(
        zenith_to_baseline_direc_eulers(east=east, north=north, up=up),
    )

zenith_to_baseline_direc_eulers(east, north, up)

Returns the Euler angles for basis rotations from the local Zenith frame to a frame aligned with the direction of the baseline vector.

The convention used here and throughout is that of ZYZ passive (basis) rotations.

Parameters:

Name Type Description Default
east float

The East component of the baseline vector.

required
north float

The North component of the baseline vector.

required
up float

The Up component of the baseline vector.

required

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the local Zenith to baseline direction frame basis rotation.

Source code in src/serval/rotate.py
def zenith_to_baseline_direc_eulers(east: float, north: float, up: float) -> EulersType:
    r"""Returns the Euler angles for basis rotations from the local Zenith frame
    to a frame aligned with the direction of the baseline vector.

    The convention used here and throughout is that of
    ZYZ passive (basis) rotations.

    Parameters
    ----------
    east : float
        The East component of the baseline vector.
    north : float
        The North component of the baseline vector.
    up : float
        The Up component of the baseline vector.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the local Zenith to baseline direction
        frame basis rotation.
    """
    norm = np.linalg.norm([east, north, up])
    if norm == 0.0:
        return (0.0, 0.0, 0.0)
    else:
        return (
            -np.arctan2(east / norm, north / norm),
            np.arcsin(up / norm) - np.pi / 2,
            0.0,
        )

zenith_to_pointing(altitude=np.pi / 2, azimuth=np.pi, boresight=0.0)

Creates a rotation object from the local Zenith frame to the to a frame aligned with the telescope pointing direction.

Parameters:

Name Type Description Default
altitude float

The altitude angle in radians , by default np.pi / 2.

pi / 2
azimuth float

The azimuth angle in radians, by default np.pi.

pi
boresight float

The boresight angle in radians, by default 0.

0.0

Returns:

Type Description
Rotation

A Rotation object representing the local Zenith to telescope pointing frame basis rotation.

Source code in src/serval/rotate.py
def zenith_to_pointing(
    altitude: float = np.pi / 2,
    azimuth: float = np.pi,
    boresight: float = 0.0,
) -> Rotation:
    r"""Creates a rotation object from the local Zenith frame to the to a frame aligned with
    the telescope pointing direction.
    Parameters
    ----------
    altitude : float, optional
        The altitude angle in radians , by default np.pi / 2.
    azimuth : float, optional
        The azimuth angle in radians, by default np.pi.
    boresight : float, optional
        The boresight angle in radians, by default 0.
    Returns
    -------
    Rotation
        A Rotation object representing the local Zenith to telescope pointing frame basis rotation.
    """
    return rotation_from_eulers(
        zenith_to_pointing_eulers(altitude=altitude, azimuth=azimuth, boresight=boresight),
    )

zenith_to_pointing_eulers(altitude=None, azimuth=None, boresight=None)

Returns the Euler angles for basis rotations from the local Zenith frame to a frame aligned with the telescope pointing direction.

The convention used here and throughout is that of ZYZ passive (basis) rotations.

Parameters:

Name Type Description Default
altitude float

The altitude angle in radians, by default np.pi / 2.

None
azimuth float

The azimuth angle in radians, by default np.pi.

None
boresight float

The boresight angle in radians, by default 0.0.

None

Returns:

Type Description
tuple[float, float, float]

The Euler angles (alpha, beta, gamma) specifying the local Zenith to pointing frame basis rotation.

Source code in src/serval/rotate.py
def zenith_to_pointing_eulers(
    altitude: float | None = None,
    azimuth: float | None = None,
    boresight: float | None = None,
) -> EulersType:
    r"""Returns the Euler angles for basis rotations from the local Zenith frame
    to a frame aligned with the telescope pointing direction.

    The convention used here and throughout is that of
    ZYZ passive (basis) rotations.

    Parameters
    ----------
    altitude : float, optional
        The altitude angle in radians, by default np.pi / 2.
    azimuth : float, optional
        The azimuth angle in radians, by default np.pi.
    boresight : float, optional
        The boresight angle in radians, by default 0.0.
    Returns
    -------
    tuple[float, float, float]
        The Euler angles (alpha, beta, gamma) specifying the local Zenith to pointing
        frame basis rotation.
    """
    altitude = np.pi / 2 if altitude is None else altitude  # Default
    azimuth = np.pi if azimuth is None else azimuth  # Default
    boresight = 0.0 if boresight is None else boresight  # Default
    return (-azimuth, altitude - np.pi / 2, boresight)

Spherical Harmonic Transform Utilities (serval.sht.py)

alm_from_healpix(healpix_alm)

Transforms spherical harmonics coefficients from healpy format (1D) to standard Fourier order format (2D).

Parameters:

Name Type Description Default
healpix_alm NDArray[complex128]

A spherical harmonics coefficients array in healpy format (1D).

required

Returns:

Type Description
NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order (2D), in dense form.

Source code in src/serval/sht.py
def alm_from_healpix(healpix_alm: npt.NDArray[np.complex128]) -> npt.NDArray[np.complex128]:
    r"""Transforms spherical harmonics coefficients from healpy format (1D)
    to standard Fourier order format (2D).

    Parameters
    ----------
    healpix_alm : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in healpy format (1D).

    Returns
    -------
    npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order (2D),
        in dense form.
    """

    lmax = get_lmax_from_1D_alm(healpix_alm.size)
    ell, m = get_alm_ls_ms(lmax)
    # healpy includes the Condon-Shortley phase (-1)^m in Y_lm for m > 0,
    # but pyshtools with csphase=1 excludes it.  Apply the sign correction
    # so the stored alm are in pyshtools convention.
    cs_sign = (-1) ** m
    fourrier_alm = empty_complex_array((lmax + 1, 2 * lmax + 1))
    fourrier_alm[ell, m + lmax] = cs_sign * healpix_alm
    # Fill in the redundant negative-m part using pyshtools reality condition:
    # a_{l,-m} = (-1)^m * conj(a_{l,m})  →  conj(healpix_alm[m>0]) (no extra sign).
    fourrier_alm[ell[m > 0], lmax - m[m > 0]] = healpix_alm[m > 0].conj()
    return fourrier_alm

analysis(grid, threshold=SPARSE_THRESHOLD, conjugate=False)

Transforms a pyshtools grid to the corresponding spherical harmonics coefficients or their conjugates in standard Fourier order.

Parameters:

Name Type Description Default
grid SHGrid

A spherical harmonic grid object.

required
threshold float

The threshold below which coefficients are set to zero.

SPARSE_THRESHOLD
conjugate bool

Whether to return the conjugate spherical harmonics coefficients.

False

Returns:

Type Description
NDArray[complex128]

The spherical harmonics coefficients array or their conjugates in standard Fourier order, in dense form.

Source code in src/serval/sht.py
def analysis(
    grid: pysh.SHGrid,
    threshold: float = SPARSE_THRESHOLD,
    conjugate: bool = False,
) -> npt.NDArray[np.complex128]:
    r"""Transforms a pyshtools grid to the corresponding spherical harmonics coefficients or their
    conjugates in standard Fourier order.

    Parameters
    ----------
    grid : pysh.SHGrid
        A spherical harmonic grid object.
    threshold : float, optional
        The threshold below which coefficients are set to zero.
    conjugate : bool, optional
        Whether to return the conjugate spherical harmonics coefficients.

    Returns
    -------
    npt.NDArray[np.complex128]
        The spherical harmonics coefficients array or their conjugates in standard Fourier order,
        in dense form.
    """
    coeffs = grid.expand(**SHT_CONVENTIONS)
    alm = stack_ms(coeffs.coeffs)
    filtered_alm = filter_coeffs(alm, threshold=threshold)
    out = (make_conjugate(filtered_alm) if conjugate else filtered_alm)
    return out

array_synthesis(array_coeff)

Transfomrs spherical harmonics coefficients from a pyshtools array to a pyshtools grid.

Parameters:

Name Type Description Default
array_coeff SHCoeffs | NDArray[complex128]

A spherical harmonics coefficients array in pytshtools format. The array can be a pysh.SHCoeffs object or a stacked array. Its shape should be (2, lmax + 1, mmax + 1) or (lmax + 1, 2 * mmax + 1) respectively.

required

Returns:

Type Description
SHGrid

A spherical harmonic grid object.

Source code in src/serval/sht.py
def array_synthesis(array_coeff: pysh.SHCoeffs | npt.NDArray[np.complex128]) -> pysh.SHGrid:
    r"""Transfomrs spherical harmonics coefficients from a pyshtools array to a pyshtools grid.

    Parameters
    ----------
    array_coeff : pysh.SHCoeffs | npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in pytshtools format.
        The array can be a pysh.SHCoeffs object or a stacked array. Its shape should be
        (2, lmax + 1, mmax + 1) or (lmax + 1, 2 * mmax + 1) respectively.

    Returns
    -------
    pysh.SHGrid
        A spherical harmonic grid object.
    """
    if isinstance(array_coeff, pysh.SHCoeffs):
        return array_coeff.expand(grid=GRID_CONVENTIONS["grid"], extend=GRID_CONVENTIONS["extend"])
    return pysh.SHCoeffs.from_array(unstack_ms(array_coeff), **SHT_CONVENTIONS).expand(
        grid=GRID_CONVENTIONS["grid"], extend=GRID_CONVENTIONS["extend"]
    )

batch_array_analysis(map_batch, lmax)

Batch-analyze complex DH grids to SERVAL alm arrays via pyshtools.

Parameters:

Name Type Description Default
map_batch NDArray[complex128]

Complex grid data of shape (..., ntheta, nphi_ring) where ntheta = nphi_ring = 2*(lmax+1).

required
lmax int

Maximum spherical harmonic degree.

required

Returns:

Type Description
NDArray[complex128]

SERVAL alm array of shape (..., lmax+1, 2*lmax+1).

Source code in src/serval/sht.py
def batch_array_analysis(
    map_batch: npt.NDArray[np.complex128],
    lmax: int,
) -> npt.NDArray[np.complex128]:
    r"""Batch-analyze complex DH grids to SERVAL alm arrays via pyshtools.

    Parameters
    ----------
    map_batch : npt.NDArray[np.complex128]
        Complex grid data of shape ``(..., ntheta, nphi_ring)`` where
        ``ntheta = nphi_ring = 2*(lmax+1)``.
    lmax : int
        Maximum spherical harmonic degree.

    Returns
    -------
    npt.NDArray[np.complex128]
        SERVAL alm array of shape ``(..., lmax+1, 2*lmax+1)``.
    """
    batch_shape = map_batch.shape[:-2]
    n = int(np.prod(batch_shape))
    maps_flat = map_batch.reshape(n, *map_batch.shape[-2:])
    template = grid_template(lmax)
    alms = np.empty((n, lmax + 1, 2 * lmax + 1), dtype=np.complex128)
    for i in range(n):
        g = template.copy()
        g.data = maps_flat[i]
        alms[i] = analysis(g)
    return alms.reshape(*batch_shape, lmax + 1, 2 * lmax + 1)

batch_array_synthesis(alm_batch, lmax, nthreads=0)

Batch-synthesize complex SH alm arrays to complex DH grids via ducc0.

Uses ducc0.sht.experimental.synthesis with ntrans=2n (real and imaginary components stacked) to replace n separate pyshtools synthesis calls.

Parameters:

Name Type Description Default
alm_batch NDArray[complex128]

SERVAL alm array of shape (..., lmax+1, 2*lmax+1), already band-limited to lmax in both degree and order.

required
lmax int

Maximum spherical harmonic degree.

required
nthreads int

Number of threads for ducc0 (0 = all hardware threads).

0

Returns:

Type Description
NDArray[complex128]

Complex grid data of shape (..., ntheta, nphi_ring) where ntheta = nphi_ring = 2*(lmax+1).

Source code in src/serval/sht.py
def batch_array_synthesis(
    alm_batch: npt.NDArray[np.complex128],
    lmax: int,
    nthreads: int = 0,
) -> npt.NDArray[np.complex128]:
    r"""Batch-synthesize complex SH alm arrays to complex DH grids via ducc0.

    Uses ``ducc0.sht.experimental.synthesis`` with ``ntrans=2n`` (real and imaginary
    components stacked) to replace ``n`` separate pyshtools synthesis calls.

    Parameters
    ----------
    alm_batch : npt.NDArray[np.complex128]
        SERVAL alm array of shape ``(..., lmax+1, 2*lmax+1)``, already band-limited
        to ``lmax`` in both degree and order.
    lmax : int
        Maximum spherical harmonic degree.
    nthreads : int
        Number of threads for ducc0 (0 = all hardware threads).

    Returns
    -------
    npt.NDArray[np.complex128]
        Complex grid data of shape ``(..., ntheta, nphi_ring)`` where
        ``ntheta = nphi_ring = 2*(lmax+1)``.
    """
    batch_shape = alm_batch.shape[:-2]
    n = int(np.prod(batch_shape))
    alm_flat = alm_batch.reshape(n, *alm_batch.shape[-2:])           # (n, lmax+1, 2*lmax+1)
    theta, nphi, phi0, ringstart, ntheta, nphi_ring = _dh_grid_params(lmax)
    aR, aI = serval_to_ducc0_alm(alm_flat, lmax, lmax)              # (n, ncoeff) each
    alm_stack = np.concatenate([aR, aI], axis=0)[:, np.newaxis, :]  # (2n, 1, ncoeff)
    maps = ducc0.sht.experimental.synthesis(
        alm=alm_stack,
        theta=theta,
        nphi=nphi,
        phi0=phi0,
        ringstart=ringstart,
        lmax=lmax,
        spin=0,
        nthreads=nthreads,
    )  # (2n, 1, ntheta*nphi_ring)
    maps = maps[:, 0, :].reshape(2 * n, ntheta, nphi_ring)
    return (maps[:n] + 1j * maps[n:]).reshape(*batch_shape, ntheta, nphi_ring)

broadcast_bandlimits(*coeffs, lmax=None)

Broadcasts a set of spherical harmonics coefficients to common bandlimits. These are either given or inferred from the maximum lmax among the inputs.

Parameters:

Name Type Description Default
*coeffs NDArray[complex128]

A variable set of spherical harmonics coefficients arrays in standard Fourier order. The shape or array i in the set should be (lmax_i + 1, 2 * mmax_i + 1) for each i.

()
lmax int | None

The target maximum spherical harmonic degree. If None, it is inferred from the maximum lmax among the input coefficients.

None

Returns:

Type Description
tuple[NDArray[complex128], ...]

A tuple containing the input spherical harmonics coefficients arrays reshaped to the common target bandlimits.

Source code in src/serval/sht.py
def broadcast_bandlimits(
    *coeffs: npt.NDArray[np.complex128], lmax: int | None = None
) -> tuple[npt.NDArray[np.complex128], ...]:
    r"""Broadcasts a set of spherical harmonics coefficients to common bandlimits. These are either
    given or inferred from the maximum lmax among the inputs.

    Parameters
    ----------
    *coeffs : npt.NDArray[np.complex128]
        A variable set of spherical harmonics coefficients arrays
        in standard Fourier order. The shape or array i in the set should be
        (lmax_i + 1, 2 * mmax_i + 1) for each i.
    lmax : int | None, optional
        The target maximum spherical harmonic degree. If None, it is inferred from the maximum
        lmax among the input coefficients.

    Returns
    -------
    tuple[npt.NDArray[np.complex128], ...]
        A tuple containing the input spherical harmonics coefficients arrays reshaped to the
        common target bandlimits.
    """
    if lmax is None:
        target_lmax = max([coeff.shape[0] - 1 for coeff in coeffs])
    else:
        target_lmax = lmax
    return tuple(set_bandlimits(coeff, lmax=target_lmax) for coeff in coeffs)

coeffs_from_array(array_coeffs, limited=False, lmax=None)

Transfomrs spherical harmonics coefficients from an array in standard Fourier order to a pyshtools SHCoeffs object. There is the option to set bandlimits.

Parameters:

Name Type Description Default
array_coeffs NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. Its shape should be (lmax + 1, 2 * mmax + 1).

required
limited bool

Whether to limit the pyshtools object to the inferred or specified lmax.

False
lmax int | None

The target maximum spherical harmonic degree. If None, it is inferred from the input coefficients lmax.

None

Returns:

Type Description
SHCoeffs

A spherical harmonics coefficients object in pyshtools format. Its shape will be (2, lmax + 1, mmax + 1).

Source code in src/serval/sht.py
def coeffs_from_array(
    array_coeffs: npt.NDArray[np.complex128], limited: bool = False, lmax: int | None = None
) -> pysh.SHCoeffs:
    r"""Transfomrs spherical harmonics coefficients from an array in standard Fourier order
    to a pyshtools SHCoeffs object. There is the option to set bandlimits.

    Parameters
    ----------
    array_coeffs : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order.
        Its shape should be (lmax + 1, 2 * mmax + 1).
    limited: bool
        Whether to limit the pyshtools object to the inferred or specified lmax.
    lmax : int | None, optional
        The target maximum spherical harmonic degree. If None, it is inferred from the input
        coefficients lmax.

    Returns
    -------
    pysh.SHCoeffs
        A spherical harmonics coefficients object in pyshtools format. Its shape
        will be (2, lmax + 1, mmax + 1).
    """
    if not limited and lmax is not None:
        raise ValueError("lmax is only used when limited=True")
    if limited:
        lmax = (array_coeffs.shape[0] - 1 if lmax is None else lmax)
        array_coeffs = set_bandlimits(array_coeffs, lmax=lmax)
    return pysh.SHCoeffs.from_array(unstack_ms(array_coeffs), **SHT_CONVENTIONS)

compute_mmodes(coeffs1, coeffs2)

Computes the mmodes between two spherical harmonics coefficients arrays. The relative formula is given by:

\[ \mathrm{mmodes}[m] = \sum_{\ell=0}^{l_{\max}} \mathrm{coeffs1}[\ell, m]\, \mathrm{coeffs2}[\ell, m] \]

Parameters:

Name Type Description Default
coeffs1 NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. Its shape should be (lmax1 + 1, 2 * mmax1 + 1).

required
coeffs2 NDArray[complex128]

A sspherical harmonics coefficients array in standard Fourier order. Its shape should be (lmax2 + 1, 2 * mmax2 + 1).

required

Returns:

Type Description
NDArray[complex128]

A spherical harmonics mmodes array resulting from the inner product of the input coefficients. Its shape will be (2 * max(mmax1, mmax2) + 1,).

Source code in src/serval/sht.py
def compute_mmodes(
    coeffs1: npt.NDArray[np.complex128],
    coeffs2: npt.NDArray[np.complex128],
) -> npt.NDArray[np.complex128]:
    r"""Computes the mmodes between two spherical harmonics coefficients arrays.
        The relative formula is given by:

    $$
    \mathrm{mmodes}[m] = \sum_{\ell=0}^{l_{\max}}
        \mathrm{coeffs1}[\ell, m]\,
        \mathrm{coeffs2}[\ell, m]
    $$

    Parameters
    ----------
    coeffs1 : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order.
        Its shape should be (lmax1 + 1, 2 * mmax1 + 1).
    coeffs2 : npt.NDArray[np.complex128]
        A sspherical harmonics coefficients array in standard Fourier order.
        Its shape should be (lmax2 + 1, 2 * mmax2 + 1).

    Returns
    -------
    npt.NDArray[np.complex128]
        A spherical harmonics mmodes array resulting from the inner product
        of the input coefficients. Its shape will be (2 * max(mmax1, mmax2) + 1,).
    """
    expanded_coeffs1, expanded_coeffs2 = broadcast_bandlimits(coeffs1, coeffs2)
    mmodes = np.sum(expanded_coeffs1 * expanded_coeffs2, axis=0)
    return mmodes

ducc0_to_serval_alm(aR, aI, lmax)

Convert the (aR, aI) pair from ducc0.sht.rotate_alm back to SERVAL format.

This is the inverse of :func:serval_to_ducc0_alm. The output always has mmax = lmax because a rotation generically populates all m-modes.

The reconstruction follows:

\[c_{\ell,m} = (-1)^m (a_{R,\ell m} + i\,a_{I,\ell m}) \quad (m > 0)\]
\[c_{\ell,-m} = \mathrm{Re}(a_{R,\ell m}) + \mathrm{Im}(a_{I,\ell m}) + i\bigl(\mathrm{Re}(a_{I,\ell m}) - \mathrm{Im}(a_{R,\ell m})\bigr) \quad (m > 0)\]
\[c_{\ell,0} = \mathrm{Re}(a_{R,\ell 0}) + i\,\mathrm{Re}(a_{I,\ell 0})\]

Parameters:

Name Type Description Default
aR NDArray[complex128]

Arrays of shape (..., ncoeff) where ncoeff = (lmax+1)*(lmax+2)//2, as returned by ducc0.sht.rotate_alm.

required
aI NDArray[complex128]

Arrays of shape (..., ncoeff) where ncoeff = (lmax+1)*(lmax+2)//2, as returned by ducc0.sht.rotate_alm.

required
lmax int

Maximum degree. Output mmax equals lmax.

required

Returns:

Type Description
NDArray[complex128]

SERVAL alm of shape (..., lmax+1, 2*lmax+1).

Source code in src/serval/sht.py
def ducc0_to_serval_alm(
    aR: npt.NDArray[np.complex128],
    aI: npt.NDArray[np.complex128],
    lmax: int,
) -> npt.NDArray[np.complex128]:
    r"""Convert the ``(aR, aI)`` pair from ``ducc0.sht.rotate_alm`` back to SERVAL format.

    This is the inverse of :func:`serval_to_ducc0_alm`.  The output always has
    ``mmax = lmax`` because a rotation generically populates all m-modes.

    The reconstruction follows:

    $$c_{\ell,m} = (-1)^m (a_{R,\ell m} + i\,a_{I,\ell m}) \quad (m > 0)$$

    $$c_{\ell,-m} = \mathrm{Re}(a_{R,\ell m}) + \mathrm{Im}(a_{I,\ell m})
                   + i\bigl(\mathrm{Re}(a_{I,\ell m}) - \mathrm{Im}(a_{R,\ell m})\bigr)
                   \quad (m > 0)$$

    $$c_{\ell,0} = \mathrm{Re}(a_{R,\ell 0}) + i\,\mathrm{Re}(a_{I,\ell 0})$$

    Parameters
    ----------
    aR, aI : npt.NDArray[np.complex128]
        Arrays of shape ``(..., ncoeff)`` where ``ncoeff = (lmax+1)*(lmax+2)//2``,
        as returned by ``ducc0.sht.rotate_alm``.
    lmax : int
        Maximum degree.  Output ``mmax`` equals ``lmax``.

    Returns
    -------
    npt.NDArray[np.complex128]
        SERVAL alm of shape ``(..., lmax+1, 2*lmax+1)``.
    """
    ls_arr, ms_arr, m0_mask, mpos_mask = _ducc0_triangular_indices(lmax)
    sign_mpos = (-1.0) ** ms_arr[mpos_mask]

    batch_shape = aR.shape[:-1]
    out = empty_complex_array((*batch_shape, lmax + 1, 2 * lmax + 1))

    # m=0
    out[..., ls_arr[m0_mask], lmax] = aR[..., m0_mask].real + 1j * aI[..., m0_mask].real

    # m>0: positive-m columns
    out[..., ls_arr[mpos_mask], lmax + ms_arr[mpos_mask]] = sign_mpos * (
        aR[..., mpos_mask] + 1j * aI[..., mpos_mask]
    )

    # m>0: negative-m columns
    out[..., ls_arr[mpos_mask], lmax - ms_arr[mpos_mask]] = (
        aR[..., mpos_mask].real + aI[..., mpos_mask].imag
    ) + 1j * (aI[..., mpos_mask].real - aR[..., mpos_mask].imag)

    return out

filter_coeffs(coeffs, threshold=SPARSE_THRESHOLD)

Filters spherical harmonics coefficients in standard Fourier order.

Parameters:

Name Type Description Default
coeffs NDArray[complex128]

A spherical harmonics coefficients object in standard Fourier order. Its shape should be (lmax + 1, 2 * mmax + 1).

required
threshold float

The threshold below which coefficients are set to zero.

SPARSE_THRESHOLD

Returns:

Type Description
NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order.

Source code in src/serval/sht.py
def filter_coeffs(
    coeffs: npt.NDArray[np.complex128], threshold: float = SPARSE_THRESHOLD
) -> npt.NDArray[np.complex128]:
    r"""Filters spherical harmonics coefficients in standard Fourier order.

    Parameters
    ----------
    coeffs : npt.NDArray[np.complex128]
        A spherical harmonics coefficients object in standard Fourier order. Its shape
        should be (lmax + 1, 2 * mmax + 1).
    threshold : float, optional
        The threshold below which coefficients are set to zero.

    Returns
    -------
    npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order.
    """
    return np.where(np.abs(coeffs) < threshold, 0.0, coeffs)

grid_template(lmax)

Creates a spherical harmonic grid object of degree lmax filled with complex zeros.

Parameters:

Name Type Description Default
lmax int

The maximum spherical harmonic degree of the grid.

required

Returns:

Type Description
SHGrid

A spherical harmonic grid object with data set to (complex) zeros.

Source code in src/serval/sht.py
def grid_template(lmax: int) -> pysh.SHGrid:
    r"""Creates a spherical harmonic grid object of degree lmax filled with complex zeros.

    Parameters
    ----------
    lmax : int
        The maximum spherical harmonic degree of the grid.

    Returns
    -------
    pysh.SHGrid
        A spherical harmonic grid object with data set to (complex) zeros.
    """
    return pysh.SHGrid.from_zeros(lmax=lmax, kind="complex", **GRID_CONVENTIONS)

healpix_from_alm(serval_alm)

Transforms spherical harmonics coefficients from standard Fourier order format (2D) to healpy format (1D). This is the inverse of :func:alm_from_healpix.

Parameters:

Name Type Description Default
serval_alm NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order (2D), shape (lmax + 1, 2 * lmax + 1).

required

Returns:

Type Description
NDArray[complex128]

A spherical harmonics coefficients array in healpy format (1D), containing only the m >= 0 entries with the Condon-Shortley phase included.

Source code in src/serval/sht.py
def healpix_from_alm(
    serval_alm: npt.NDArray[np.complex128],
) -> npt.NDArray[np.complex128]:
    r"""Transforms spherical harmonics coefficients from standard Fourier order format (2D)
    to healpy format (1D).  This is the inverse of :func:`alm_from_healpix`.

    Parameters
    ----------
    serval_alm : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order (2D),
        shape ``(lmax + 1, 2 * lmax + 1)``.

    Returns
    -------
    npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in healpy format (1D), containing
        only the m >= 0 entries with the Condon-Shortley phase included.
    """

    lmax = serval_alm.shape[0] - 1
    ell, m = get_alm_ls_ms(lmax)
    # pyshtools with csphase=1 excludes (-1)^m; healpy includes it for m > 0.
    # Multiply by (-1)^m to restore the healpy convention.
    cs_sign = (-1) ** m
    return cs_sign * serval_alm[ell, m + lmax]

make_conjugate(coeff)

Transforms spherical harmonics coefficients to their conjugate form.

Parameters:

Name Type Description Default
coeff NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. Its shape should be (lmax + 1, 2 * mmax + 1).

required

Returns:

Type Description
NDArray[complex128]

The conjugate spherical harmonics coefficients array in standard Fourier order, in dense form.

Source code in src/serval/sht.py
def make_conjugate(coeff: npt.NDArray[np.complex128]) -> npt.NDArray[np.complex128]:
    r"""Transforms spherical harmonics coefficients to their conjugate form.

    Parameters
    ----------
    coeff : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order.
        Its shape should be (lmax + 1, 2 * mmax + 1).

    Returns
    -------
    npt.NDArray[np.complex128]
        The conjugate spherical harmonics coefficients array in standard Fourier order,
        in dense form.
    """
    lmax = coeff.shape[0] - 1
    ms = np.arange(coeff.shape[1]) - lmax
    # This densifies always, could warn but should only be used in testing anyway
    out = (-1) ** np.abs(ms) * coeff[:, ::-1]
    return out

ms_from_array(arr)

Creates a range of integers corresponding to the m values of spherical harmonics from -mmax to +mmax according to the order lmax of the input array.

Parameters:

Name Type Description Default
arr NDArray[complex128]

Any array of shape compatible with spherical harmonics coefficients where the first dimension corresponds to lmax + 1.

required

Returns:

Type Description
NDArray[int_]

An array of integers representing the m values from -mmax to +mmax.

Source code in src/serval/sht.py
def ms_from_array(arr: npt.NDArray[np.complex128]) -> npt.NDArray[np.int_]:
    r"""Creates a range of integers corresponding to the m values of spherical harmonics
    from -mmax to +mmax according to the order lmax of the input array.

    Parameters
    ----------
    arr : npt.NDArray[np.complex128]
        Any array of shape compatible with spherical harmonics coefficients where
        the first dimension corresponds to lmax + 1.

    Returns
    -------
    npt.NDArray[np.int_]
        An array of integers representing the m values from -mmax to +mmax.
    """
    # Gets ms from an mmode array (or anything that has the right shape for one)
    # Assumes no bandlimit in m
    return np.arange(arr.shape[0]) - (arr.shape[0] - 1) // 2

nonzero_bandlimits(coeffs)

Computes the bandlimits lmax, mmax of spherical harmonics coefficients from an array based on the non-zero entries.

Parameters:

Name Type Description Default
coeff NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. Its shape should be (lmax + 1, 2 * mmax + 1).

required

Returns:

Type Description
tuple[int, int]

A tuple containing the maximum spherical harmonic degree lmax and maximum order mmax based on the non-zero coefficients.

Source code in src/serval/sht.py
def nonzero_bandlimits(coeffs: npt.NDArray[np.complex128]) -> tuple[int, int]:
    r"""Computes the bandlimits lmax, mmax of spherical harmonics coefficients from an
    array based on the non-zero entries.

    Parameters
    ----------
    coeff : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order. Its shape
        should be (lmax + 1, 2 * mmax + 1).

    Returns
    -------
    tuple[int, int]
        A tuple containing the maximum spherical harmonic degree lmax and
        maximum order mmax based on the non-zero coefficients.
    """
    nonzero = coeffs.nonzero()
    ells = nonzero[0]
    ms = nonzero[1] - (coeffs.shape[1] - 1) // 2
    return int(ells.max()), int(np.abs(ms).max())

serval_to_ducc0_alm(alm, lmax, mmax)

Convert SERVAL alm to the (aR, aI) pair expected by ducc0.sht.rotate_alm.

SERVAL stores complex SH coefficients in a dense (..., lmax+1, 2*mmax+1) array indexed as alm[..., l, m + mmax]. ducc0.sht.rotate_alm internally decomposes a complex field into two real fields whose m >= 0 triangular coefficients are stored flat. This function performs that decomposition and the associated normalization conversion (pyshtools ortho + csphase=1 → ducc0 internal 4π norm).

The decomposition follows the relation used by pyshtools' ducc0 backend:

\[a_{R,\ell m} = \frac{(-1)^m\,c_{\ell,m} + c_{\ell,-m}^*}{2}, \qquad a_{I,\ell m} = \frac{i(c_{\ell,-m}^* - (-1)^m\,c_{\ell,m})}{2} \quad (m > 0)\]

with \(a_{R,\ell 0} = \mathrm{Re}(c_{\ell,0})\), \(a_{I,\ell 0} = \mathrm{Im}(c_{\ell,0})\). The normalization factors cancel between the ortho→4π conversion and the real-field reconstruction, yielding the compact expressions above.

Parameters:

Name Type Description Default
alm NDArray[complex128]

SERVAL alm array of shape (..., lmax+1, 2*mmax+1).

required
lmax int

Maximum degree.

required
mmax int

Maximum order stored in alm (may be less than lmax).

required

Returns:

Type Description
aR, aI : npt.NDArray[np.complex128]

Both have shape (..., ncoeff) where ncoeff = (lmax+1)*(lmax+2)//2. Entries for m > mmax are zero. Pass directly to ducc0.sht.rotate_alm.

Source code in src/serval/sht.py
def serval_to_ducc0_alm(
    alm: npt.NDArray[np.complex128],
    lmax: int,
    mmax: int,
) -> tuple[npt.NDArray[np.complex128], npt.NDArray[np.complex128]]:
    r"""Convert SERVAL alm to the ``(aR, aI)`` pair expected by ``ducc0.sht.rotate_alm``.

    SERVAL stores complex SH coefficients in a dense ``(..., lmax+1, 2*mmax+1)`` array
    indexed as ``alm[..., l, m + mmax]``.  ``ducc0.sht.rotate_alm`` internally
    decomposes a complex field into two real fields whose ``m >= 0`` triangular
    coefficients are stored flat.  This function performs that decomposition and
    the associated normalization conversion (pyshtools ortho + csphase=1  →  ducc0
    internal 4Ï€ norm).

    The decomposition follows the relation used by pyshtools' ducc0 backend:

    $$a_{R,\ell m} = \frac{(-1)^m\,c_{\ell,m} + c_{\ell,-m}^*}{2}, \qquad
    a_{I,\ell m} = \frac{i(c_{\ell,-m}^* - (-1)^m\,c_{\ell,m})}{2} \quad (m > 0)$$

    with $a_{R,\ell 0} = \mathrm{Re}(c_{\ell,0})$, $a_{I,\ell 0} = \mathrm{Im}(c_{\ell,0})$.
    The normalization factors cancel between the ortho→4π conversion and the
    real-field reconstruction, yielding the compact expressions above.

    Parameters
    ----------
    alm : npt.NDArray[np.complex128]
        SERVAL alm array of shape ``(..., lmax+1, 2*mmax+1)``.
    lmax : int
        Maximum degree.
    mmax : int
        Maximum order stored in *alm* (may be less than *lmax*).

    Returns
    -------
    aR, aI : npt.NDArray[np.complex128]
        Both have shape ``(..., ncoeff)`` where ``ncoeff = (lmax+1)*(lmax+2)//2``.
        Entries for ``m > mmax`` are zero.  Pass directly to ``ducc0.sht.rotate_alm``.
    """
    ls_arr, ms_arr, m0_mask, mpos_mask = _ducc0_triangular_indices(lmax)
    ncoeff = len(ms_arr)
    batch_shape = alm.shape[:-2]
    aR = empty_complex_array((*batch_shape, ncoeff))
    aI = empty_complex_array((*batch_shape, ncoeff))

    # m=0: ortho→4π and real-field norms cancel, leaving real/imag parts directly
    aR[..., m0_mask] = alm[..., ls_arr[m0_mask], mmax].real
    aI[..., m0_mask] = alm[..., ls_arr[m0_mask], mmax].imag

    # m>0 entries that are present in the SERVAL array
    fill_mpos = mpos_mask & (ms_arr <= mmax)
    if np.any(fill_mpos):
        ls_v = ls_arr[fill_mpos]
        ms_v = ms_arr[fill_mpos]
        sign_v = (-1.0) ** ms_v
        a_pos = alm[..., ls_v, mmax + ms_v]
        a_neg = alm[..., ls_v, mmax - ms_v]
        aR[..., fill_mpos] = (sign_v * a_pos + a_neg.conj()) / 2
        aI[..., fill_mpos] = 1j * (a_neg.conj() - sign_v * a_pos) / 2

    return aR, aI

set_bandlimits(coeffs, lmax, mmax=None)

Reshapes spherical harmonics coefficients to given bandlimits.

Operates on the last two axes of coeffs, treating any preceding axes as batch dimensions.

Parameters:

Name Type Description Default
coeffs NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. The last two axes should have shape (lmax + 1, 2 * mmax + 1).

required
lmax int

The target maximum spherical harmonic degree.

required
mmax int | None

The target maximum spherical harmonic order. If None, it is set equal to lmax.

None

Returns:

Type Description
NDArray[complex128]

A spherical harmonics coefficients array reshaped to the target bandlimits.

Source code in src/serval/sht.py
def set_bandlimits(
    coeffs: npt.NDArray[np.complex128], lmax: int, mmax: int | None = None
) -> npt.NDArray[np.complex128]:
    r"""Reshapes spherical harmonics coefficients to given bandlimits.

    Operates on the last two axes of ``coeffs``, treating any preceding axes as
    batch dimensions.

    Parameters
    ----------
    coeffs : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order.
        The last two axes should have shape ``(lmax + 1, 2 * mmax + 1)``.
    lmax : int
        The target maximum spherical harmonic degree.
    mmax : int | None, optional
        The target maximum spherical harmonic order. If None, it is set equal to lmax.

    Returns
    -------
    npt.NDArray[np.complex128]
        A spherical harmonics coefficients array reshaped to the target bandlimits.
    """
    if mmax is None:
        mmax = lmax
    out = coeffs.copy()
    lead = tuple((0, 0) for _ in range(out.ndim - 2))
    start_lmax = coeffs.shape[-2] - 1
    delta_lmax = start_lmax - lmax
    if delta_lmax > 0:
        out = out[..., :-delta_lmax, :]
    elif delta_lmax < 0:
        pad_width = (*lead, (0, abs(delta_lmax)), (0, 0))
        out = np.pad(out, pad_width, constant_values=0.0)
    start_mmax = (coeffs.shape[-1] - 1) // 2
    delta_mmax = start_mmax - mmax
    if delta_mmax > 0:
        out = out[..., :, delta_mmax:-delta_mmax]
    elif delta_mmax < 0:
        pad_width = (*lead, (0, 0), (abs(delta_mmax), abs(delta_mmax)))
        out = np.pad(out, pad_width, constant_values=0.0)
    return out

stack_ms(coeffs)

Reorganizes spherical harmonics coefficients from the pyshtools format to the standard Fourrier order.

Parameters:

Name Type Description Default
coeffs NDArray[complex128]

A spherical harmonics coefficients array in pyshtools format. Its shape should be (2, lmax + 1, mmax + 1), where the first dimension corresponds to negative and positive m values.

required

Returns:

Type Description
NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. Its shape will be (lmax + 1, 2 * mmax + 1), with m values ranging from -mmax to +mmax.

Source code in src/serval/sht.py
def stack_ms(coeffs: npt.NDArray[np.complex128]) -> npt.NDArray[np.complex128]:
    r"""Reorganizes spherical harmonics coefficients from the pyshtools format
    to the standard Fourrier order.

    Parameters
    ----------
    coeffs : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in pyshtools format. Its shape
        should be (2, lmax + 1, mmax + 1), where the first dimension
        corresponds to negative and positive m values.

    Returns
    -------
    npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order. Its shape
        will be (lmax + 1, 2 * mmax + 1), with m values ranging from -mmax to +mmax.
    """
    neg_ms = coeffs[1, :, :0:-1]
    pos_ms = coeffs[0, ...]
    return np.concatenate([neg_ms, pos_ms], axis=-1)

threshold_bandlimits(coeffs, threshold=SPARSE_THRESHOLD)

Computes the bandlimits lmax, mmax of spherical harmonics coefficients from an array based on the non-zero entries, after setting the coefficients below a given threshold to zero.

Parameters:

Name Type Description Default
coeff NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. Its shape should be (lmax + 1, 2 * mmax + 1).

required
threshold float

The threshold below which coefficients are set to zero.

SPARSE_THRESHOLD

Returns:

Type Description
tuple[int, int]

A tuple containing the maximum spherical harmonic degree lmax and maximum order mmax based on the filtered coefficients.

Source code in src/serval/sht.py
def threshold_bandlimits(
    coeffs: npt.NDArray[np.complex128], threshold: float = SPARSE_THRESHOLD
) -> tuple[int, int]:
    r"""Computes the bandlimits lmax, mmax of spherical harmonics coefficients from an array based
    on the non-zero entries, after setting the coefficients below a given threshold to zero.

    Parameters
    ----------
    coeff : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order.
        Its shape should be (lmax + 1, 2 * mmax + 1).
    threshold : float, optional
        The threshold below which coefficients are set to zero.

    Returns
    -------
    tuple[int, int]
        A tuple containing the maximum spherical harmonic degree lmax and
        maximum order mmax based on the filtered coefficients.
    """
    return nonzero_bandlimits(filter_coeffs(coeffs, threshold=threshold))

unstack_ms(coeffs)

Reorganizes spherical harmonics coefficients from the standard Fourier order to the pyshtools format.

Parameters:

Name Type Description Default
coeffs NDArray[complex128]

A spherical harmonics coefficients array in standard Fourier order. Its shape should be (lmax + 1, 2 * mmax + 1), with m values ranging from -mmax to +mmax.

required

Returns:

Type Description
NDArray[complex128]

A spherical harmonics coefficients array in pyshtools format. Its shape will be (2, lmax + 1, mmax + 1), where the first dimension corresponds to negative and positive m values.

Source code in src/serval/sht.py
def unstack_ms(coeffs: npt.NDArray[np.complex128]) -> npt.NDArray[np.complex128]:
    r"""Reorganizes spherical harmonics coefficients from the standard Fourier order
    to the pyshtools format.

    Parameters
    ----------
    coeffs : npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in standard Fourier order. Its shape
        should be (lmax + 1, 2 * mmax + 1), with m values ranging from -mmax to +mmax.

    Returns
    -------
    npt.NDArray[np.complex128]
        A spherical harmonics coefficients array in pyshtools format. Its shape
        will be (2, lmax + 1, mmax + 1), where the first dimension
        corresponds to negative and positive m values.
    """
    mmax = (coeffs.shape[1] - 1) // 2
    out = np.zeros(shape=(2, coeffs.shape[0], mmax + 1), dtype=coeffs.dtype)
    out[0, :, :] = coeffs[:, mmax:]
    out[1, :, 1:] = coeffs[:, mmax - 1 :: -1]
    return out

General Utilities (serval.utils.py)

airy_pattern(freq, theta, phi, D_eff, asymmetry_ratio=1.0, asymmetry_angle=0.0, power=False, fwhm_fac=AIRY_FWHM_FACTOR)

Evaluate an Airy disk beam pattern in pointing-frame coordinates.

The voltage pattern is \(2 J_1(x) / x\) with \(x = \pi \cdot \mathrm{AIRY\_FWHM\_FACTOR} \cdot D_\mathrm{eff} \sin\theta / (\mathrm{fwhm\_fac} \cdot \lambda)\), so the power-beam FWHM equals \(\mathrm{fwhm\_fac} \cdot \lambda / D_\mathrm{eff}\) exactly in \(\sin\theta\). Use gaussian_pattern with use_sin_theta=True for a Gaussian whose FWHM is defined in the same \(\sin\theta\) coordinate.

Parameters:

Name Type Description Default
freq float or array_like

Frequency in MHz, broadcastable to (n_freq, ntheta, nphi).

required
theta array_like

Pointing-frame colatitude in radians, shape (ntheta, nphi).

required
phi array_like

Pointing-frame azimuth in radians, shape (ntheta, nphi).

required
D_eff float

Effective dish diameter in metres.

required
asymmetry_ratio float

Ratio of semi-major to semi-minor aperture (\(\geq 1\)). Default 1.0.

1.0
asymmetry_angle float

Orientation of the major axis from \(\phi = 0\), in radians. Default 0.0.

0.0
power bool

If True, return a power beam (squared amplitude); otherwise return the voltage amplitude. Default False.

False
fwhm_fac float

Power-beam FWHM in units of \(\lambda / D_\mathrm{eff}\). Default AIRY_FWHM_FACTOR (~1.029), giving the true Airy pattern for a uniformly-illuminated circular aperture.

AIRY_FWHM_FACTOR

Returns:

Type Description
NDArray[float64]

Beam pattern evaluated on the input grid.

Source code in src/serval/utils.py
def airy_pattern(
    freq: float | npt.NDArray[np.float64],
    theta: float | npt.NDArray[np.float64],
    phi: float | npt.NDArray[np.float64],
    D_eff: float,
    asymmetry_ratio: float = 1.0,
    asymmetry_angle: float = 0.0,
    power=False,
    fwhm_fac=AIRY_FWHM_FACTOR,
):
    r"""Evaluate an Airy disk beam pattern in pointing-frame coordinates.

    The voltage pattern is $2 J_1(x) / x$ with
    $x = \pi \cdot \mathrm{AIRY\_FWHM\_FACTOR} \cdot D_\mathrm{eff} \sin\theta
    / (\mathrm{fwhm\_fac} \cdot \lambda)$, so the power-beam FWHM equals
    $\mathrm{fwhm\_fac} \cdot \lambda / D_\mathrm{eff}$ exactly in $\sin\theta$.
    Use [gaussian_pattern][serval.utils.gaussian_pattern] with ``use_sin_theta=True``
    for a Gaussian whose FWHM is defined in the same $\sin\theta$ coordinate.

    Parameters
    ----------
    freq : float or array_like
        Frequency in MHz, broadcastable to ``(n_freq, ntheta, nphi)``.
    theta : array_like
        Pointing-frame colatitude in radians, shape ``(ntheta, nphi)``.
    phi : array_like
        Pointing-frame azimuth in radians, shape ``(ntheta, nphi)``.
    D_eff : float
        Effective dish diameter in metres.
    asymmetry_ratio : float
        Ratio of semi-major to semi-minor aperture ($\geq 1$).  Default 1.0.
    asymmetry_angle : float
        Orientation of the major axis from $\phi = 0$, in radians.  Default 0.0.
    power : bool
        If ``True``, return a power beam (squared amplitude); otherwise return
        the voltage amplitude.  Default ``False``.
    fwhm_fac : float
        Power-beam FWHM in units of $\lambda / D_\mathrm{eff}$.  Default
        `AIRY_FWHM_FACTOR` (~1.029), giving the true Airy pattern for a
        uniformly-illuminated circular aperture.

    Returns
    -------
    npt.NDArray[np.float64]
        Beam pattern evaluated on the input grid.
    """
    wl = (freq * units.MHz).to("m", equivalencies=units.spectral()).value
    if asymmetry_ratio != 1.0:
        cos_a = np.cos(phi - asymmetry_angle)
        sin_a = np.sin(phi - asymmetry_angle)
        D_phi = D_eff / np.sqrt(cos_a**2 + sin_a**2 / asymmetry_ratio**2)
    else:
        D_phi = D_eff
    x = np.pi * AIRY_FWHM_FACTOR * D_phi * np.sin(theta) / (wl * fwhm_fac)
    with np.errstate(invalid="ignore", divide="ignore"):
        airy_amp = 2 * j1(x) / x
        airy_amp[x == 0] = 1.0
    if power:
        return airy_amp**2
    else:
        return airy_amp

analytic_plane_wave_al(ells, kz)

Computes the analytic spherical harmonic coefficients of a plane wave propagating in the z-direction.

Parameters:

Name Type Description Default
ells NDArray[int64]

The spherical harmonic degrees at which to evaluate the coefficients.

required
kz NDArray[float64]

The wavevector magnitudes at which to evaluate the coefficients.

required

Returns:

Type Description
NDArray[complex128]

The spherical harmonic coefficients of the plane wave at the specified degrees and wavevector magnitudes.

Source code in src/serval/utils.py
def analytic_plane_wave_al(
    ells: npt.NDArray[np.int64], kz: npt.NDArray[np.float64]
) -> npt.NDArray[np.complex128]:
    r"""Computes the analytic spherical harmonic coefficients of a plane wave
    propagating in the z-direction.
    Parameters
    ----------
    ells : npt.NDArray[np.int64]
        The spherical harmonic degrees at which to evaluate the coefficients.
    kz : npt.NDArray[np.float64]
        The wavevector magnitudes at which to evaluate the coefficients.
    Returns
    -------
    npt.NDArray[np.complex128]
        The spherical harmonic coefficients of the plane wave at the specified
        degrees and wavevector magnitudes.
    """
    return spherical_jn(ells, kz) * np.sqrt((2 * ells + 1) * 4 * np.pi) * (1j) ** ells

bandlimited_random_plane_wave(lmax, template_grid, rng, threshold=SPARSE_THRESHOLD)

Generates a random band-limited plane wave on the sphere evaluated on the grid.

Parameters:

Name Type Description Default
lmax int

The maximum spherical harmonic degree for band-limiting.

required
template_grid SHGrid

A pyshtools SHGrid object that serves as a template for the output grid.

required
rng Generator | None

An optional random number generator for reproducibility. If None, a new generator will be created. Default is None.

required
threshold float

The threshold for determining the plane wave magnitude from the bandlimit.

SPARSE_THRESHOLD

Returns:

Type Description
tuple[SHGrid, NDArray[float64]]

A tuple containing the generated band-limited plane wave grid and the corresponding wavevector.

Source code in src/serval/utils.py
def bandlimited_random_plane_wave(
    lmax: int,
    template_grid: pysh.SHGrid,
    rng: np.random.Generator | None,
    threshold: float = SPARSE_THRESHOLD,
) -> tuple[pysh.SHGrid, npt.NDArray[np.float64]]:
    r"""Generates a random band-limited plane wave on the sphere evaluated on the grid.
    Parameters
    ----------
    lmax : int
        The maximum spherical harmonic degree for band-limiting.
    template_grid : pysh.SHGrid
        A pyshtools SHGrid object that serves as a template for the output grid.
    rng : np.random.Generator | None
        An optional random number generator for reproducibility. If None, a new generator
        will be created. Default is None.
    threshold : float, optional
        The threshold for determining the plane wave magnitude from the bandlimit.
    Returns
    -------
    tuple[pysh.SHGrid, npt.NDArray[np.float64]]
        A tuple containing the generated band-limited plane wave grid and the
        corresponding wavevector.
    """
    if rng is None:
        rng = np.random.default_rng()
    unrot_k = np.array([0, 0, plane_wave_mag_from_bandlimit(lmax, threshold=threshold)])
    eulers = rng.random(3) * np.array([2 * np.pi, np.pi, 2 * np.pi])
    rot_mat = rotation_from_eulers(eulers)
    k = rot_mat.apply(unrot_k)
    plane_wave_grid = plane_waves(
        [
            k,
        ],
        template_grid,
    )
    return (
        plane_wave_grid,
        k,
    )

gaussian_pattern(freq, theta, phi, D_eff, asymmetry_ratio=1.0, asymmetry_angle=0.0, power=False, fwhm_fac=AIRY_FWHM_FACTOR, use_sin_theta=False)

Evaluate a Gaussian beam pattern in pointing-frame coordinates.

The FWHM of the power beam along the major axis is \(\mathrm{fwhm\_fac} \cdot \lambda / D_\mathrm{eff}\), exact in \(\theta\) by default. Setting use_sin_theta=True replaces \(\theta\) with \(\sin\theta\) throughout, making the FWHM exact in \(\sin\theta\) — directly comparable to airy_pattern. An optional elliptical asymmetry stretches the beam along a specified axis.

Parameters:

Name Type Description Default
freq float or array_like

Frequency in MHz, broadcastable to (n_freq, ntheta, nphi).

required
theta array_like

Pointing-frame colatitude in radians, shape (ntheta, nphi).

required
phi array_like

Pointing-frame azimuth in radians, shape (ntheta, nphi).

required
D_eff float

Effective dish diameter in metres.

required
asymmetry_ratio float

Ratio of semi-major to semi-minor beam width (\(\geq 1\)). Default 1.0.

1.0
asymmetry_angle float

Orientation of the major axis from \(\phi = 0\), in radians. Default 0.0.

0.0
power bool

If True, return a power beam (\(e^{-\mathrm{exponent}/2}\)); otherwise return a voltage amplitude (\(e^{-\mathrm{exponent}/4}\)). Default False.

False
fwhm_fac float

Power-beam FWHM in units of \(\lambda / D_\mathrm{eff}\). Default AIRY_FWHM_FACTOR (~1.029), matching the FWHM of a uniformly-illuminated circular aperture.

AIRY_FWHM_FACTOR
use_sin_theta bool

If True, use \(\sin\theta\) instead of \(\theta\) in the Gaussian exponent. The FWHM is then exact in \(\sin\theta\), matching the physically motivated airy_pattern. Default False.

False

Returns:

Type Description
NDArray[float64]

Beam pattern evaluated on the input grid.

Source code in src/serval/utils.py
def gaussian_pattern(
    freq: float | npt.NDArray[np.float64],
    theta: float | npt.NDArray[np.float64],
    phi: float | npt.NDArray[np.float64],
    D_eff: float,
    asymmetry_ratio: float = 1.0,
    asymmetry_angle: float = 0.0,
    power=False,
    fwhm_fac=AIRY_FWHM_FACTOR,
    use_sin_theta: bool = False,
):
    r"""Evaluate a Gaussian beam pattern in pointing-frame coordinates.

    The FWHM of the power beam along the major axis is $\mathrm{fwhm\_fac} \cdot
    \lambda / D_\mathrm{eff}$, exact in $\theta$ by default.  Setting
    ``use_sin_theta=True`` replaces $\theta$ with $\sin\theta$ throughout, making
    the FWHM exact in $\sin\theta$ — directly comparable to
    [airy_pattern][serval.utils.airy_pattern].  An optional elliptical asymmetry
    stretches the beam along a specified axis.

    Parameters
    ----------
    freq : float or array_like
        Frequency in MHz, broadcastable to ``(n_freq, ntheta, nphi)``.
    theta : array_like
        Pointing-frame colatitude in radians, shape ``(ntheta, nphi)``.
    phi : array_like
        Pointing-frame azimuth in radians, shape ``(ntheta, nphi)``.
    D_eff : float
        Effective dish diameter in metres.
    asymmetry_ratio : float
        Ratio of semi-major to semi-minor beam width ($\geq 1$).  Default 1.0.
    asymmetry_angle : float
        Orientation of the major axis from $\phi = 0$, in radians.  Default 0.0.
    power : bool
        If ``True``, return a power beam ($e^{-\mathrm{exponent}/2}$); otherwise
        return a voltage amplitude ($e^{-\mathrm{exponent}/4}$).  Default ``False``.
    fwhm_fac : float
        Power-beam FWHM in units of $\lambda / D_\mathrm{eff}$.  Default
        `AIRY_FWHM_FACTOR` (~1.029), matching the FWHM of a uniformly-illuminated
        circular aperture.
    use_sin_theta : bool
        If ``True``, use $\sin\theta$ instead of $\theta$ in the Gaussian exponent.
        The FWHM is then exact in $\sin\theta$, matching the physically motivated
        [airy_pattern][serval.utils.airy_pattern].  Default ``False``.

    Returns
    -------
    npt.NDArray[np.float64]
        Beam pattern evaluated on the input grid.
    """
    wl = (freq * units.MHz).to("m", equivalencies=units.spectral()).value
    sigma = (fwhm_fac * wl / D_eff) * gaussian_fwhm_to_sigma
    t = np.sin(theta) if use_sin_theta else theta
    if asymmetry_ratio != 1.0:
        sigma_major = sigma
        sigma_minor = sigma / asymmetry_ratio
        x = t * np.cos(phi - asymmetry_angle)
        y = t * np.sin(phi - asymmetry_angle)
        exponent = x**2 / sigma_major**2 + y**2 / sigma_minor**2
    else:
        exponent = t**2 / sigma**2
    if power:
        return np.exp(-exponent / 2)
    else:
        return np.exp(-exponent / 4)

harmonic_point_source(ra_deg, dec_deg, lmax)

Generates band-limited spherical harmonic coefficients for a point source at given RA and Dec.

Parameters:

Name Type Description Default
ra_deg float

The right ascension of the point source in degrees.

required
dec_deg float

The declination of the point source in degrees.

required
lmax int

The maximum spherical harmonic degree.

required

Returns:

Type Description
NDArray[complex128]

The band-limited spherical harmonic coefficients of the point source in standard Fourier order, shape (lmax+1, 2*lmax+1).

Source code in src/serval/utils.py
def harmonic_point_source(
    ra_deg: float, dec_deg: float, lmax: int) -> npt.NDArray[np.complex128]:
    r"""Generates band-limited spherical harmonic coefficients for a point source
    at given RA and Dec.
    Parameters
    ----------
    ra_deg : float
        The right ascension of the point source in degrees.
    dec_deg : float
        The declination of the point source in degrees.
    lmax : int
        The maximum spherical harmonic degree.
    Returns
    -------
    npt.NDArray[np.complex128]
        The band-limited spherical harmonic coefficients of the point source in
        standard Fourier order, shape (lmax+1, 2*lmax+1).
    """
    ps_theta = np.pi / 2 - np.radians(dec_deg)
    ps_phi = np.radians(ra_deg)
    eulers = inv_eulers((ps_phi, ps_theta, 0))
    # wigner_d with mprime_max=0 gives shape (lmax+1, 1, 2*lmax+1).
    D = wigner_d(lmax, 0, eulers)
    ell = np.arange(lmax + 1)
    alm = D[:, 0, :] * np.sqrt((2 * ell + 1) / (4 * np.pi))[:, np.newaxis]
    return alm

integrals_from_alm(alm)

Compute the integral over the unit sphere for each frequency channel.

Reads the l=0, m=0 coefficient directly from SERVAL's Fourier-ordered alm array, consistent with the ortho+csphase=1 SHT convention.

Parameters:

Name Type Description Default
alm (ndarray, shape(..., lmax + 1, 2 * mmax + 1))

Spherical harmonic coefficients in SERVAL Fourier format. m=0 is at column index mmax = (alm.shape[-1] - 1) // 2.

required

Returns:

Type Description
(ndarray, shape(...))

Integral of the function over the unit sphere per leading-axis element. Complex in general; real for real-valued beams.

Source code in src/serval/utils.py
def integrals_from_alm(
    alm: npt.NDArray[np.complex128],
) -> npt.NDArray[np.complex128]:
    """Compute the integral over the unit sphere for each frequency channel.

    Reads the l=0, m=0 coefficient directly from SERVAL's Fourier-ordered
    alm array, consistent with the ortho+csphase=1 SHT convention.

    Parameters
    ----------
    alm : ndarray, shape (..., lmax+1, 2*mmax+1)
        Spherical harmonic coefficients in SERVAL Fourier format.
        m=0 is at column index ``mmax = (alm.shape[-1] - 1) // 2``.

    Returns
    -------
    ndarray, shape (...,)
        Integral of the function over the unit sphere per leading-axis element.
        Complex in general; real for real-valued beams.
    """
    mmax = (alm.shape[-1] - 1) // 2
    return alm[..., 0, mmax] * np.sqrt(4 * np.pi)

mmodes_to_visibilities(mmodes, m1max=None, ms=None)

Reconstruct a visibility timestream from m-mode amplitudes.

The output is the physical visibility timestream reconstructed from the Fourier-series amplitudes in mmodes:

\[ \mathrm{vis}(\phi) = \sum_m A_m \exp\!\left(2 \pi i m \phi / 360\right). \]

Internally this is evaluated via ifft(full_mmodes) multiplied by the output length to undo NumPy's inverse-FFT normalisation, so that :func:visibilities_to_mmodes and :func:mmodes_to_visibilities are true inverses on a consistent ERA grid.

Parameters:

Name Type Description Default
mmodes NDArray[complex128]

M-mode amplitudes.

  • ms=None: full array of shape (..., n_mmodes) in the same fftshifted order produced by :func:visibilities_to_mmodes (m-values increasing from left to right).
  • ms provided: sparse array of shape (..., len(ms)) where entry i is the amplitude for m-value ms[i]. Order does not matter; ms identifies which m each entry belongs to.
required
m1max int

Maximum absolute m-value of the output grid. Required when ms is provided. The output has 2*m1max+1 ERA samples.

None
ms array of int

M-values corresponding to the last axis of the sparse mmodes input. M-values outside [-m1max, m1max] are silently dropped.

None

Returns:

Type Description
NDArray[complex128]

Visibility timestream with shape (..., n_out).

Source code in src/serval/utils.py
def mmodes_to_visibilities(
    mmodes: npt.NDArray[np.complex128],
    m1max: int | None = None,
    ms: npt.NDArray[np.int64] | None = None,
) -> npt.NDArray[np.complex128]:
    r"""Reconstruct a visibility timestream from m-mode amplitudes.

    The output is the physical visibility timestream reconstructed from the
    Fourier-series amplitudes in ``mmodes``:

    $$
    \mathrm{vis}(\phi) = \sum_m A_m \exp\!\left(2 \pi i m \phi / 360\right).
    $$

    Internally this is evaluated via ``ifft(full_mmodes)`` multiplied by the
    output length to undo NumPy's inverse-FFT normalisation, so that
    :func:`visibilities_to_mmodes` and :func:`mmodes_to_visibilities` are true
    inverses on a consistent ERA grid.

    Parameters
    ----------
    mmodes : npt.NDArray[np.complex128]
        M-mode amplitudes.

        * ``ms=None``: full array of shape ``(..., n_mmodes)`` in the same
          fftshifted order produced by :func:`visibilities_to_mmodes`
          (m-values increasing from left to right).
        * ``ms`` provided: sparse array of shape ``(..., len(ms))`` where
          entry ``i`` is the amplitude for m-value ``ms[i]``.  Order does not
          matter; ``ms`` identifies which m each entry belongs to.
    m1max : int, optional
        Maximum absolute m-value of the output grid.  Required when ``ms`` is
        provided.  The output has ``2*m1max+1`` ERA samples.
    ms : array of int, optional
        M-values corresponding to the last axis of the sparse ``mmodes`` input.
        M-values outside ``[-m1max, m1max]`` are silently dropped.
    Returns
    -------
    npt.NDArray[np.complex128]
        Visibility timestream with shape ``(..., n_out)``.
    """
    if ms is None:
        full_mmodes = np.fft.fftshift(mmodes, axes=-1)
    else:
        if not isinstance(m1max, int):
            raise TypeError("m1max must be an integer, since ms is not None.")
        if mmodes.ndim == 1:
            full_mmodes = empty_complex_array(2 * m1max + 1)
            fftfreq = np.fft.ifftshift(
                np.linspace(-m1max, m1max, 2 * m1max + 1)
            ).astype(int)
            _, comm1, _ = np.intersect1d(fftfreq, ms, return_indices=True)
            full_mmodes[comm1] = mmodes
        else:
            full_mmodes = empty_complex_array(mmodes.shape[:-1] + (2 * m1max + 1,))
            fftfreq = np.fft.ifftshift(
                np.linspace(-m1max, m1max, 2 * m1max + 1)
            ).astype(int)
            _, comm1, _ = np.intersect1d(fftfreq, ms, return_indices=True)
            full_mmodes[..., comm1] = mmodes

    return full_mmodes.shape[-1] * np.fft.ifft(full_mmodes, axis=-1)

plane_wave_bandlimits(k, threshold=SPARSE_THRESHOLD)

Computes the spherical harmonic bandlimits lmax, mmax required to represent a plane wave with wavevector k to a given threshold.

Parameters:

Name Type Description Default
k NDArray[float64]

The wavevector of the plane wave.

required
threshold float

The threshold for determining the bandlimits. Default is SPARSE_THRESHOLD.

SPARSE_THRESHOLD

Returns:

Type Description
tuple[int, int]

A tuple containing the maximum spherical harmonic degree lmax and maximum absolute order mmax required to represent the plane wave.

Source code in src/serval/utils.py
def plane_wave_bandlimits(
    k: npt.NDArray[np.float64], threshold: float = SPARSE_THRESHOLD
) -> tuple[int, int]:
    r"""Computes the spherical harmonic bandlimits lmax, mmax required to
    represent a plane wave with wavevector k to a given threshold.
    Parameters
    ----------
    k : npt.NDArray[np.float64]
        The wavevector of the plane wave.
    threshold : float, optional
        The threshold for determining the bandlimits. Default is SPARSE_THRESHOLD.
    Returns
    -------
    tuple[int, int]
        A tuple containing the maximum spherical harmonic degree lmax and
        maximum absolute order mmax required to represent the plane wave.
    """
    # Threshold in |al|, not power
    mag_k = np.linalg.norm(k)
    lstart = max(0, int(np.round(mag_k)) - 1)
    lstop = max(13, 10 * lstart)
    plane_wave_al = np.abs(analytic_plane_wave_al(np.arange(lstart, lstop), mag_k))
    plane_wave_al /= plane_wave_al.max()
    lmax = np.argmin(plane_wave_al >= threshold) + lstart
    if mag_k == 0.0:
        mmax = 0  # +1 maybe?
    else:
        mmax = int(np.round(lmax * np.sqrt(k[0] ** 2 + k[1] ** 2) / mag_k))  # +1 maybe?
    return int(lmax), mmax

plane_wave_mag_from_bandlimit(lmax, threshold=SPARSE_THRESHOLD)

Computes the maximum usable plane-wave wavenumber |k| that can be represented on a spherical-harmonic grid of bandlimit lmax.

Parameters:

Name Type Description Default
lmax int

The maximum spherical harmonic degree for band-limiting.

required
threshold float

The threshold for determining the plane wave magnitude from the bandlimit.

SPARSE_THRESHOLD

Returns:

Type Description
float

The maximum usable plane-wave wavenumber |k|.

Source code in src/serval/utils.py
def plane_wave_mag_from_bandlimit(
    lmax: int, threshold: float = SPARSE_THRESHOLD
) -> float:
    r"""Computes the maximum usable plane-wave wavenumber |k| that can be represented
    on a spherical-harmonic grid of bandlimit lmax.
    Parameters
    ----------
    lmax : int
        The maximum spherical harmonic degree for band-limiting.
    threshold : float, optional
        The threshold for determining the plane wave magnitude from the bandlimit.
    Returns
    -------
    float
        The maximum usable plane-wave wavenumber |k|.
    """
    at_bandlimit = np.abs(analytic_plane_wave_al(lmax, lmax))

    def to_opt(mag_k):
        return np.log10(
            np.abs(analytic_plane_wave_al(lmax, mag_k)) / at_bandlimit
        ) - np.log10(threshold)

    with np.errstate(divide="ignore"):
        return brentq(to_opt, 0, lmax)

plane_waves(ks, template)

Produces a band-limited spherical plane wave evaluated on the grid from wavevectors ks.

Parameters:

Name Type Description Default
ks list[NDArray[float64]]

A list of wavevector arrays. Each array should have shape (3,).

required
template SHGrid

A pyshtools SHGrid object that serves as a template for the output grid.

required

Returns:

Type Description
SHGrid

A pyshtools SHGrid object containing the plane wave data.

Source code in src/serval/utils.py
def plane_waves(
    ks: list[npt.NDArray[np.float64]], template: pysh.SHGrid
) -> pysh.SHGrid:
    r"""Produces a band-limited spherical plane wave evaluated on the grid from wavevectors ks.
    Parameters
    ----------
    ks : list[npt.NDArray[np.float64]]
        A list of wavevector arrays. Each array should have shape (3,).
    template : pysh.SHGrid
        A pyshtools SHGrid object that serves as a template for the output grid.
    Returns
    -------
    pysh.SHGrid
        A pyshtools SHGrid object containing the plane wave data."""
    theta, phi = np.meshgrid(
        np.pi / 2 - template.lats(degrees=False),
        template.lons(degrees=False),
        indexing="ij",
    )
    nhat = spherical_to_normal(theta, phi)
    out_grid = template.copy()
    tot_k = np.sum(ks, axis=0)
    if np.linalg.norm(tot_k) > template.lmax:
        raise ValueError(
            f"Harmonic order of grid is too low to sample plane wave at bandlimit. "
            f"|k| = {np.linalg.norm(tot_k):.4f} > {template.lmax:.4f}"
        )
    out_grid.data = np.exp(1j * (nhat @ tot_k))
    return out_grid

plane_waves_integral(ks)

Computes the integral over the unit sphere of plane waves with wavevectors ks.

\[ \int_{S^2} e^{\,i\,\mathbf{k}\cdot\mathbf{n}}\, d\Omega = 4\pi\, \frac{\sin\lvert \mathbf{k}\rvert}{\lvert \mathbf{k}\rvert} \]

Parameters:

Name Type Description Default
ks list[NDArray[float64]]

A list of wavevector arrays. Each array should have shape (..., 3), where ... represents any number of leading dimensions.

required

Returns:

Type Description
float | NDArray[float64]

The integral of the product of plane waves over the unit sphere. The output shape is the broadcasted shape of the input wavevector arrays without the last dimension.

Source code in src/serval/utils.py
def plane_waves_integral(
    ks: list[npt.NDArray[np.float64]],
) -> float | npt.NDArray[np.float64]:
    r"""Computes the integral over the unit sphere of plane waves with wavevectors ks.

    $$
    \int_{S^2} e^{\,i\,\mathbf{k}\cdot\mathbf{n}}\, d\Omega
        = 4\pi\, \frac{\sin\lvert \mathbf{k}\rvert}{\lvert \mathbf{k}\rvert}
    $$

    Parameters
    ----------
    ks : list[npt.NDArray[np.float64]]
        A list of wavevector arrays. Each array should have shape (..., 3), where
        ... represents any number of leading dimensions.
    Returns
    -------
    float | npt.NDArray[np.float64]
        The integral of the product of plane waves over the unit sphere. The output
        shape is the broadcasted shape of the input wavevector arrays without the last
        dimension.
    """
    # Can be broadcastable vector stacks with xyz on -1 axis.
    total_k = np.sum(np.array(np.broadcast_arrays(*ks)), axis=0)
    mag_k = np.linalg.norm(total_k, axis=-1)
    return 4 * np.pi * np.sinc(mag_k / np.pi)

plane_waves_mmodes(ks, mmax)

Computes the m-modes of a product of plane waves by rotating one of the k-vectors (the first one) through all azimuthal angles to create visibilities.

Parameters:

Name Type Description Default
ks list[NDArray[float64]]

A list of wavevector arrays. Each array should have shape (3,).

required
mmax int

The maximum absolute m-mode index.

required

Returns:

Type Description
NDArray[complex128]

The m-modes of the product of plane waves, with shape (2 * mmax + 1,).

Source code in src/serval/utils.py
def plane_waves_mmodes(
    ks: list[npt.NDArray[np.float64]], mmax: int
) -> npt.NDArray[np.complex128]:
    r"""Computes the m-modes of a product of plane waves by rotating one of the k-vectors
    (the first one) through all azimuthal angles to create visibilities.
    Parameters
    ----------
    ks : list[npt.NDArray[np.float64]]
        A list of wavevector arrays. Each array should have shape (3,).
    mmax : int
        The maximum absolute m-mode index.
    Returns
    -------
    npt.NDArray[np.complex128]
        The m-modes of the product of plane waves, with shape (2 * mmax + 1,).
    """
    # Rotates the first plane wave relative to the others.
    eras = np.linspace(0, 2 * np.pi, 2 * mmax + 1, endpoint=False)
    # Rotate k0 from cirs to tirs basis (sky-like)
    rot_k0s = np.array([tirs_to_cirs(era).inv().apply(ks[0]) for era in eras])
    pw_vis = plane_waves_integral(
        [
            rot_k0s,
        ]
        + ks[1:]
    )
    mmodes = visibilities_to_mmodes(pw_vis)
    return mmodes

pointed_theta_phi(lmax, latitude, longitude, altitude, azimuth, boresight)

Compute TIRS-grid coordinates expressed in the pointing frame.

For each point on the TIRS spherical harmonic grid (determined by lmax), computes the colatitude and azimuth as seen in the frame of the dish pointing direction. This is the coordinate map needed to evaluate a beam pattern (defined in pointing coordinates) over the whole sky grid.

Parameters:

Name Type Description Default
lmax int

Band-limit of the spherical harmonic grid.

required
latitude float

Geodetic latitude of the observer in radians.

required
longitude float

Geodetic longitude of the observer in radians.

required
altitude float

Altitude of the observer in metres.

required
azimuth float

Dish azimuth in radians (North-through-East).

required
boresight float

Boresight rotation angle in radians.

required

Returns:

Name Type Description
pointing_theta NDArray[float64]

Colatitude in the pointing frame for every TIRS grid point, shape (nlat, nlon).

pointing_phi NDArray[float64]

Azimuth in the pointing frame for every TIRS grid point, shape (nlat, nlon).

Source code in src/serval/utils.py
def pointed_theta_phi(
    lmax: int,
    latitude: float,
    longitude: float,
    altitude: float,
    azimuth: float,
    boresight: float,
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
    r"""Compute TIRS-grid coordinates expressed in the pointing frame.

    For each point on the TIRS spherical harmonic grid (determined by
    `lmax`), computes the colatitude and azimuth as seen in the frame
    of the dish pointing direction.  This is the coordinate map needed
    to evaluate a beam pattern (defined in pointing coordinates) over
    the whole sky grid.

    Parameters
    ----------
    lmax : int
        Band-limit of the spherical harmonic grid.
    latitude : float
        Geodetic latitude of the observer in radians.
    longitude : float
        Geodetic longitude of the observer in radians.
    altitude : float
        Altitude of the observer in metres.
    azimuth : float
        Dish azimuth in radians (North-through-East).
    boresight : float
        Boresight rotation angle in radians.

    Returns
    -------
    pointing_theta : npt.NDArray[np.float64]
        Colatitude in the pointing frame for every TIRS grid point,
        shape ``(nlat, nlon)``.
    pointing_phi : npt.NDArray[np.float64]
        Azimuth in the pointing frame for every TIRS grid point,
        shape ``(nlat, nlon)``.
    """
    grid = grid_template(lmax)
    tirs_theta, tirs_phi = grid_theta_phi(grid, meshgrid=True)
    tirs_normals = spherical_to_normal(tirs_theta, tirs_phi)
    rot_tirs_to_pointing = tirs_to_pointing(
        latitude=latitude,
        longitude=longitude,
        altitude=altitude,
        azimuth=azimuth,
        boresight=boresight,
    ).as_matrix()
    pointing_normals = (rot_tirs_to_pointing @ tirs_normals[..., None]).squeeze()
    pointing_theta = np.arccos(pointing_normals[..., 2])
    pointing_phi = np.arctan2(pointing_normals[..., 1], pointing_normals[..., 0])
    return pointing_theta, pointing_phi

power_law_sky(lmax, power_law_index=2.0, seed=None)

Generates a random sky realization with a power-law angular power spectrum.

Parameters:

Name Type Description Default
lmax int

The maximum spherical harmonic degree.

required
power_law_index float

The index of the power-law angular power spectrum. Default is 2.0.

2.0
seed int | None

The random seed for reproducibility. Default is None.

None

Returns:

Type Description
SHGrid

The generated random sky realization as a pyshtools spherical harmonic grid.

Source code in src/serval/utils.py
def power_law_sky(
    lmax: int, power_law_index: float = 2.0, seed: int | None = None
) -> pysh.SHGrid:
    r"""Generates a random sky realization with a power-law angular power spectrum.
    Parameters
    ----------
    lmax : int
        The maximum spherical harmonic degree.
    power_law_index : float, optional
        The index of the power-law angular power spectrum. Default is 2.0.
    seed : int | None, optional
        The random seed for reproducibility. Default is None.
    Returns
    -------
    pysh.SHGrid
        The generated random sky realization as a pyshtools spherical harmonic grid.
    """
    ells = np.arange(lmax + 1, dtype=float)
    power = np.zeros(lmax + 1, dtype=float)
    power[1:] = ells[1:] ** (-power_law_index)
    pl_sky_alm = stack_ms(
        pysh.SHCoeffs.from_random(
            power, kind="complex", seed=seed, **SHT_CONVENTIONS
        ).coeffs
    )
    pl_sky_grid = array_synthesis(pl_sky_alm)
    pl_sky_grid.data.imag = 0.0  # Make sky real.
    return pl_sky_grid

visibilities_at_eras(mmodes, m_values, era_deg)

Evaluate a visibility timestream at arbitrary ERA positions via NUDFT.

Reconstructs visibilities at arbitrary Earth Rotation Angle values using the stored m-mode amplitudes. The evaluation is exact for band-limited signals (no aliasing up to the Nyquist m-value) and periodic with period 360°.

The relationship between m-modes and visibilities is::

vis(f, φ) = Σ_m  A_m(f) · exp(2πi · m · φ / 360)

where A_m = mmodes[..., mmax + m] (fftshifted storage).

Parameters:

Name Type Description Default
mmodes (ndarray, shape(..., n_era))

M-mode array in fftshifted order — m=0 is at index mmax = (n_era - 1) // 2. May be a zarr.Array; it will be loaded eagerly via np.asarray.

required
m_values ndarray of int, shape (n_m,)

M-values of the non-zero entries in mmodes.

required
era_deg (ndarray, shape(n_t))

ERA values in degrees at which to evaluate. Values outside [0, 360) are handled correctly via the periodicity of the complex exponential.

required

Returns:

Type Description
(ndarray, shape(..., n_t))

Reconstructed visibilities at the requested ERA positions.

Source code in src/serval/utils.py
def visibilities_at_eras(
    mmodes: npt.NDArray[np.complex128],
    m_values: npt.NDArray[np.int_],
    era_deg: npt.NDArray[np.float64],
) -> npt.NDArray[np.complex128]:
    r"""Evaluate a visibility timestream at arbitrary ERA positions via NUDFT.

    Reconstructs visibilities at arbitrary Earth Rotation Angle values using the
    stored m-mode amplitudes.  The evaluation is exact for band-limited signals
    (no aliasing up to the Nyquist m-value) and periodic with period 360°.

    The relationship between m-modes and visibilities is::

        vis(f, φ) = Σ_m  A_m(f) · exp(2πi · m · φ / 360)

    where ``A_m = mmodes[..., mmax + m]`` (fftshifted storage).

    Parameters
    ----------
    mmodes : ndarray, shape (..., n_era)
        M-mode array in fftshifted order — m=0 is at index ``mmax = (n_era - 1) // 2``.
        May be a ``zarr.Array``; it will be loaded eagerly via ``np.asarray``.
    m_values : ndarray of int, shape (n_m,)
        M-values of the non-zero entries in ``mmodes``.
    era_deg : ndarray, shape (n_t,)
        ERA values in degrees at which to evaluate. Values outside ``[0, 360)``
        are handled correctly via the periodicity of the complex exponential.

    Returns
    -------
    ndarray, shape (..., n_t)
        Reconstructed visibilities at the requested ERA positions.
    """
    mmax = (mmodes.shape[-1] - 1) // 2
    amplitudes = np.asarray(mmodes)[..., m_values + mmax]          # (..., n_m)
    phases = np.exp(2j * np.pi * m_values * era_deg[:, np.newaxis] / 360.0)  # (n_t, n_m)
    return amplitudes @ phases.swapaxes(-1, -2)  # (..., n_t)

visibilities_to_mmodes(vis)

Compute per-sample m-mode amplitudes from a visibility timestream.

Applies an FFT along the last axis and normalises by n_times so the returned coefficients are the Fourier-series amplitudes \(A_m\) satisfying

\[ \mathrm{vis}(\phi) = \sum_m A_m \exp\!\left(2 \pi i m \phi / 360\right). \]

The output is fftshifted: index n_times // 2 + m corresponds to m-mode m.

Parameters:

Name Type Description Default
vis NDArray[complex128]

Input visibilities with shape (..., n_times).

required

Returns:

Type Description
NDArray[complex128]

M-mode amplitudes with shape (..., n_times). The last axis is fftshifted so that m-values run from -n_times//2 to n_times//2 - 1 (even n_times) or (n_times-1)//2 (odd).

Source code in src/serval/utils.py
def visibilities_to_mmodes(
    vis: npt.NDArray[np.complex128],
) -> npt.NDArray[np.complex128]:
    r"""Compute per-sample m-mode amplitudes from a visibility timestream.

    Applies an FFT along the last axis and normalises by ``n_times`` so the
    returned coefficients are the Fourier-series amplitudes $A_m$ satisfying

    $$
    \mathrm{vis}(\phi) = \sum_m A_m \exp\!\left(2 \pi i m \phi / 360\right).
    $$

    The output is fftshifted: index ``n_times // 2 + m`` corresponds to
    m-mode ``m``.

    Parameters
    ----------
    vis : npt.NDArray[np.complex128]
        Input visibilities with shape ``(..., n_times)``.
    Returns
    -------
    npt.NDArray[np.complex128]
        M-mode amplitudes with shape ``(..., n_times)``.  The last axis is
        fftshifted so that m-values run from ``-n_times//2`` to
        ``n_times//2 - 1`` (even ``n_times``) or ``(n_times-1)//2`` (odd).
    """
    return np.fft.fftshift(np.fft.fft(vis, axis=-1), axes=-1) / vis.shape[-1]

Core Gaunt Coefficient Utilities (serval.gaunt.core.py)

gaunt_dot12(alm1, alm2, l3max, sum_m1=False, absm1_lower=None, absm1_upper=None)

Compute a projector for alm3 for the integral of the triple product of spherical harmonics by computing the Gaunt co-efficients in-place and sum-producting over l1, l2, m2 and m1 (if requested).

TODO add formula.

Parameters:

Name Type Description Default
alm1 NDArray[complex128]

Spherical harmonic co-efficients in l1, m1.

required
alm2 NDArray[complex128]

Spherical harmonic co-efficients in l2, m2.

required
l3max int

Maximum l3 to compute co-efficients up to.

required
sum_m1 bool

If True, also sum over m1, otherwise return m1-modes for the m1 range specified, by default False

False
absm1_lower int | None

Lower limit in |m1| to use, by default None, ie. |m1| >= 0.

None
absm1_upper int | None

Upper limit in |m1| to use, by default None, ie. |m1| <= m1max = l1max.

None

Returns:

Type Description
NDArray[complex128]

m1-mode alm3 projector as a numpy array of shape (Nm1, l3max+1, 2l3max+1) if sum_m1 is False or (l3max+1, 2l3max+1) is sum_m1 is True.

Source code in src/serval/gaunt/core.py
def gaunt_dot12(
    alm1: npt.NDArray[np.complex128],
    alm2: npt.NDArray[np.complex128],
    l3max: int,
    sum_m1: bool = False,
    absm1_lower: int | None = None,
    absm1_upper: int | None = None,
) -> npt.NDArray[np.complex128]:
    """Compute a projector for alm3 for the integral of the triple product
    of spherical harmonics by computing the Gaunt co-efficients in-place
    and sum-producting over l1, l2, m2 and m1 (if requested).

    TODO add formula.

    Parameters
    ----------
    alm1 : npt.NDArray[np.complex128]
        Spherical harmonic co-efficients in l1, m1.
    alm2 : npt.NDArray[np.complex128]
        Spherical harmonic co-efficients in l2, m2.
    l3max : int
        Maximum l3 to compute co-efficients up to.
    sum_m1 : bool, optional
        If True, also sum over m1, otherwise return m1-modes for the m1
        range specified, by default False
    absm1_lower : int | None, optional
        Lower limit in |m1| to use, by default None, ie. |m1| >= 0.
    absm1_upper : int | None, optional
        Upper limit in |m1| to use, by default None, ie. |m1| <= m1max = l1max.

    Returns
    -------
    npt.NDArray[np.complex128]
        m1-mode alm3 projector as a numpy array of shape (Nm1, l3max+1, 2*l3max+1)
        if sum_m1 is False or (l3max+1, 2*l3max+1) is sum_m1 is True.
    """
    if absm1_lower is None:
        absm1_lower = 0
    if absm1_upper is None:
        absm1_upper = alm1.shape[0]
    nm1 = len(_gaunt.m1_indexing(alm1.shape[0] - 1, absm1_lower, absm1_upper)[0])
    result = empty_complex_array((nm1, l3max + 1, 2 * l3max + 1))
    _gaunt.inplace_dot12(alm1, alm2, result, l3max, absm1_lower, absm1_upper)
    if sum_m1:
        return np.sum(result, axis=0)
    else:
        return result

gaunt_dot123(alm1, alm2, alm3, sum_m1=False, absm1_lower=None, absm1_upper=None)

Compute the integral of the triple product of spherical harmonics by computing the Gaunt co-efficients in-place and sum-producting over all harmonic degrees and orders except m1, unless requested.

TODO add formula.

Parameters:

Name Type Description Default
alm1 NDArray[complex128]

Spherical harmonic co-efficients in l1, m1.

required
alm2 NDArray[complex128]

Spherical harmonic co-efficients in l2, m2.

required
alm3 NDArray[complex128]

Spherical harmonic co-efficients in l3, m3.

required
sum_m1 bool

If True, also sum over m1, otherwise return m1-modes for the m1 range specified, by default False

False
absm1_lower int | None

Lower limit in |m1| to use, by default None, ie. |m1| >= 0.

None
absm1_upper int | None

Upper limit in |m1| to use, by default None, ie. |m1| <= m1max = l1max.

None

Returns:

Type Description
NDArray[complex128] | float

Numpy array, shape (Nm1), of m1-modes or their sum if sum_m1 is True.

Source code in src/serval/gaunt/core.py
def gaunt_dot123(
    alm1: npt.NDArray[np.complex128],
    alm2: npt.NDArray[np.complex128],
    alm3: npt.NDArray[np.complex128],
    sum_m1: bool = False,
    absm1_lower: int | None = None,
    absm1_upper: int | None = None,
) -> npt.NDArray[np.complex128] | float:
    """Compute the integral of the triple product of spherical harmonics
    by computing the Gaunt co-efficients in-place and sum-producting over all
    harmonic degrees and orders except m1, unless requested.

    TODO add formula.

    Parameters
    ----------
    alm1 : npt.NDArray[np.complex128]
        Spherical harmonic co-efficients in l1, m1.
    alm2 : npt.NDArray[np.complex128]
        Spherical harmonic co-efficients in l2, m2.
    alm3 : npt.NDArray[np.complex128]
        Spherical harmonic co-efficients in l3, m3.
    sum_m1 : bool, optional
        If True, also sum over m1, otherwise return m1-modes for the m1
        range specified, by default False
    absm1_lower : int | None, optional
        Lower limit in |m1| to use, by default None, ie. |m1| >= 0.
    absm1_upper : int | None, optional
        Upper limit in |m1| to use, by default None, ie. |m1| <= m1max = l1max.

    Returns
    -------
    npt.NDArray[np.complex128] | float
        Numpy array, shape (Nm1), of m1-modes or their sum if sum_m1 is True.
    """
    if absm1_lower is None:
        absm1_lower = 0
    if absm1_upper is None:
        absm1_upper = alm1.shape[0]
    nm1 = len(_gaunt.m1_indexing(alm1.shape[0] - 1, absm1_lower, absm1_upper)[0])
    result = empty_complex_array(nm1)
    _gaunt.inplace_dot123(alm1, alm2, alm3, result, absm1_lower, absm1_upper)
    if sum_m1:
        return result.sum()
    else:
        return result

gaunt_dot23(alm2, alm3, l1max, sum_m1=False, absm1_lower=None, absm1_upper=None)

Compute a projector for alm1 for the integral of the triple product of spherical harmonics by computing the Gaunt co-efficients in-place and sum-producting over l2, m2, l3, m3 and m1 (if requested).

TODO add formula.

Parameters:

Name Type Description Default
alm2 NDArray[complex128]

Spherical harmonic co-efficients in l2, m2.

required
alm3 NDArray[complex128]

Spherical harmonic co-efficients in l3, m3.

required
l1max int

Maximum l1 to compute co-efficients up to.

required
sum_m1 bool

If True, also sum over m1, otherwise return m1-modes for the m1 range specified, by default False

False
absm1_lower int | None

Lower limit in |m1| to use, by default None, ie. |m1| >= 0.

None
absm1_upper int | None

Upper limit in |m1| to use, by default None, ie. |m1| <= m1max = l1max.

None

Returns:

Type Description
NDArray[complex128]

m1-mode alm1 projector as a numpy array of shape (Nm1, l1max+1) if sum_m1 is False or (l1max+1,) is sum_m1 is True.

Source code in src/serval/gaunt/core.py
def gaunt_dot23(
    alm2: npt.NDArray[np.complex128],
    alm3: npt.NDArray[np.complex128],
    l1max: int,
    sum_m1: bool = False,
    absm1_lower: int | None = None,
    absm1_upper: int | None = None,
) -> npt.NDArray[np.complex128]:
    """Compute a projector for alm1 for the integral of the triple product
    of spherical harmonics by computing the Gaunt co-efficients in-place
    and sum-producting over l2, m2, l3, m3 and m1 (if requested).

    TODO add formula.

    Parameters
    ----------
    alm2 : npt.NDArray[np.complex128]
        Spherical harmonic co-efficients in l2, m2.
    alm3 : npt.NDArray[np.complex128]
        Spherical harmonic co-efficients in l3, m3.
    l1max : int
        Maximum l1 to compute co-efficients up to.
    sum_m1 : bool, optional
        If True, also sum over m1, otherwise return m1-modes for the m1
        range specified, by default False
    absm1_lower : int | None, optional
        Lower limit in |m1| to use, by default None, ie. |m1| >= 0.
    absm1_upper : int | None, optional
        Upper limit in |m1| to use, by default None, ie. |m1| <= m1max = l1max.

    Returns
    -------
    npt.NDArray[np.complex128]
        m1-mode alm1 projector as a numpy array of shape (Nm1, l1max+1)
        if sum_m1 is False or (l1max+1,) is sum_m1 is True.
    """
    if absm1_lower is None:
        absm1_lower = 0
    if absm1_upper is None:
        absm1_upper = l1max + 1
    nm1 = len(_gaunt.m1_indexing(l1max, absm1_lower, absm1_upper)[0])
    result = empty_complex_array((nm1, l1max + 1))
    _gaunt.inplace_dot23(alm2, alm3, result, l1max, absm1_lower, absm1_upper)
    if sum_m1:
        return np.sum(result, axis=0)
    else:
        return result

integrator12_contract3(int12, contract3)

(m1 l3 m3) (l3 m3' m3) -> (m1 l3 m3')

Source code in src/serval/gaunt/core.py
def integrator12_contract3(
    int12: npt.NDArray[np.complex128], contract3: npt.NDArray[np.complex128]
) -> npt.NDArray[np.complex128]:
    """(m1 l3 m3) (l3 m3' m3) -> (m1 l3 m3')"""
    # Check m3' axis smaller than m3 axis
    if int12.ndim == 3:  # Has no frequency axis
        result = empty_complex_array((int12.shape[0], contract3.shape[0], contract3.shape[1]))
        _contract.integrator12_contract3(int12, contract3, result)
        return result
    elif int12.ndim == 4:  # Has frequency axis
        result = empty_complex_array(
            (int12.shape[0] * int12.shape[1], contract3.shape[0], contract3.shape[1])
        )
        _contract.integrator12_contract3(
            int12.reshape((-1,) + int12.shape[2:]), contract3, result
        )
        return result.reshape(
            (int12.shape[0], int12.shape[1], contract3.shape[0], contract3.shape[1])
        )
    else:
        raise ValueError

single_gaunt(l1, l2, l3, m1, m2)

Computes a single Gaunt co-efficient for given harmonic degrees and orders. This uses Wigner-3j family algorithms so it is not efficient for computing many Gaunts co-efficients. Primarily for testing purposes m3 is determined by m1 + m2 + m3 = 0.

Parameters:

Name Type Description Default
l1 int

Harmonic degree l1.

required
l2 int

Harmonic degree l2.

required
l3 int

Harmonic degree l3.

required
m1 int

Harmonic order m1.

required
m2 int

Harmonic order m2.

required

Returns:

Type Description
float

Computed Gaunt co-efficient.

Source code in src/serval/gaunt/core.py
def single_gaunt(l1: int, l2: int, l3: int, m1: int, m2: int) -> float:
    """Computes a single Gaunt co-efficient for given harmonic degrees
    and orders. This uses Wigner-3j family algorithms so it is not
    efficient for computing many Gaunts co-efficients. Primarily for
    testing purposes m3 is determined by m1 + m2 + m3 = 0.

    Parameters
    ----------
    l1 : int
        Harmonic degree l1.
    l2 : int
        Harmonic degree l2.
    l3 : int
        Harmonic degree l3.
    m1 : int
        Harmonic order m1.
    m2 : int
        Harmonic order m2.

    Returns
    -------
    float
        Computed Gaunt co-efficient.
    """
    triangle = abs(l1 - l3) <= l2 <= abs(l1 + l3)
    parity = (l1 + l2 + l3) % 2 != 0
    if (
        not triangle
        or l1 < 0
        or l2 < 0
        or l3 < 0
        or abs(m1) > l1
        or abs(m2) > l2
        or abs(m1 + m2) > l3
        or parity      # parity condition: the sum of the harmonic degrees must be even
    ):
        return 0.0
    return _gaunt.single_gaunt(l1, l2, l3, m1, m2)

wigner_3jj(l2, l3, m2, m3)

Compute the family of non-zero Wigner-3j terms for harmonic degrees l2 and l3 for harmonic degrees m1, m2, m3 = -m1 -m2.

Parameters:

Name Type Description Default
l2 int

Harmonic degree l2.

required
l3 int

Harmonic degree l3.

required
m2 int

Harmonic order m2.

required
m3 int

Harmonic order m3.

required

Returns:

Type Description
tuple[int, NDArray[float64]]

Tuple of first non-zero harmonic degree l1min and array of Wigner-3j values of length l1max - l1min. If there are no non-zero elements of the Wigner-3j family, returns (-1, np.array([])).

Source code in src/serval/gaunt/core.py
def wigner_3jj(
    l2: int, l3: int, m2: int, m3: int
) -> tuple[int, npt.NDArray[np.float64]]:
    """Compute the family of non-zero Wigner-3j terms for harmonic degrees l2 and l3
    for harmonic degrees m1, m2, m3 = -m1 -m2.

    Parameters
    ----------
    l2 : int
        Harmonic degree l2.
    l3 : int
        Harmonic degree l3.
    m2 : int
        Harmonic order m2.
    m3 : int
        Harmonic order m3.

    Returns
    -------
    tuple[int, npt.NDArray[np.float64]]
        Tuple of first non-zero harmonic degree l1min and array of Wigner-3j values of
        length l1max - l1min. If there are no non-zero elements of the Wigner-3j family,
        returns (-1, np.array([])).
    """
    # In case there is an error in the result, pyshtools Wigner3j can also be used.
    # However, it carries extra zeros that need to be removed:
    # Wigner_3j = pysh.utils.Wigner3j(l2, l3, -m2 - m3, m2, m3)[0]
    # lmin = max(abs(l2 - l3), abs(-m2 -m3))
    # lmax = l2 + l3
    # lnum = lmax - lmin + 1
    # return lmin, Wigner_3j[: lnum + 1 :]
    if m2 == 0 and m3 == 0:
        return wigner_3jj_000(l2, l3)
    if l2 < 0 or l3 < 0 or abs(m2) > l2 or abs(m3) > l3:
        return (-1, np.array([]))
    result = _wigner.wigner_3jj_nochecks(l2, l3, m2, m3)
    return result[0], np.array(result[1])

wigner_3jj_000(l2, l3)

Compute the family of non-zero Wigner-3j terms for harmonic degrees l2 and l3 for harmonic degrees m1 = m2 = m3 = 0.

Parameters:

Name Type Description Default
l2 int

Harmonic degree l2.

required
l3 int

Harmonic degree l3.

required

Returns:

Type Description
tuple[int, NDArray[float64]]

Tuple of first non-zero harmonic order l1min and array of Wigner-3j values of length l1max - l1min. If there are no non-zero elements of the Wigner-3j family, returns (-1, np.array([])).

Source code in src/serval/gaunt/core.py
def wigner_3jj_000(l2: int, l3: int) -> tuple[int, npt.NDArray[np.float64]]:
    """Compute the family of non-zero Wigner-3j terms for harmonic degrees l2 and l3
    for harmonic degrees m1 = m2 = m3 = 0.

    Parameters
    ----------
    l2 : int
        Harmonic degree l2.
    l3 : int
        Harmonic degree l3.

    Returns
    -------
    tuple[int, npt.NDArray[np.float64]]
        Tuple of first non-zero harmonic order l1min and array of Wigner-3j values of
        length l1max - l1min. If there are no non-zero elements of the Wigner-3j family,
        returns (-1, np.array([])).
    """
    if l2 < 0 or l3 < 0:
        return (-1, np.array([]))
    result = _wigner.wigner_3jj_000_nochecks(l2, l3)
    return result[0], np.array(result[1])

wigner_3jm(l1, l2, l3, m1)

Compute the family of Wigner-3j terms for harmonic degrees l1, l2 and l3 all non-zero terms with harmonic order m1.

Parameters:

Name Type Description Default
l1 int

Harmonic degree l1.

required
l2 int

Harmonic degree l2.

required
l3 int

Harmonic degree l3.

required
m1 int

Harmonic order m1.

required

Returns:

Type Description
tuple[int, NDArray[float64]]

Tuple of first non-zero harmonic order m2min and array of Wigner-3j values of length l2max - l2min. If there are no non-zero elements of the Wigner-3j family, returns (-1, np.array([])).

Source code in src/serval/gaunt/core.py
def wigner_3jm(
    l1: int, l2: int, l3: int, m1: int
) -> tuple[int, npt.NDArray[np.float64]]:
    """Compute the family of Wigner-3j terms for harmonic degrees l1, l2 and l3
    all non-zero terms with harmonic order m1.

    Parameters
    ----------
    l1 : int
        Harmonic degree l1.
    l2 : int
        Harmonic degree l2.
    l3 : int
        Harmonic degree l3.
    m1 : int
        Harmonic order m1.

    Returns
    -------
    tuple[int, npt.NDArray[np.float64]]
        Tuple of first non-zero harmonic order m2min and array of Wigner-3j values of
        length l2max - l2min. If there are no non-zero elements of the Wigner-3j family,
        returns (-1, np.array([])).
    """
    triangle = abs(l1 - l3) <= l2 <= abs(l1 + l3)
    if (
        not triangle
        or l1 < 0
        or l2 < 0
        or l3 < 0
        or abs(m1) > l1
        or not (abs(l1 - l3) < l2 < l1 + l3)
    ):
        return (-1, np.array([]))
    result = _wigner.wigner_3jm_nochecks(l1, l2, l3, m1)
    return result[0], np.array(result[1])