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