lisaanalysistools 1.1.20__cp39-cp39-macosx_15_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lisaanalysistools/git_version.py +7 -0
- lisaanalysistools-1.1.20.dist-info/METADATA +281 -0
- lisaanalysistools-1.1.20.dist-info/RECORD +48 -0
- lisaanalysistools-1.1.20.dist-info/WHEEL +5 -0
- lisaanalysistools-1.1.20.dist-info/licenses/LICENSE +201 -0
- lisatools/.dylibs/libgcc_s.1.1.dylib +0 -0
- lisatools/.dylibs/libstdc++.6.dylib +0 -0
- lisatools/__init__.py +90 -0
- lisatools/_version.py +34 -0
- lisatools/analysiscontainer.py +474 -0
- lisatools/cutils/Detector.cu +307 -0
- lisatools/cutils/Detector.hpp +84 -0
- lisatools/cutils/__init__.py +129 -0
- lisatools/cutils/global.hpp +28 -0
- lisatools/cutils/pycppdetector.pyx +256 -0
- lisatools/datacontainer.py +312 -0
- lisatools/detector.py +867 -0
- lisatools/diagnostic.py +990 -0
- lisatools/git_version.py.in +7 -0
- lisatools/orbit_files/equalarmlength-orbits-best-fit-to-esa.h5 +0 -0
- lisatools/orbit_files/equalarmlength-orbits.h5 +0 -0
- lisatools/orbit_files/esa-trailing-orbits.h5 +0 -0
- lisatools/sampling/__init__.py +0 -0
- lisatools/sampling/likelihood.py +882 -0
- lisatools/sampling/moves/__init__.py +0 -0
- lisatools/sampling/moves/skymodehop.py +110 -0
- lisatools/sampling/prior.py +646 -0
- lisatools/sampling/stopping.py +320 -0
- lisatools/sampling/utility.py +411 -0
- lisatools/sensitivity.py +1554 -0
- lisatools/sources/__init__.py +6 -0
- lisatools/sources/bbh/__init__.py +1 -0
- lisatools/sources/bbh/waveform.py +106 -0
- lisatools/sources/defaultresponse.py +37 -0
- lisatools/sources/emri/__init__.py +1 -0
- lisatools/sources/emri/waveform.py +79 -0
- lisatools/sources/gb/__init__.py +1 -0
- lisatools/sources/gb/waveform.py +69 -0
- lisatools/sources/utils.py +459 -0
- lisatools/sources/waveformbase.py +41 -0
- lisatools/stochastic.py +327 -0
- lisatools/utils/__init__.py +0 -0
- lisatools/utils/constants.py +54 -0
- lisatools/utils/exceptions.py +95 -0
- lisatools/utils/parallelbase.py +11 -0
- lisatools/utils/utility.py +122 -0
- lisatools_backend_cpu/git_version.py +7 -0
- lisatools_backend_cpu/pycppdetector.cpython-39-darwin.so +0 -0
lisatools/sensitivity.py
ADDED
|
@@ -0,0 +1,1554 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import warnings
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import Any, Tuple, Optional, List
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy import interpolate
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import cupy as cp
|
|
15
|
+
|
|
16
|
+
except (ModuleNotFoundError, ImportError):
|
|
17
|
+
import numpy as cp
|
|
18
|
+
|
|
19
|
+
from . import detector as lisa_models
|
|
20
|
+
from .utils.utility import AET, get_array_module
|
|
21
|
+
from .utils.constants import *
|
|
22
|
+
from .stochastic import (
|
|
23
|
+
StochasticContribution,
|
|
24
|
+
FittedHyperbolicTangentGalacticForeground,
|
|
25
|
+
check_stochastic,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
The sensitivity code is heavily based on an original code by Stas Babak, Antoine Petiteau for the LDC team.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Sensitivity(ABC):
|
|
34
|
+
"""Base Class for PSD information.
|
|
35
|
+
|
|
36
|
+
The initialization function is only needed if using a file input.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
channel: str = None
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def get_xp(array: np.ndarray) -> object:
|
|
44
|
+
"""Numpy or Cupy (or float)"""
|
|
45
|
+
try:
|
|
46
|
+
return get_array_module(array)
|
|
47
|
+
except ValueError:
|
|
48
|
+
if isinstance(array, float):
|
|
49
|
+
return np
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"array must be a numpy or cupy array (it can be a float as well)."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def transform(
|
|
56
|
+
f: float | np.ndarray,
|
|
57
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
58
|
+
**kwargs: dict,
|
|
59
|
+
) -> float | np.ndarray:
|
|
60
|
+
"""Transform from the base sensitivity functions to the TDI PSDs.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
f: Frequency array.
|
|
64
|
+
noise_levels: Current noise levels at frequency ``f``.
|
|
65
|
+
**kwargs: For interoperability.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Transformed TDI PSD values.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def get_Sn(
|
|
75
|
+
cls,
|
|
76
|
+
f: float | np.ndarray,
|
|
77
|
+
model: Optional[lisa_models.LISAModel | str] = lisa_models.sangria,
|
|
78
|
+
**kwargs: dict,
|
|
79
|
+
) -> float | np.ndarray:
|
|
80
|
+
"""Calculate the PSD
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
f: Frequency array.
|
|
84
|
+
model: Noise model. Object of type :class:`lisa_models.LISAModel`.
|
|
85
|
+
It can also be a string corresponding to one of the stock models.
|
|
86
|
+
The model object must include attributes for ``Soms_d`` (shot noise)
|
|
87
|
+
and ``Sa_a`` (acceleration noise) or a spline as attribute ``Sn_spl``.
|
|
88
|
+
In the case of a spline, this must be a dictionary with
|
|
89
|
+
channel names as keys and callable PSD splines. For example,
|
|
90
|
+
if using ``scipy.interpolate.CubicSpline``, an input option
|
|
91
|
+
can be:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
noise_model.Sn_spl = {
|
|
95
|
+
"A": CubicSpline(f, Sn_A)),
|
|
96
|
+
"E": CubicSpline(f, Sn_E)),
|
|
97
|
+
"T": CubicSpline(f, Sn_T))
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
**kwargs: For interoperability.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
PSD values.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
# spline or stock computation
|
|
107
|
+
if hasattr(model, "Sn_spl") and model.Sn_spl is not None:
|
|
108
|
+
spl = model.Sn_spl
|
|
109
|
+
if cls.channel not in spl:
|
|
110
|
+
raise ValueError("Calling a channel that is not available.")
|
|
111
|
+
|
|
112
|
+
Sout = spl[cls.channel](f)
|
|
113
|
+
|
|
114
|
+
else:
|
|
115
|
+
model = lisa_models.check_lisa_model(model)
|
|
116
|
+
# assert hasattr(model, "Soms_d") and hasattr(model, "Sa_a")
|
|
117
|
+
|
|
118
|
+
# get noise values
|
|
119
|
+
noise_levels = model.lisanoises(f)
|
|
120
|
+
|
|
121
|
+
# transform as desired for TDI combination
|
|
122
|
+
Sout = cls.transform(f, noise_levels, **kwargs)
|
|
123
|
+
|
|
124
|
+
# will add zero if ignored
|
|
125
|
+
stochastic_contribution = cls.stochastic_transform(
|
|
126
|
+
f, cls.get_stochastic_contribution(f, **kwargs), **kwargs
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
Sout += stochastic_contribution
|
|
131
|
+
except:
|
|
132
|
+
breakpoint()
|
|
133
|
+
return Sout
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def get_stochastic_contribution(
|
|
137
|
+
cls,
|
|
138
|
+
f: float | np.ndarray,
|
|
139
|
+
stochastic_params: Optional[tuple] = (),
|
|
140
|
+
stochastic_kwargs: Optional[dict] = {},
|
|
141
|
+
stochastic_function: Optional[StochasticContribution | str] = None,
|
|
142
|
+
) -> float | np.ndarray:
|
|
143
|
+
"""Calculate contribution from stochastic signal.
|
|
144
|
+
|
|
145
|
+
This function directs and wraps the calculation of and returns
|
|
146
|
+
the stochastic signal. The ``stochastic_function`` calculates the
|
|
147
|
+
sensitivity contribution. The ``transform_factor`` can transform that
|
|
148
|
+
output to the correct TDI contribution.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
f: Frequency array.
|
|
152
|
+
stochastic_params: Parameters (arguments) to feed to ``stochastic_function``.
|
|
153
|
+
stochastic_kwargs: Keyword arguments to feeed to ``stochastic_function``.
|
|
154
|
+
stochastic_function: Stochastic class or string name of stochastic class. Takes ``stochastic_args`` and ``stochastic_kwargs``.
|
|
155
|
+
If ``None``, it uses :class:`FittedHyperbolicTangentGalacticForeground`.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Contribution from stochastic signal.
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
xp = cls.get_xp(f)
|
|
163
|
+
if isinstance(f, float):
|
|
164
|
+
f = xp.ndarray([f])
|
|
165
|
+
squeeze = True
|
|
166
|
+
else:
|
|
167
|
+
squeeze = False
|
|
168
|
+
|
|
169
|
+
sgal = xp.zeros_like(f)
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
(stochastic_params != () and stochastic_params is not None)
|
|
173
|
+
or (stochastic_kwargs != {} and stochastic_kwargs is not None)
|
|
174
|
+
or stochastic_function is not None
|
|
175
|
+
):
|
|
176
|
+
if stochastic_function is None:
|
|
177
|
+
stochastic_function = FittedHyperbolicTangentGalacticForeground
|
|
178
|
+
|
|
179
|
+
stochastic_function = check_stochastic(stochastic_function)
|
|
180
|
+
|
|
181
|
+
sgal[:] = stochastic_function.get_Sh(
|
|
182
|
+
f, *stochastic_params, **stochastic_kwargs
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if squeeze:
|
|
186
|
+
sgal = sgal.squeeze()
|
|
187
|
+
return sgal
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def stochastic_transform(
|
|
191
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
192
|
+
) -> float | np.ndarray:
|
|
193
|
+
"""Transform from the base stochastic functions to the TDI PSDs.
|
|
194
|
+
|
|
195
|
+
**Note**: If not implemented, the transform will return the input.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
f: Frequency array.
|
|
199
|
+
Sh: Power spectral density in stochastic term.
|
|
200
|
+
**kwargs: For interoperability.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Transformed TDI PSD values.
|
|
204
|
+
|
|
205
|
+
"""
|
|
206
|
+
return Sh
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class X1TDISens(Sensitivity):
|
|
210
|
+
channel: str = "X"
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def Cxx(f: float | np.ndarray) -> float | np.ndarray:
|
|
214
|
+
"""Common TDI transform factor.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
f: Frequencyies to evaluate.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Cxx: Transform factor.
|
|
221
|
+
|
|
222
|
+
"""
|
|
223
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
224
|
+
return 16.0 * np.sin(x) ** 2
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def transform(
|
|
228
|
+
f: float | np.ndarray,
|
|
229
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
230
|
+
**kwargs: dict,
|
|
231
|
+
) -> float | np.ndarray:
|
|
232
|
+
__doc__ = (
|
|
233
|
+
"Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
|
|
234
|
+
+ Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
assert noise_levels.units == "relative_frequency"
|
|
238
|
+
Cxx = X1TDISens.Cxx(f)
|
|
239
|
+
|
|
240
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
241
|
+
# TODO: need to check these
|
|
242
|
+
isi_rfi_readout_transfer = Cxx
|
|
243
|
+
tmi_readout_transfer = Cxx * (2.0 * (1.0 + np.cos(x) ** 2))
|
|
244
|
+
tm_transfer = Cxx * (2.0 * (1.0 + np.cos(x) ** 2))
|
|
245
|
+
rfi_backlink_transfer = Cxx
|
|
246
|
+
tmi_backlink_transfer = Cxx * (2.0 * (1.0 + np.cos(x) ** 2))
|
|
247
|
+
|
|
248
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
249
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
250
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
251
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
252
|
+
|
|
253
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
254
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
255
|
+
|
|
256
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
257
|
+
return total_noise
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def stochastic_transform(
|
|
261
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
262
|
+
) -> float | np.ndarray:
|
|
263
|
+
__doc__ = (
|
|
264
|
+
"Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
|
|
265
|
+
+ Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
|
|
266
|
+
)
|
|
267
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
268
|
+
t = 4.0 * x**2 * np.sin(x) ** 2
|
|
269
|
+
return Sh * t
|
|
270
|
+
|
|
271
|
+
class Y1TDISens(X1TDISens):
|
|
272
|
+
channel: str = "Y"
|
|
273
|
+
__doc__ = X1TDISens.__doc__
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
class Z1TDISens(X1TDISens):
|
|
277
|
+
channel: str = "Z"
|
|
278
|
+
__doc__ = X1TDISens.__doc__
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class XY1TDISens(Sensitivity):
|
|
283
|
+
channel: str = "XY"
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def Cxy(f: float | np.ndarray) -> float | np.ndarray:
|
|
287
|
+
"""Common TDI transform factor for CSD.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
f: Frequencyies to evaluate.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Cxy: Transform factor.
|
|
294
|
+
|
|
295
|
+
"""
|
|
296
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
297
|
+
return -4.0 * np.sin(2 * x) * np.sin(x)
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def transform(
|
|
301
|
+
f: float | np.ndarray,
|
|
302
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
303
|
+
**kwargs: dict,
|
|
304
|
+
) -> float | np.ndarray:
|
|
305
|
+
__doc__ = (
|
|
306
|
+
"Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
|
|
307
|
+
+ Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
assert noise_levels.units == "relative_frequency"
|
|
311
|
+
Cxy = XY1TDISens.Cxy(f)
|
|
312
|
+
|
|
313
|
+
isi_rfi_readout_transfer = Cxy
|
|
314
|
+
tmi_readout_transfer = 4 * Cxy
|
|
315
|
+
tm_transfer = 4 * Cxy
|
|
316
|
+
rfi_backlink_transfer = Cxy
|
|
317
|
+
tmi_backlink_transfer = 4 * Cxy
|
|
318
|
+
|
|
319
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
320
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
321
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
322
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
323
|
+
|
|
324
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
325
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
326
|
+
|
|
327
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
328
|
+
return total_noise
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def stochastic_transform(
|
|
332
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
333
|
+
) -> float | np.ndarray:
|
|
334
|
+
__doc__ = (
|
|
335
|
+
"Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
|
|
336
|
+
+ Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
|
|
337
|
+
)
|
|
338
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
339
|
+
# TODO: check these functions
|
|
340
|
+
# GB = -0.5 of X
|
|
341
|
+
t = -0.5 * (4.0 * x**2 * np.sin(x) ** 2)
|
|
342
|
+
return Sh * t
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class ZX1TDISens(XY1TDISens):
|
|
346
|
+
channel: str = "ZX"
|
|
347
|
+
__doc__ = XY1TDISens.__doc__
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class YZ1TDISens(XY1TDISens):
|
|
352
|
+
channel: str = "YZ"
|
|
353
|
+
__doc__ = XY1TDISens.__doc__
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class X2TDISens(Sensitivity):
|
|
358
|
+
channel: str = "X"
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def Cxx(f: float | np.ndarray) -> float | np.ndarray:
|
|
362
|
+
"""Common TDI transform factor.
|
|
363
|
+
|
|
364
|
+
`arXiv:2211.02539 <https://arxiv.org/pdf/2211.02539>`_.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
f: Frequencyies to evaluate.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Cxx: Transform factor.
|
|
371
|
+
|
|
372
|
+
"""
|
|
373
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
374
|
+
return 16. * np.sin(x) ** 2 * np.sin(2 * x) ** 2 # np.abs(1. - np.exp(-2j * np.pi * f * L_SI / C_SI) ** 2) ** 2
|
|
375
|
+
|
|
376
|
+
@staticmethod
|
|
377
|
+
def transform(
|
|
378
|
+
f: float | np.ndarray,
|
|
379
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
380
|
+
**kwargs: dict,
|
|
381
|
+
) -> float | np.ndarray:
|
|
382
|
+
__doc__ = (
|
|
383
|
+
"Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
|
|
384
|
+
+ Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
assert noise_levels.units == "relative_frequency"
|
|
388
|
+
Cxx = X2TDISens.Cxx(f)
|
|
389
|
+
|
|
390
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
391
|
+
|
|
392
|
+
isi_rfi_readout_transfer = 4. * Cxx
|
|
393
|
+
tmi_readout_transfer = Cxx * (3 + np.cos(2 * x))
|
|
394
|
+
tm_transfer = 4 * Cxx * (3 + np.cos(2 * x))
|
|
395
|
+
rfi_backlink_transfer = 4 * Cxx
|
|
396
|
+
tmi_backlink_transfer = Cxx * (3 + np.cos(2 * x))
|
|
397
|
+
|
|
398
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
399
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
400
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
401
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
402
|
+
|
|
403
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
404
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
405
|
+
|
|
406
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
407
|
+
return total_noise
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def stochastic_transform(
|
|
411
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
412
|
+
) -> float | np.ndarray:
|
|
413
|
+
__doc__ = (
|
|
414
|
+
"Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
|
|
415
|
+
+ Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
|
|
416
|
+
)
|
|
417
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
418
|
+
# TODO: check these functions for TDI2
|
|
419
|
+
t = 4.0 * x**2 * np.sin(x) ** 2
|
|
420
|
+
return Sh * t
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class Y2TDISens(X2TDISens):
|
|
424
|
+
channel: str = "Y"
|
|
425
|
+
__doc__ = X2TDISens.__doc__
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class Z2TDISens(X2TDISens):
|
|
430
|
+
channel: str = "Z"
|
|
431
|
+
__doc__ = X2TDISens.__doc__
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
class XY2TDISens(Sensitivity):
|
|
435
|
+
"""
|
|
436
|
+
Cross-spectral density (CSD) between X and Y channels for TDI2.
|
|
437
|
+
|
|
438
|
+
From Table II of Nam et al. (2023) for uncorrelated noises:
|
|
439
|
+
- Common factor: C_XY(ω) = -16 sin(ωL) sin³(2ωL)
|
|
440
|
+
- Acceleration contribution: 4 * C_XY * S_pm
|
|
441
|
+
- Optical path contribution (ISI/RFI): C_XY * S_op
|
|
442
|
+
|
|
443
|
+
Total CSD: C_XY * (4*S_pm + S_op)
|
|
444
|
+
|
|
445
|
+
Notes:
|
|
446
|
+
- By circular symmetry, YZ and ZX CSDs have identical transfer functions
|
|
447
|
+
- For equal armlengths, the CSD is real-valued
|
|
448
|
+
- This implements the uncorrelated noise case
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
channel: str = "XY"
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def Cxy(f: float | np.ndarray) -> float | np.ndarray:
|
|
455
|
+
"""Common TDI transform factor for CSD.
|
|
456
|
+
|
|
457
|
+
`arXiv:2211.02539 <https://arxiv.org/pdf/2211.02539>`_.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
f: Frequencyies to evaluate.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Cxy: Transform factor.
|
|
464
|
+
|
|
465
|
+
"""
|
|
466
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
467
|
+
|
|
468
|
+
return -16.0 * np.sin(x) * np.sin(2.0 * x) ** 3
|
|
469
|
+
|
|
470
|
+
@staticmethod
|
|
471
|
+
def transform(
|
|
472
|
+
f: float | np.ndarray,
|
|
473
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
474
|
+
**kwargs: dict,
|
|
475
|
+
) -> float | np.ndarray:
|
|
476
|
+
"""
|
|
477
|
+
Transform from base sensitivity functions (S_pm, S_op) to TDI2 XY CSD.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
f: Frequency array [Hz].
|
|
481
|
+
noise_levels: Current noise levels at frequency ``f``.
|
|
482
|
+
**kwargs: For interoperability.
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Cross-spectral density between X and Y channels.
|
|
486
|
+
|
|
487
|
+
Mathematical form:
|
|
488
|
+
x = 2π(L/c)f [dimensionless frequency]
|
|
489
|
+
C_XY = -16 sin(x) sin³(2x)
|
|
490
|
+
CSD_XY = C_XY * (4*S_pm + S_op)
|
|
491
|
+
"""
|
|
492
|
+
assert noise_levels.units == "relative_frequency"
|
|
493
|
+
Cxy = XY2TDISens.Cxy(f)
|
|
494
|
+
|
|
495
|
+
isi_rfi_readout_transfer = Cxy
|
|
496
|
+
tmi_readout_transfer = Cxy
|
|
497
|
+
tm_transfer = 4 * Cxy
|
|
498
|
+
rfi_backlink_transfer = Cxy
|
|
499
|
+
tmi_backlink_transfer = Cxy
|
|
500
|
+
|
|
501
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
502
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
503
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
504
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
505
|
+
|
|
506
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
507
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
508
|
+
|
|
509
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
510
|
+
return total_noise
|
|
511
|
+
|
|
512
|
+
@staticmethod
|
|
513
|
+
def stochastic_transform(
|
|
514
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
515
|
+
) -> float | np.ndarray:
|
|
516
|
+
"""
|
|
517
|
+
Transform stochastic background to TDI2 XY CSD.
|
|
518
|
+
|
|
519
|
+
Note: For now, using same transform as TDI1 (placeholder).
|
|
520
|
+
TODO: Verify correct stochastic transform for TDI2 CSDs.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
f: Frequency array [Hz].
|
|
524
|
+
Sh: Stochastic signal PSD.
|
|
525
|
+
**kwargs: For interoperability.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Stochastic contribution to CSD.
|
|
529
|
+
"""
|
|
530
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
531
|
+
# Placeholder - using TDI1 form scaled by -0.5
|
|
532
|
+
t = -0.5 * (4.0 * x**2 * np.sin(x) ** 2)
|
|
533
|
+
return Sh * t
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class YZ2TDISens(XY2TDISens):
|
|
537
|
+
"""
|
|
538
|
+
Cross-spectral density (CSD) between Y and Z channels for TDI2.
|
|
539
|
+
|
|
540
|
+
By circular symmetry of the LISA constellation (for equal armlengths),
|
|
541
|
+
this has the same transfer function as XY2TDISens.
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
channel: str = "YZ"
|
|
545
|
+
__doc__ = XY2TDISens.__doc__
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class ZX2TDISens(XY2TDISens):
|
|
550
|
+
"""
|
|
551
|
+
Cross-spectral density (CSD) between Z and X channels for TDI2.
|
|
552
|
+
|
|
553
|
+
By circular symmetry of the LISA constellation (for equal armlengths),
|
|
554
|
+
this has the same transfer function as XY2TDISens.
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
channel: str = "ZX"
|
|
558
|
+
__doc__ = XY2TDISens.__doc__
|
|
559
|
+
pass
|
|
560
|
+
|
|
561
|
+
class A1TDISens(X1TDISens, Sensitivity):
|
|
562
|
+
channel: str = "A"
|
|
563
|
+
|
|
564
|
+
@staticmethod
|
|
565
|
+
def transform(
|
|
566
|
+
f: float | np.ndarray,
|
|
567
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
568
|
+
**kwargs: dict,
|
|
569
|
+
) -> float | np.ndarray:
|
|
570
|
+
__doc__ = (
|
|
571
|
+
"Transform from the base sensitivity functions to the A,E TDI PSDs.\n\n"
|
|
572
|
+
+ Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# these are WRONG
|
|
576
|
+
if np.any(np.asarray([
|
|
577
|
+
noise_levels.rfi_backlink_noise,
|
|
578
|
+
noise_levels.tmi_backlink_noise,
|
|
579
|
+
noise_levels.rfi_oms_noise,
|
|
580
|
+
noise_levels.tmi_oms_noise
|
|
581
|
+
]) != 0.0):
|
|
582
|
+
raise NotImplementedError("ExtendedLISAModel has not been implemented yet for A1/E1/T1.")
|
|
583
|
+
|
|
584
|
+
assert noise_levels.units == "relative_frequency"
|
|
585
|
+
Cxx = X1TDISens.Cxx(f)
|
|
586
|
+
|
|
587
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
588
|
+
|
|
589
|
+
# these are WRONG
|
|
590
|
+
tmi_readout_transfer = Cxx * (2.0 * (1.0 + np.cos(x) ** 2))
|
|
591
|
+
rfi_backlink_transfer = Cxx
|
|
592
|
+
tmi_backlink_transfer = Cxx * (2.0 * (1.0 + np.cos(x) ** 2))
|
|
593
|
+
|
|
594
|
+
# these are right and were changed accordingly
|
|
595
|
+
# Need to find a citation for these 1st gen stuff
|
|
596
|
+
# all that is needed for old model type
|
|
597
|
+
isi_rfi_readout_transfer = 1/2 * Cxx * (2.0 + np.cos(x))
|
|
598
|
+
tm_transfer = Cxx * (3.0 + 2.0 * np.cos(x) + np.cos(2 * x))
|
|
599
|
+
|
|
600
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
601
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
602
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
603
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
604
|
+
|
|
605
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
606
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
607
|
+
|
|
608
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
609
|
+
return total_noise
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def stochastic_transform(
|
|
613
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
614
|
+
) -> float | np.ndarray:
|
|
615
|
+
__doc__ = (
|
|
616
|
+
"Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
|
|
617
|
+
+ Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
|
|
618
|
+
)
|
|
619
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
620
|
+
t = 4.0 * x**2 * np.sin(x) ** 2
|
|
621
|
+
return 1.5 * (Sh * t)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class E1TDISens(A1TDISens):
|
|
625
|
+
channel: str = "E"
|
|
626
|
+
__doc__ = A1TDISens.__doc__
|
|
627
|
+
pass
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class T1TDISens(Sensitivity):
|
|
631
|
+
channel: str = "T"
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def transform(
|
|
635
|
+
f: float | np.ndarray,
|
|
636
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
637
|
+
**kwargs: dict,
|
|
638
|
+
) -> float | np.ndarray:
|
|
639
|
+
__doc__ = (
|
|
640
|
+
"Transform from the base sensitivity functions to the T TDI PSDs.\n\n"
|
|
641
|
+
+ Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
assert noise_levels.units == "relative_frequency"
|
|
645
|
+
|
|
646
|
+
Cxx = X1TDISens.Cxx(f)
|
|
647
|
+
|
|
648
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
649
|
+
|
|
650
|
+
# these are WRONG
|
|
651
|
+
if np.any(np.asarray([
|
|
652
|
+
noise_levels.rfi_backlink_noise,
|
|
653
|
+
noise_levels.tmi_backlink_noise,
|
|
654
|
+
noise_levels.rfi_oms_noise,
|
|
655
|
+
noise_levels.tmi_oms_noise
|
|
656
|
+
]) != 0.0):
|
|
657
|
+
raise NotImplementedError("ExtendedLISAModel has not been implemented yet for A1/E1/T1.")
|
|
658
|
+
tmi_readout_transfer = Cxx * (2.0 * (1.0 + np.cos(x) ** 2))
|
|
659
|
+
rfi_backlink_transfer = Cxx
|
|
660
|
+
tmi_backlink_transfer = Cxx * (2.0 * (1.0 + np.cos(x) ** 2))
|
|
661
|
+
|
|
662
|
+
# these are right and were changed accordingly
|
|
663
|
+
# Need to find a citation for these 1st gen stuff
|
|
664
|
+
# all that is needed for old model type
|
|
665
|
+
isi_rfi_readout_transfer = Cxx * (1 - np.cos(x))
|
|
666
|
+
tm_transfer = 8.0 * Cxx * np.sin(x / 2.) ** 4
|
|
667
|
+
|
|
668
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
669
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
670
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
671
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
672
|
+
|
|
673
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
674
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
675
|
+
|
|
676
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
677
|
+
return total_noise
|
|
678
|
+
|
|
679
|
+
@staticmethod
|
|
680
|
+
def stochastic_transform(
|
|
681
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
682
|
+
) -> float | np.ndarray:
|
|
683
|
+
__doc__ = (
|
|
684
|
+
"Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
|
|
685
|
+
+ Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
|
|
686
|
+
)
|
|
687
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
688
|
+
t = 4.0 * x**2 * np.sin(x) ** 2
|
|
689
|
+
return 0.0 * (Sh * t)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class A2TDISens(X2TDISens, Sensitivity):
|
|
694
|
+
channel: str = "A"
|
|
695
|
+
|
|
696
|
+
@staticmethod
|
|
697
|
+
def transform(
|
|
698
|
+
f: float | np.ndarray,
|
|
699
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
700
|
+
**kwargs: dict,
|
|
701
|
+
) -> float | np.ndarray:
|
|
702
|
+
__doc__ = (
|
|
703
|
+
"Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
|
|
704
|
+
+ Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
assert noise_levels.units == "relative_frequency"
|
|
708
|
+
Cxx = X2TDISens.Cxx(f)
|
|
709
|
+
|
|
710
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
711
|
+
|
|
712
|
+
isi_rfi_readout_transfer = 2. * Cxx * (2 * np.cos(x))
|
|
713
|
+
tmi_readout_transfer = Cxx * (3 + 2 * np.cos(x) + np.cos(2 * x))
|
|
714
|
+
tm_transfer = 4 * Cxx * (3 + 2 * np.cos(x) + np.cos(2 * x))
|
|
715
|
+
rfi_backlink_transfer = 2 * Cxx * (2 * np.cos(x))
|
|
716
|
+
tmi_backlink_transfer = Cxx * (3 + 2 * np.cos(x) + np.cos(2 * x))
|
|
717
|
+
|
|
718
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
719
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
720
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
721
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
722
|
+
|
|
723
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
724
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
725
|
+
|
|
726
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
727
|
+
return total_noise
|
|
728
|
+
|
|
729
|
+
@staticmethod
|
|
730
|
+
def stochastic_transform(
|
|
731
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
732
|
+
) -> float | np.ndarray:
|
|
733
|
+
__doc__ = (
|
|
734
|
+
"Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
|
|
735
|
+
+ Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
|
|
736
|
+
)
|
|
737
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
738
|
+
# TODO: check these functions for TDI2
|
|
739
|
+
t = 4.0 * x**2 * np.sin(x) ** 2
|
|
740
|
+
return Sh * t
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class E2TDISens(A2TDISens):
|
|
744
|
+
channel: str = "E"
|
|
745
|
+
__doc__ = A2TDISens.__doc__
|
|
746
|
+
pass
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class T2TDISens(X2TDISens, Sensitivity):
|
|
750
|
+
channel: str = "T"
|
|
751
|
+
|
|
752
|
+
@staticmethod
|
|
753
|
+
def transform(
|
|
754
|
+
f: float | np.ndarray,
|
|
755
|
+
noise_levels: lisa_models.CurrentNoises,
|
|
756
|
+
**kwargs: dict,
|
|
757
|
+
) -> float | np.ndarray:
|
|
758
|
+
__doc__ = (
|
|
759
|
+
"Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
|
|
760
|
+
+ Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
assert noise_levels.units == "relative_frequency"
|
|
764
|
+
Cxx = X2TDISens.Cxx(f)
|
|
765
|
+
|
|
766
|
+
x = 2 * np.pi * f * L_SI / C_SI
|
|
767
|
+
|
|
768
|
+
isi_rfi_readout_transfer = 4. * Cxx * (1 - np.cos(x))
|
|
769
|
+
tmi_readout_transfer = 8 * Cxx * np.sin(x / 2.) ** 4
|
|
770
|
+
tm_transfer = 32 * Cxx * np.sin(x / 2.) ** 4
|
|
771
|
+
rfi_backlink_transfer = 4. * Cxx * (1 - np.cos(x))
|
|
772
|
+
tmi_backlink_transfer = 8 * Cxx * np.sin(x / 2.) ** 4
|
|
773
|
+
|
|
774
|
+
isi_oms_ffd = isi_rfi_readout_transfer * noise_levels.isi_oms_noise
|
|
775
|
+
rfi_oms_ffd = isi_rfi_readout_transfer * noise_levels.rfi_oms_noise
|
|
776
|
+
tmi_oms_ffd = tmi_readout_transfer * noise_levels.tmi_oms_noise
|
|
777
|
+
tm_noise_ffd = tm_transfer * noise_levels.tm_noise
|
|
778
|
+
|
|
779
|
+
rfi_backlink_ffd = rfi_backlink_transfer * noise_levels.rfi_backlink_noise
|
|
780
|
+
tmi_backlink_ffd = tmi_backlink_transfer * noise_levels.tmi_backlink_noise
|
|
781
|
+
|
|
782
|
+
total_noise = tm_noise_ffd + isi_oms_ffd + rfi_oms_ffd + tmi_oms_ffd + rfi_backlink_ffd + tmi_backlink_ffd
|
|
783
|
+
return total_noise
|
|
784
|
+
|
|
785
|
+
@staticmethod
|
|
786
|
+
def stochastic_transform(
|
|
787
|
+
f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
|
|
788
|
+
) -> float | np.ndarray:
|
|
789
|
+
__doc__ = (
|
|
790
|
+
"Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
|
|
791
|
+
+ Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
|
|
792
|
+
)
|
|
793
|
+
x = 2.0 * np.pi * lisaLT * f
|
|
794
|
+
# TODO: check these functions for TDI2
|
|
795
|
+
t = 4.0 * x**2 * np.sin(x) ** 2
|
|
796
|
+
return Sh * t
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
class LISASens(Sensitivity):
|
|
800
|
+
@classmethod
|
|
801
|
+
def get_Sn(
|
|
802
|
+
cls,
|
|
803
|
+
f: float | np.ndarray,
|
|
804
|
+
model: Optional[lisa_models.LISAModel | str] = lisa_models.sangria,
|
|
805
|
+
average: bool = True,
|
|
806
|
+
**kwargs: dict,
|
|
807
|
+
) -> float | np.ndarray:
|
|
808
|
+
"""Compute the base LISA sensitivity function.
|
|
809
|
+
|
|
810
|
+
Args:
|
|
811
|
+
f: Frequency array.
|
|
812
|
+
model: Noise model. Object of type :class:`lisa_models.LISAModel`. It can also be a string corresponding to one of the stock models.
|
|
813
|
+
average: Whether to apply averaging factors to sensitivity curve.
|
|
814
|
+
Antenna response: ``av_resp = np.sqrt(5) if average else 1.0``
|
|
815
|
+
Projection effect: ``Proj = 2.0 / np.sqrt(3) if average else 1.0``
|
|
816
|
+
**kwargs: Keyword arguments to pass to :func:`get_stochastic_contribution`. # TODO: fix
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
Sensitivity array.
|
|
820
|
+
|
|
821
|
+
"""
|
|
822
|
+
model = lisa_models.check_lisa_model(model)
|
|
823
|
+
|
|
824
|
+
if not isinstance(model, lisa_models.LISAModel):
|
|
825
|
+
raise NotImplementedError("This function has not been implemented for ExtendedLISAModel yet.")
|
|
826
|
+
|
|
827
|
+
# get noise values
|
|
828
|
+
noise_values = model.lisanoises(f, unit="displacement")
|
|
829
|
+
|
|
830
|
+
Sa_d = noise_values.tm_noise
|
|
831
|
+
Sop = noise_values.isi_oms_noise
|
|
832
|
+
|
|
833
|
+
all_m = np.sqrt(4.0 * Sa_d + Sop)
|
|
834
|
+
## Average the antenna response
|
|
835
|
+
av_resp = np.sqrt(5) if average else 1.0
|
|
836
|
+
|
|
837
|
+
## Projection effect
|
|
838
|
+
Proj = 2.0 / np.sqrt(3) if average else 1.0
|
|
839
|
+
|
|
840
|
+
## Approximative transfer function
|
|
841
|
+
f0 = 1.0 / (2.0 * lisaLT)
|
|
842
|
+
a = 0.41
|
|
843
|
+
T = np.sqrt(1 + (f / (a * f0)) ** 2)
|
|
844
|
+
sens = (av_resp * Proj * T * all_m / lisaL) ** 2
|
|
845
|
+
|
|
846
|
+
# will add zero if ignored
|
|
847
|
+
sens += cls.get_stochastic_contribution(f, **kwargs)
|
|
848
|
+
return sens
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
class CornishLISASens(LISASens):
|
|
852
|
+
"""PSD from https://arxiv.org/pdf/1803.01944.pdf
|
|
853
|
+
|
|
854
|
+
Power Spectral Density for the LISA detector assuming it has been active for a year.
|
|
855
|
+
I found an analytic version in one of Niel Cornish's paper which he submitted to the arXiv in
|
|
856
|
+
2018. I evaluate the PSD at the frequency bins found in the signal FFT.
|
|
857
|
+
|
|
858
|
+
PSD obtained from: https://arxiv.org/pdf/1803.01944.pdf
|
|
859
|
+
|
|
860
|
+
"""
|
|
861
|
+
|
|
862
|
+
@staticmethod
|
|
863
|
+
def get_Sn(
|
|
864
|
+
f: float | np.ndarray, average: bool = True, **kwargs: dict
|
|
865
|
+
) -> float | np.ndarray:
|
|
866
|
+
# TODO: documentation here
|
|
867
|
+
|
|
868
|
+
sky_averaging_constant = 20.0 / 3.0 if average else 1.0
|
|
869
|
+
|
|
870
|
+
L = 2.5 * 10**9 # Length of LISA arm
|
|
871
|
+
f0 = 19.09 * 10 ** (-3) # transfer frequency
|
|
872
|
+
|
|
873
|
+
# Optical Metrology Sensor
|
|
874
|
+
Poms = ((1.5e-11) * (1.5e-11)) * (1 + np.power((2e-3) / f, 4))
|
|
875
|
+
|
|
876
|
+
# Acceleration Noise
|
|
877
|
+
Pacc = (
|
|
878
|
+
(3e-15)
|
|
879
|
+
* (3e-15)
|
|
880
|
+
* (1 + (4e-4 / f) * (4e-4 / f))
|
|
881
|
+
* (1 + np.power(f / (8e-3), 4))
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
# constants for Galactic background after 1 year of observation
|
|
885
|
+
alpha = 0.171
|
|
886
|
+
beta = 292
|
|
887
|
+
k = 1020
|
|
888
|
+
gamma = 1680
|
|
889
|
+
f_k = 0.00215
|
|
890
|
+
|
|
891
|
+
# Galactic background contribution
|
|
892
|
+
Sc = (
|
|
893
|
+
9e-45
|
|
894
|
+
* np.power(f, -7 / 3)
|
|
895
|
+
* np.exp(-np.power(f, alpha) + beta * f * np.sin(k * f))
|
|
896
|
+
* (1 + np.tanh(gamma * (f_k - f)))
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
# PSD
|
|
900
|
+
PSD = (sky_averaging_constant) * (
|
|
901
|
+
(10 / (3 * L * L))
|
|
902
|
+
* (Poms + (4 * Pacc) / (np.power(2 * np.pi * f, 4)))
|
|
903
|
+
* (1 + 0.6 * (f / f0) * (f / f0))
|
|
904
|
+
+ Sc
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
return PSD
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
class FlatPSDFunction(LISASens):
|
|
911
|
+
"""White Noise PSD function."""
|
|
912
|
+
|
|
913
|
+
@classmethod
|
|
914
|
+
def get_Sn(
|
|
915
|
+
cls, f: float | np.ndarray, val: float, **kwargs: dict
|
|
916
|
+
) -> float | np.ndarray:
|
|
917
|
+
# TODO: documentation here
|
|
918
|
+
xp = cls.get_xp(f)
|
|
919
|
+
out = xp.full_like(f, val)
|
|
920
|
+
if isinstance(f, float):
|
|
921
|
+
out = out.item()
|
|
922
|
+
return out
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
class SensitivityMatrix:
|
|
926
|
+
"""Container to hold sensitivity information.
|
|
927
|
+
|
|
928
|
+
Args:
|
|
929
|
+
f: Frequency array.
|
|
930
|
+
sens_mat: Input sensitivity list. The shape of the nested lists should represent the shape of the
|
|
931
|
+
desired matrix. Each entry in the list must be an array, :class:`Sensitivity`-derived object,
|
|
932
|
+
or a string corresponding to the :class:`Sensitivity` object.
|
|
933
|
+
**sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
|
|
934
|
+
|
|
935
|
+
"""
|
|
936
|
+
|
|
937
|
+
def __init__(
|
|
938
|
+
self,
|
|
939
|
+
f: np.ndarray,
|
|
940
|
+
sens_mat: (
|
|
941
|
+
List[List[np.ndarray | Sensitivity]]
|
|
942
|
+
| List[np.ndarray | Sensitivity]
|
|
943
|
+
| np.ndarray
|
|
944
|
+
| Sensitivity
|
|
945
|
+
),
|
|
946
|
+
*sens_args: tuple,
|
|
947
|
+
sens_kwargs_mat = None,
|
|
948
|
+
**sens_kwargs: dict,
|
|
949
|
+
) -> None:
|
|
950
|
+
self.frequency_arr = f
|
|
951
|
+
self.data_length = len(self.frequency_arr)
|
|
952
|
+
self.sens_args = sens_args
|
|
953
|
+
if sens_kwargs_mat is None:
|
|
954
|
+
self.sens_kwargs = sens_kwargs
|
|
955
|
+
else:
|
|
956
|
+
self.sens_kwargs = sens_kwargs_mat
|
|
957
|
+
|
|
958
|
+
self.sens_mat = sens_mat
|
|
959
|
+
|
|
960
|
+
@property
|
|
961
|
+
def frequency_arr(self) -> np.ndarray:
|
|
962
|
+
return self._frequency_arr
|
|
963
|
+
|
|
964
|
+
@frequency_arr.setter
|
|
965
|
+
def frequency_arr(self, frequency_arr: np.ndarray) -> None:
|
|
966
|
+
assert frequency_arr.dtype == np.float64 or frequency_arr.dtype == float
|
|
967
|
+
assert frequency_arr.ndim == 1
|
|
968
|
+
self._frequency_arr = frequency_arr
|
|
969
|
+
|
|
970
|
+
def check_update(self):
|
|
971
|
+
if not self.can_redo:
|
|
972
|
+
raise ValueError("Cannot update sensitivities because original input was arrays rather than functions.")
|
|
973
|
+
|
|
974
|
+
def update_frequency_arr(self, frequency_arr: np.ndarray) -> None:
|
|
975
|
+
"""Update class with new frequency array.
|
|
976
|
+
|
|
977
|
+
Args:
|
|
978
|
+
frequency_arr: Frequency array.
|
|
979
|
+
|
|
980
|
+
"""
|
|
981
|
+
self.check_update()
|
|
982
|
+
self.frequency_arr = frequency_arr
|
|
983
|
+
self.sens_mat = self.sens_mat_input
|
|
984
|
+
|
|
985
|
+
def update_model(self, model: lisa_models.LISAModel | list | np.ndarray) -> None:
|
|
986
|
+
"""Update class with new sensitivity model.
|
|
987
|
+
|
|
988
|
+
Args:
|
|
989
|
+
model: Noise model. Object of type :class:`lisa_models.LISAModel`. It can also be a string corresponding to one of the stock models.
|
|
990
|
+
|
|
991
|
+
"""
|
|
992
|
+
self.check_update()
|
|
993
|
+
for tmp_kwargs in self.sens_kwargs.flatten():
|
|
994
|
+
tmp_kwargs["model"] = model
|
|
995
|
+
self.sens_mat = self.sens_mat_input
|
|
996
|
+
|
|
997
|
+
def update_stochastic(self, **kwargs: dict) -> None:
|
|
998
|
+
"""Update class with new stochastic function.
|
|
999
|
+
|
|
1000
|
+
Args:
|
|
1001
|
+
**kwargs: Keyword arguments update for :func:`lisatools.sensitivity.Sensitivity.get_stochastic_contribution`.
|
|
1002
|
+
This operation will combine the new and old kwarg dictionaries, updating any
|
|
1003
|
+
old information with any added corresponding new information. **Note**: any old information
|
|
1004
|
+
that is not updated will remain in place.
|
|
1005
|
+
|
|
1006
|
+
"""
|
|
1007
|
+
self.check_update()
|
|
1008
|
+
tmptmp = self.sens_kwargs.flatten()
|
|
1009
|
+
for i, tmp_kwargs in tmptmp:
|
|
1010
|
+
tmptmp[i] = {**tmp_kwargs, **kwargs}
|
|
1011
|
+
self.sens_kwargs = tmptmp.reshape(self.sens_kwargs.shape)
|
|
1012
|
+
self.sens_mat = self.sens_mat_input
|
|
1013
|
+
|
|
1014
|
+
@property
|
|
1015
|
+
def sens_mat(self) -> np.ndarray:
|
|
1016
|
+
"""Get sensitivity matrix."""
|
|
1017
|
+
return self._sens_mat
|
|
1018
|
+
|
|
1019
|
+
@sens_mat.setter
|
|
1020
|
+
def sens_mat(
|
|
1021
|
+
self,
|
|
1022
|
+
sens_mat: (
|
|
1023
|
+
List[List[np.ndarray | Sensitivity]]
|
|
1024
|
+
| List[np.ndarray | Sensitivity]
|
|
1025
|
+
| np.ndarray
|
|
1026
|
+
| Sensitivity
|
|
1027
|
+
),
|
|
1028
|
+
) -> None:
|
|
1029
|
+
"""Set sensitivity matrix."""
|
|
1030
|
+
|
|
1031
|
+
if (isinstance(sens_mat, np.ndarray) or isinstance(
|
|
1032
|
+
sens_mat, cp.ndarray)
|
|
1033
|
+
) and sens_mat.dtype != object:
|
|
1034
|
+
self._sens_mat = sens_mat
|
|
1035
|
+
if not hasattr(self, "sens_mat_input"):
|
|
1036
|
+
self.can_redo = False
|
|
1037
|
+
else:
|
|
1038
|
+
self.can_redo = True
|
|
1039
|
+
|
|
1040
|
+
elif isinstance(sens_mat, list) or (isinstance(sens_mat, np.ndarray) and sens_mat.dtype == object):
|
|
1041
|
+
self.sens_mat_input = deepcopy(sens_mat)
|
|
1042
|
+
_run = True
|
|
1043
|
+
_layer = self.sens_mat_input
|
|
1044
|
+
outer_shape = [len(_layer)]
|
|
1045
|
+
while _run:
|
|
1046
|
+
_test_length = None
|
|
1047
|
+
_type_1 = None
|
|
1048
|
+
for tmp in _layer:
|
|
1049
|
+
# check each entry is the same type
|
|
1050
|
+
if _type_1 is None:
|
|
1051
|
+
_type_1 = type(tmp)
|
|
1052
|
+
else:
|
|
1053
|
+
if _type_1 != type(tmp):
|
|
1054
|
+
raise ValueError("List inputs must be all of the same type.")
|
|
1055
|
+
|
|
1056
|
+
if isinstance(tmp, list):
|
|
1057
|
+
if _test_length is None:
|
|
1058
|
+
_test_length = len(tmp)
|
|
1059
|
+
else:
|
|
1060
|
+
if len(tmp) != _test_length:
|
|
1061
|
+
raise ValueError("Input list structure is not Rectangular.")
|
|
1062
|
+
elif isinstance(tmp, np.ndarray) or isinstance(tmp, cp.ndarray):
|
|
1063
|
+
if tmp.ndim > 1:
|
|
1064
|
+
raise ValueError("If entering a list of arrays, arrays must be 1D on the last dimension of the list structure.")
|
|
1065
|
+
if _test_length is None:
|
|
1066
|
+
_test_length = len(tmp)
|
|
1067
|
+
else:
|
|
1068
|
+
if len(tmp) != _test_length:
|
|
1069
|
+
raise ValueError("Input list/array structure is not Rectangular.")
|
|
1070
|
+
|
|
1071
|
+
if isinstance(_layer[0], list):
|
|
1072
|
+
outer_shape.append(len(_layer[0]))
|
|
1073
|
+
_layer = _layer[0]
|
|
1074
|
+
continue
|
|
1075
|
+
|
|
1076
|
+
elif isinstance(_layer[0], np.ndarray) or isinstance(_layer[0], cp.ndarray):
|
|
1077
|
+
# hit the array, must be last layer
|
|
1078
|
+
_run = False
|
|
1079
|
+
self.can_redo = False
|
|
1080
|
+
self.is_array_base = True
|
|
1081
|
+
continue
|
|
1082
|
+
|
|
1083
|
+
# TODO: better way to do this?
|
|
1084
|
+
elif hasattr(_layer[0], "get_Sn"):
|
|
1085
|
+
_run = False
|
|
1086
|
+
self.can_redo = True
|
|
1087
|
+
self.is_array_base = False
|
|
1088
|
+
continue
|
|
1089
|
+
|
|
1090
|
+
elif isinstance(_layer[0], str):
|
|
1091
|
+
_run = False
|
|
1092
|
+
self.can_redo = True
|
|
1093
|
+
self.is_array_base = False
|
|
1094
|
+
sensitivity = check_sensitivity(_layer[0])
|
|
1095
|
+
assert hasattr(sensitivity, "get_Sn")
|
|
1096
|
+
continue
|
|
1097
|
+
|
|
1098
|
+
else:
|
|
1099
|
+
raise ValueError("Matrix element must be Sensitivity object, string representing a sensitivity object, or an array with values.")
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
if isinstance(self.sens_kwargs, np.ndarray) or isinstance(self.sens_kwargs, list):
|
|
1103
|
+
tmp_kwargs = np.asarray(self.sens_kwargs, dtype=object)
|
|
1104
|
+
assert tmp_kwargs.shape == tuple(outer_shape)
|
|
1105
|
+
|
|
1106
|
+
elif isinstance(self.sens_kwargs, dict):
|
|
1107
|
+
tmp_kwargs = np.full(outer_shape, self.sens_kwargs, dtype=object)
|
|
1108
|
+
else:
|
|
1109
|
+
raise ValueError("sens_kwargs Must be numpy object array, list, or dict.")
|
|
1110
|
+
|
|
1111
|
+
# TODO: sens_kwargs property setup
|
|
1112
|
+
self.sens_kwargs = tmp_kwargs
|
|
1113
|
+
|
|
1114
|
+
num_components = np.prod(outer_shape).item()
|
|
1115
|
+
xp = get_array_module(self.frequency_arr)
|
|
1116
|
+
if self.is_array_base:
|
|
1117
|
+
_sens_mat = xp.asarray(sens_mat)
|
|
1118
|
+
|
|
1119
|
+
else:
|
|
1120
|
+
_flattened_arr = np.asarray(sens_mat, dtype=object).flatten()
|
|
1121
|
+
_sens_mat = xp.zeros((num_components, len(self.frequency_arr)))
|
|
1122
|
+
for i, matrix_member in enumerate(_flattened_arr):
|
|
1123
|
+
# calculate it
|
|
1124
|
+
if hasattr(matrix_member, "get_Sn") or isinstance(matrix_member, str):
|
|
1125
|
+
_sens_mat[i, :] = get_sensitivity(
|
|
1126
|
+
self.frequency_arr,
|
|
1127
|
+
*self.sens_args,
|
|
1128
|
+
sens_fn=matrix_member,
|
|
1129
|
+
**self.sens_kwargs.flatten()[i],
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
else:
|
|
1133
|
+
raise ValueError
|
|
1134
|
+
|
|
1135
|
+
# setup in array form
|
|
1136
|
+
self._sens_mat = _sens_mat.reshape(tuple(outer_shape) + (len(self.frequency_arr),))
|
|
1137
|
+
|
|
1138
|
+
else:
|
|
1139
|
+
raise ValueError("Must input array or list.")
|
|
1140
|
+
|
|
1141
|
+
self._setup_det_and_inv()
|
|
1142
|
+
|
|
1143
|
+
def _setup_det_and_inv(self):
|
|
1144
|
+
# setup detC
|
|
1145
|
+
"""Determinant of TDI matrix."""
|
|
1146
|
+
if self.sens_mat.ndim < 3:
|
|
1147
|
+
self.detC = self.sens_mat
|
|
1148
|
+
self.invC = 1/self.sens_mat
|
|
1149
|
+
|
|
1150
|
+
else:
|
|
1151
|
+
xp = get_array_module(self.sens_mat)
|
|
1152
|
+
self.detC = xp.linalg.det(self.sens_mat.transpose(2, 0, 1))
|
|
1153
|
+
invC = xp.zeros_like(self.sens_mat.transpose(2, 0, 1))
|
|
1154
|
+
if xp.all(self.detC == 0.0):
|
|
1155
|
+
raise ValueError("All determinants are zero.")
|
|
1156
|
+
|
|
1157
|
+
invC[self.detC != 0.0] = xp.linalg.inv(self.sens_mat.transpose(2, 0, 1)[self.detC != 0.0])
|
|
1158
|
+
invC[self.detC == 0.0] = 1e-100
|
|
1159
|
+
self.invC = invC.transpose(1, 2, 0)
|
|
1160
|
+
|
|
1161
|
+
xp = get_array_module(self.sens_mat)
|
|
1162
|
+
|
|
1163
|
+
# setup detC
|
|
1164
|
+
"""Determinant and inverse of TDI matrix."""
|
|
1165
|
+
if self.sens_mat.ndim < 3:
|
|
1166
|
+
self.detC = xp.prod(self.sens_mat, axis=0)
|
|
1167
|
+
self.invC = 1 / self.sens_mat
|
|
1168
|
+
|
|
1169
|
+
else:
|
|
1170
|
+
self.detC = xp.linalg.det(self.sens_mat.transpose(2, 0, 1))
|
|
1171
|
+
invC = xp.zeros_like(self.sens_mat.transpose(2, 0, 1))
|
|
1172
|
+
invC[self.detC != 0.0] = xp.linalg.inv(
|
|
1173
|
+
self.sens_mat.transpose(2, 0, 1)[self.detC != 0.0]
|
|
1174
|
+
)
|
|
1175
|
+
invC[self.detC == 0.0] = 1e-100
|
|
1176
|
+
self.invC = invC.transpose(1, 2, 0)
|
|
1177
|
+
|
|
1178
|
+
def __getitem__(self, index: Any) -> np.ndarray:
|
|
1179
|
+
"""Indexing the class indexes the array."""
|
|
1180
|
+
return self.sens_mat[index]
|
|
1181
|
+
|
|
1182
|
+
def __setitem__(self, index: Any, value: np.ndarray) -> np.ndarray:
|
|
1183
|
+
"""Indexing the class indexes the array."""
|
|
1184
|
+
self.sens_mat[index] = value
|
|
1185
|
+
self._setup_det_and_inv()
|
|
1186
|
+
|
|
1187
|
+
@property
|
|
1188
|
+
def ndim(self) -> int:
|
|
1189
|
+
"""Dimensionality of sens mat array."""
|
|
1190
|
+
return self.sens_mat.ndim
|
|
1191
|
+
|
|
1192
|
+
def flatten(self) -> np.ndarray:
|
|
1193
|
+
"""Flatten sens mat array."""
|
|
1194
|
+
return self.sens_mat.reshape(-1, self.sens_mat.shape[-1])
|
|
1195
|
+
|
|
1196
|
+
@property
|
|
1197
|
+
def shape(self) -> tuple:
|
|
1198
|
+
"""Shape of sens mat array."""
|
|
1199
|
+
return self.sens_mat.shape
|
|
1200
|
+
|
|
1201
|
+
def loglog(
|
|
1202
|
+
self,
|
|
1203
|
+
ax: Optional[plt.Axes] = None,
|
|
1204
|
+
fig: Optional[plt.Figure] = None,
|
|
1205
|
+
inds: Optional[int | tuple] = None,
|
|
1206
|
+
char_strain: Optional[bool] = False,
|
|
1207
|
+
**kwargs: dict,
|
|
1208
|
+
) -> Tuple[plt.Figure, plt.Axes]:
|
|
1209
|
+
"""Produce a log-log plot of the sensitivity.
|
|
1210
|
+
|
|
1211
|
+
Args:
|
|
1212
|
+
ax: Matplotlib Axes objects to add plots. Either a list of Axes objects or a single Axes object.
|
|
1213
|
+
fig: Matplotlib figure object.
|
|
1214
|
+
inds: Integer index to select out which data to add to a single access.
|
|
1215
|
+
A list can be provided if ax is a list. They must be the same length.
|
|
1216
|
+
char_strain: If ``True``, plot in characteristic strain representation. **Note**: assumes the sensitivity
|
|
1217
|
+
is input as power spectral density.
|
|
1218
|
+
**kwargs: Keyword arguments to be passed to ``loglog`` function in matplotlib.
|
|
1219
|
+
|
|
1220
|
+
Returns:
|
|
1221
|
+
Matplotlib figure and axes objects in a 2-tuple.
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
"""
|
|
1225
|
+
if (ax is None and fig is None) or (
|
|
1226
|
+
ax is not None and (isinstance(ax, list) or isinstance(ax, np.ndarray))
|
|
1227
|
+
):
|
|
1228
|
+
if ax is None and fig is None:
|
|
1229
|
+
outer_shape = self.shape[:-1]
|
|
1230
|
+
if len(outer_shape) == 2:
|
|
1231
|
+
nrows = outer_shape[0]
|
|
1232
|
+
ncols = outer_shape[1]
|
|
1233
|
+
elif len(outer_shape) == 1:
|
|
1234
|
+
nrows = 1
|
|
1235
|
+
ncols = outer_shape[0]
|
|
1236
|
+
|
|
1237
|
+
fig, ax = plt.subplots(nrows, ncols, sharex=True, sharey=True)
|
|
1238
|
+
try:
|
|
1239
|
+
ax = ax.ravel()
|
|
1240
|
+
except AttributeError:
|
|
1241
|
+
ax = [ax] # just one axis object, no list
|
|
1242
|
+
|
|
1243
|
+
else:
|
|
1244
|
+
assert len(ax) == np.prod(self.shape[:-1])
|
|
1245
|
+
|
|
1246
|
+
for i in range(np.prod(self.shape[:-1])):
|
|
1247
|
+
plot_in = self.flatten()[i]
|
|
1248
|
+
if char_strain:
|
|
1249
|
+
plot_in = np.sqrt(self.frequency_arr * plot_in)
|
|
1250
|
+
ax[i].loglog(self.frequency_arr, plot_in, **kwargs)
|
|
1251
|
+
|
|
1252
|
+
elif fig is not None:
|
|
1253
|
+
raise NotImplementedError
|
|
1254
|
+
|
|
1255
|
+
elif isinstance(ax, plt.axes):
|
|
1256
|
+
if inds is None:
|
|
1257
|
+
raise ValueError(
|
|
1258
|
+
"When passing a single axes object for `ax`, but also pass `inds` kwarg."
|
|
1259
|
+
)
|
|
1260
|
+
plot_in = self.sens_mat[inds]
|
|
1261
|
+
if char_strain:
|
|
1262
|
+
plot_in = np.sqrt(self.frequency_arr * plot_in)
|
|
1263
|
+
ax.loglog(self.frequency_arr, plot_in, **kwargs)
|
|
1264
|
+
|
|
1265
|
+
else:
|
|
1266
|
+
raise ValueError(
|
|
1267
|
+
"ax must be a list of axes objects or a single axes object."
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
return (fig, ax)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
class XYZ1SensitivityMatrix(SensitivityMatrix):
|
|
1274
|
+
"""Default sensitivity matrix for XYZ (TDI 1)
|
|
1275
|
+
|
|
1276
|
+
This is 3x3 symmetric matrix.
|
|
1277
|
+
|
|
1278
|
+
Args:
|
|
1279
|
+
f: Frequency array.
|
|
1280
|
+
**sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
|
|
1281
|
+
|
|
1282
|
+
"""
|
|
1283
|
+
|
|
1284
|
+
def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
|
|
1285
|
+
sens_mat = [
|
|
1286
|
+
[X1TDISens, XY1TDISens, ZX1TDISens],
|
|
1287
|
+
[XY1TDISens, Y1TDISens, YZ1TDISens],
|
|
1288
|
+
[ZX1TDISens, YZ1TDISens, Z1TDISens],
|
|
1289
|
+
]
|
|
1290
|
+
super().__init__(f, sens_mat, **sens_kwargs)
|
|
1291
|
+
|
|
1292
|
+
class XYZ2SensitivityMatrix(SensitivityMatrix):
|
|
1293
|
+
"""
|
|
1294
|
+
Default sensitivity matrix for XYZ channels using TDI2 transfer functions.
|
|
1295
|
+
|
|
1296
|
+
This creates a 3×3 Hermitian covariance matrix accounting for correlations
|
|
1297
|
+
between the X, Y, and Z TDI channels due to shared noise sources (S_pm and S_op).
|
|
1298
|
+
|
|
1299
|
+
Matrix structure:
|
|
1300
|
+
Σ(f) = [ Σ_XX Σ_XY Σ_XZ ]
|
|
1301
|
+
[ Σ_YX Σ_YY Σ_YZ ] at each frequency
|
|
1302
|
+
[ Σ_ZX Σ_ZY Σ_ZZ ]
|
|
1303
|
+
|
|
1304
|
+
Args:
|
|
1305
|
+
f: Frequency array [Hz].
|
|
1306
|
+
**sens_kwargs: Keyword arguments to pass to Sensitivity.get_Sn()
|
|
1307
|
+
(e.g., model=lisa_models.sangria).
|
|
1308
|
+
|
|
1309
|
+
Notes:
|
|
1310
|
+
- Inherits matrix inversion and determinant computation from SensitivityMatrix
|
|
1311
|
+
- The invC attribute provides Σ⁻¹(f) for likelihood computations
|
|
1312
|
+
- The detC attribute provides det[Σ(f)] for normalization
|
|
1313
|
+
"""
|
|
1314
|
+
|
|
1315
|
+
def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
|
|
1316
|
+
"""
|
|
1317
|
+
Initialize TDI2 sensitivity matrix.
|
|
1318
|
+
|
|
1319
|
+
Args:
|
|
1320
|
+
f: Frequency array [Hz].
|
|
1321
|
+
**sens_kwargs: Keyword arguments for Sensitivity.get_Sn()
|
|
1322
|
+
Common kwargs:
|
|
1323
|
+
- model: LISA noise model (e.g., sangria, sangria)
|
|
1324
|
+
- stochastic_params: Parameters for galactic foreground
|
|
1325
|
+
- stochastic_function: Custom stochastic function
|
|
1326
|
+
"""
|
|
1327
|
+
# Define 3×3 matrix structure
|
|
1328
|
+
# Diagonal: X2, Y2, Z2 PSDs
|
|
1329
|
+
# Off-diagonal: XY2, YZ2, ZX2 CSDs
|
|
1330
|
+
sens_mat = [
|
|
1331
|
+
[X2TDISens, XY2TDISens, ZX2TDISens],
|
|
1332
|
+
[XY2TDISens, Y2TDISens, YZ2TDISens],
|
|
1333
|
+
[ZX2TDISens, YZ2TDISens, Z2TDISens],
|
|
1334
|
+
]
|
|
1335
|
+
|
|
1336
|
+
super().__init__(f, sens_mat, **sens_kwargs)
|
|
1337
|
+
|
|
1338
|
+
class AET1SensitivityMatrix(SensitivityMatrix):
|
|
1339
|
+
"""Default sensitivity matrix for AET (TDI 1)
|
|
1340
|
+
|
|
1341
|
+
This is just an array because no cross-terms.
|
|
1342
|
+
|
|
1343
|
+
Args:
|
|
1344
|
+
f: Frequency array.
|
|
1345
|
+
**sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
|
|
1346
|
+
|
|
1347
|
+
"""
|
|
1348
|
+
|
|
1349
|
+
def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
|
|
1350
|
+
sens_mat = [A1TDISens, E1TDISens, T1TDISens]
|
|
1351
|
+
super().__init__(f, sens_mat, **sens_kwargs)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
class AET2SensitivityMatrix(SensitivityMatrix):
|
|
1356
|
+
"""Default sensitivity matrix for AET (TDI 2)
|
|
1357
|
+
|
|
1358
|
+
This is just an array because no cross-terms.
|
|
1359
|
+
|
|
1360
|
+
Args:
|
|
1361
|
+
f: Frequency array.
|
|
1362
|
+
**sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
|
|
1363
|
+
|
|
1364
|
+
"""
|
|
1365
|
+
|
|
1366
|
+
def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
|
|
1367
|
+
sens_mat = [A2TDISens, E2TDISens, T2TDISens]
|
|
1368
|
+
super().__init__(f, sens_mat, **sens_kwargs)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
class AE1SensitivityMatrix(SensitivityMatrix):
|
|
1372
|
+
"""Default sensitivity matrix for AE (no T) (TDI 1)
|
|
1373
|
+
|
|
1374
|
+
Args:
|
|
1375
|
+
f: Frequency array.
|
|
1376
|
+
**sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
|
|
1377
|
+
|
|
1378
|
+
"""
|
|
1379
|
+
|
|
1380
|
+
def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
|
|
1381
|
+
sens_mat = [A1TDISens, E1TDISens]
|
|
1382
|
+
super().__init__(f, sens_mat, **sens_kwargs)
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
class AE2SensitivityMatrix(SensitivityMatrix):
|
|
1386
|
+
"""Default sensitivity matrix for AE (no T) (TDI 1)
|
|
1387
|
+
|
|
1388
|
+
Args:
|
|
1389
|
+
f: Frequency array.
|
|
1390
|
+
**sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
|
|
1391
|
+
|
|
1392
|
+
"""
|
|
1393
|
+
|
|
1394
|
+
def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
|
|
1395
|
+
sens_mat = [A2TDISens, E2TDISens]
|
|
1396
|
+
super().__init__(f, sens_mat, **sens_kwargs)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
class LISASensSensitivityMatrix(SensitivityMatrix):
|
|
1400
|
+
"""Default sensitivity matrix adding :class:`LISASens` for the specified number of channels.
|
|
1401
|
+
|
|
1402
|
+
Args:
|
|
1403
|
+
f: Frequency array.
|
|
1404
|
+
nchannels: Number of channels.
|
|
1405
|
+
**sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
|
|
1406
|
+
|
|
1407
|
+
"""
|
|
1408
|
+
|
|
1409
|
+
def __init__(self, f: np.ndarray, nchannels: int, **sens_kwargs: dict) -> None:
|
|
1410
|
+
sens_mat = [LISASens for _ in range(nchannels)]
|
|
1411
|
+
super().__init__(f, sens_mat, **sens_kwargs)
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def get_sensitivity(
|
|
1415
|
+
f: float | np.ndarray,
|
|
1416
|
+
*args: tuple,
|
|
1417
|
+
sens_fn: Optional[Sensitivity | str] = LISASens,
|
|
1418
|
+
return_type="PSD",
|
|
1419
|
+
fill_nans: float = 1e10,
|
|
1420
|
+
**kwargs,
|
|
1421
|
+
) -> float | np.ndarray:
|
|
1422
|
+
"""Generic sensitivity generator
|
|
1423
|
+
|
|
1424
|
+
Same interface to many sensitivity curves.
|
|
1425
|
+
|
|
1426
|
+
Args:
|
|
1427
|
+
f: Frequency array.
|
|
1428
|
+
*args: Any additional arguments for the sensitivity function ``get_Sn`` method.
|
|
1429
|
+
sens_fn: String or class that represents the name of the desired PSD function.
|
|
1430
|
+
return_type: Described the desired output. Choices are ASD,
|
|
1431
|
+
PSD, or char_strain (characteristic strain). Default is ASD.
|
|
1432
|
+
fill_nans: Value to fill nans in sensitivity (at 0 frequency).
|
|
1433
|
+
If ``None``, thens nans will be left in the array.
|
|
1434
|
+
**kwargs: Keyword arguments to pass to sensitivity function ``get_Sn`` method.
|
|
1435
|
+
|
|
1436
|
+
Return:
|
|
1437
|
+
Sensitivity values.
|
|
1438
|
+
|
|
1439
|
+
"""
|
|
1440
|
+
|
|
1441
|
+
if isinstance(sens_fn, str):
|
|
1442
|
+
sensitivity = check_sensitivity(sens_fn)
|
|
1443
|
+
|
|
1444
|
+
elif hasattr(sens_fn, "get_Sn"):
|
|
1445
|
+
sensitivity = sens_fn
|
|
1446
|
+
|
|
1447
|
+
else:
|
|
1448
|
+
raise ValueError(
|
|
1449
|
+
"sens_fn must be a string for a stock option or a class with a get_Sn method."
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
PSD = sensitivity.get_Sn(f, *args, **kwargs)
|
|
1453
|
+
|
|
1454
|
+
if fill_nans is not None:
|
|
1455
|
+
assert isinstance(fill_nans, float)
|
|
1456
|
+
PSD[np.isnan(PSD)] = fill_nans
|
|
1457
|
+
|
|
1458
|
+
if return_type == "PSD":
|
|
1459
|
+
return PSD
|
|
1460
|
+
|
|
1461
|
+
elif return_type == "ASD":
|
|
1462
|
+
return PSD ** (1 / 2)
|
|
1463
|
+
|
|
1464
|
+
elif return_type == "char_strain":
|
|
1465
|
+
return (f * PSD) ** (1 / 2)
|
|
1466
|
+
|
|
1467
|
+
else:
|
|
1468
|
+
raise ValueError("return_type must be PSD, ASD, or char_strain.")
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
__stock_sens_options__ = [
|
|
1472
|
+
"X1TDISens",
|
|
1473
|
+
"Y1TDISens",
|
|
1474
|
+
"Z1TDISens",
|
|
1475
|
+
"XY1TDISens",
|
|
1476
|
+
"YZ1TDISens",
|
|
1477
|
+
"ZX1TDISens",
|
|
1478
|
+
"A1TDISens",
|
|
1479
|
+
"E1TDISens",
|
|
1480
|
+
"T1TDISens",
|
|
1481
|
+
"X2TDISens",
|
|
1482
|
+
"Y2TDISens",
|
|
1483
|
+
"Z2TDISens",
|
|
1484
|
+
"XY2TDISens",
|
|
1485
|
+
"YZ2TDISens",
|
|
1486
|
+
"ZX2TDISens",
|
|
1487
|
+
"LISASens",
|
|
1488
|
+
"CornishLISASens",
|
|
1489
|
+
"FlatPSDFunction",
|
|
1490
|
+
]
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def get_stock_sensitivity_options() -> List[Sensitivity]:
|
|
1494
|
+
"""Get stock options for sensitivity curves.
|
|
1495
|
+
|
|
1496
|
+
Returns:
|
|
1497
|
+
List of stock sensitivity options.
|
|
1498
|
+
|
|
1499
|
+
"""
|
|
1500
|
+
return __stock_sens_options__
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
__stock_sensitivity_mat_options__ = [
|
|
1504
|
+
"XYZ1SensitivityMatrix",
|
|
1505
|
+
"XYZ2SensitivityMatrix",
|
|
1506
|
+
"AET1SensitivityMatrix",
|
|
1507
|
+
"AE1SensitivityMatrix",
|
|
1508
|
+
]
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def get_stock_sensitivity_matrix_options() -> List[SensitivityMatrix]:
|
|
1512
|
+
"""Get stock options for sensitivity matrix.
|
|
1513
|
+
|
|
1514
|
+
Returns:
|
|
1515
|
+
List of stock sensitivity matrix options.
|
|
1516
|
+
|
|
1517
|
+
"""
|
|
1518
|
+
return __stock_sensitivity_mat_options__
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def get_stock_sensitivity_from_str(sensitivity: str) -> Sensitivity:
|
|
1522
|
+
"""Return a LISA sensitivity from a ``str`` input.
|
|
1523
|
+
|
|
1524
|
+
Args:
|
|
1525
|
+
sensitivity: Sensitivity indicated with a ``str``.
|
|
1526
|
+
|
|
1527
|
+
Returns:
|
|
1528
|
+
Sensitivity associated to that ``str``.
|
|
1529
|
+
|
|
1530
|
+
"""
|
|
1531
|
+
if sensitivity not in __stock_sens_options__:
|
|
1532
|
+
raise ValueError(
|
|
1533
|
+
"Requested string sensitivity is not available. See lisatools.sensitivity documentation."
|
|
1534
|
+
)
|
|
1535
|
+
return globals()[sensitivity]
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def check_sensitivity(sensitivity: Any) -> Sensitivity:
|
|
1539
|
+
"""Check input sensitivity.
|
|
1540
|
+
|
|
1541
|
+
Args:
|
|
1542
|
+
sensitivity: Sensitivity to check.
|
|
1543
|
+
|
|
1544
|
+
Returns:
|
|
1545
|
+
Sensitivity checked. Adjusted from ``str`` if ``str`` input.
|
|
1546
|
+
|
|
1547
|
+
"""
|
|
1548
|
+
if isinstance(sensitivity, str):
|
|
1549
|
+
sensitivity = get_stock_sensitivity_from_str(sensitivity)
|
|
1550
|
+
|
|
1551
|
+
if not issubclass(sensitivity, Sensitivity):
|
|
1552
|
+
raise ValueError("sensitivity argument not given correctly.")
|
|
1553
|
+
|
|
1554
|
+
return sensitivity
|