Source code for master_thesis_code.LISA_configuration
"""LISA TDI noise configuration: PSD, antenna patterns, and frame transformations.
Provides :class:`LisaTdiConfiguration` with the noise power spectral density for
the A/E/T TDI channels, the one-sided optical metrology and test-mass noise
components, and the sky-averaged F+/F× antenna pattern functions.
"""
import logging
import types
from dataclasses import dataclass
from typing import Any
import numpy as np
import numpy.typing as npt
try:
import cupy as cp
_CUPY_AVAILABLE = True
except ImportError:
cp = None
_CUPY_AVAILABLE = False
from master_thesis_code.constants import (
LISA_ARM_LENGTH as L,
)
from master_thesis_code.constants import (
LISA_PSD_A,
LISA_PSD_A1,
LISA_PSD_AK,
LISA_PSD_ALPHA,
LISA_PSD_B1,
LISA_PSD_BK,
LISA_PSD_F2,
C,
)
_LOGGER = logging.getLogger()
def _get_xp(arr: Any) -> types.ModuleType:
"""Return cupy if arr is a cupy array and cupy is available, else numpy."""
if _CUPY_AVAILABLE and cp is not None and isinstance(arr, cp.ndarray):
return cp # type: ignore[no-any-return]
return np
[docs]
@dataclass
class LisaTdiConfiguration:
"""LISA TDI noise model and antenna pattern functions.
Implements the noise power spectral density for the A, E, and T TDI channels
and the sky-averaged F+/F× antenna pattern functions for the LISA constellation,
following the equal-arm-length approximation.
The A/E-channel PSD includes an optional galactic confusion noise foreground
S_c(f) from unresolved white dwarf binaries, controlled by
``include_confusion_noise`` (default True). The observation time
``t_obs_years`` sets the level of foreground subtraction.
References:
Babak et al. (2023), arXiv:2303.15929
Cornish & Robson (2017), arXiv:1703.09858
Robson, Cornish & Liu (2019), arXiv:1803.01944
"""
t_obs_years: float = 4.0
include_confusion_noise: bool = True
[docs]
def power_spectral_density(
self,
# xp may be numpy or cupy at runtime; annotation reflects the numpy-compatible interface
frequencies: npt.NDArray[np.float64],
channel: str = "A",
) -> npt.NDArray[np.float64]:
"""PSD noise for AET channels from https://arxiv.org/pdf/2303.15929.pdf assuming equal arm length."""
if channel.upper() in ["A", "E"]:
return self.power_spectral_density_a_channel(frequencies)
elif channel.upper() == "T":
return self.power_spectral_density_t_channel(frequencies)
return np.zeros_like(frequencies)
# Eq. (3) in Cornish & Robson (2017), arXiv:1703.09858
# LDC parameterization with continuous T_obs dependence
# See also Robson, Cornish & Liu (2019), arXiv:1803.01944, Eq. (14)
def _confusion_noise(self, frequencies: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
"""Galactic foreground confusion noise S_c(f).
Computes the residual foreground from unresolved white dwarf binaries
using the LDC parameterization with continuous observation-time
dependence. Dominates the LISA sensitivity in the 0.1--3 mHz band.
Args:
frequencies: Positive frequency array in Hz.
Returns:
S_c(f) in Hz^{-1} (one-sided strain PSD contribution).
"""
xp = _get_xp(frequencies)
# Power-law coefficients (a1, b1, ak, bk) were fitted with T_obs in years.
f1 = 10.0 ** (LISA_PSD_A1 * xp.log10(self.t_obs_years) + LISA_PSD_B1)
fk = 10.0 ** (LISA_PSD_AK * xp.log10(self.t_obs_years) + LISA_PSD_BK)
return ( # type: ignore[no-any-return]
LISA_PSD_A
* frequencies ** (-7.0 / 3.0)
* xp.exp(-((frequencies / f1) ** LISA_PSD_ALPHA))
* 0.5
* (1.0 + xp.tanh(-(frequencies - fk) / LISA_PSD_F2))
)
[docs]
def power_spectral_density_a_channel(
self, frequencies: npt.NDArray[np.float64]
) -> npt.NDArray[np.float64]:
"""A/E-channel PSD including optional galactic confusion noise.
References:
Instrumental noise: Babak et al. (2023), arXiv:2303.15929
Confusion noise: Cornish & Robson (2017), arXiv:1703.09858
"""
xp = _get_xp(frequencies)
instrumental = (
8
* xp.sin(2 * xp.pi * frequencies * L / C) ** 2
* (
self.S_OMS(frequencies) * (xp.cos(2 * xp.pi * frequencies * L / C) + 2)
+ 2
* (
3
+ 2 * xp.cos(2 * xp.pi * frequencies * L / C)
+ xp.cos(4 * xp.pi * frequencies * L / C)
)
* self.S_TM(frequencies)
)
)
if self.include_confusion_noise:
instrumental = instrumental + self._confusion_noise(frequencies)
return instrumental # type: ignore[no-any-return]
[docs]
def power_spectral_density_t_channel(
self, frequencies: npt.NDArray[np.float64]
) -> npt.NDArray[np.float64]:
"""from https://arxiv.org/pdf/2303.15929.pdf NOT UPDATED"""
xp = _get_xp(frequencies)
return ( # type: ignore[no-any-return]
16
/ 3
* xp.sin(xp.pi * frequencies * L / C) ** 2
* xp.sin(2 * xp.pi * frequencies * L / C) ** 2
* self.S_zz(frequencies)
)
[docs]
def S_zz(self, frequencies: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
xp = _get_xp(frequencies)
return 6 * ( # type: ignore[no-any-return]
self.S_OMS(frequencies)
+ 2 * (1 - xp.cos(2 * xp.pi * frequencies * L / C) * self.S_TM(frequencies))
)
[docs]
@staticmethod
def S_OMS(frequencies: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
xp = _get_xp(frequencies)
return 15**2 * 1e-24 * (1 + (2e-3 / frequencies) ** 4) * (2 * xp.pi * frequencies / C) ** 2 # type: ignore[no-any-return]
[docs]
@staticmethod
def S_TM(frequencies: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
xp = _get_xp(frequencies)
return ( # type: ignore[no-any-return]
9e-30
* (1 + (0.4e-3 / frequencies) ** 2)
* (1 + (frequencies / 8e-3) ** 4)
* (1 / 2 / xp.pi / frequencies / C) ** 2
)