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.
Files changed (48) hide show
  1. lisaanalysistools/git_version.py +7 -0
  2. lisaanalysistools-1.1.20.dist-info/METADATA +281 -0
  3. lisaanalysistools-1.1.20.dist-info/RECORD +48 -0
  4. lisaanalysistools-1.1.20.dist-info/WHEEL +5 -0
  5. lisaanalysistools-1.1.20.dist-info/licenses/LICENSE +201 -0
  6. lisatools/.dylibs/libgcc_s.1.1.dylib +0 -0
  7. lisatools/.dylibs/libstdc++.6.dylib +0 -0
  8. lisatools/__init__.py +90 -0
  9. lisatools/_version.py +34 -0
  10. lisatools/analysiscontainer.py +474 -0
  11. lisatools/cutils/Detector.cu +307 -0
  12. lisatools/cutils/Detector.hpp +84 -0
  13. lisatools/cutils/__init__.py +129 -0
  14. lisatools/cutils/global.hpp +28 -0
  15. lisatools/cutils/pycppdetector.pyx +256 -0
  16. lisatools/datacontainer.py +312 -0
  17. lisatools/detector.py +867 -0
  18. lisatools/diagnostic.py +990 -0
  19. lisatools/git_version.py.in +7 -0
  20. lisatools/orbit_files/equalarmlength-orbits-best-fit-to-esa.h5 +0 -0
  21. lisatools/orbit_files/equalarmlength-orbits.h5 +0 -0
  22. lisatools/orbit_files/esa-trailing-orbits.h5 +0 -0
  23. lisatools/sampling/__init__.py +0 -0
  24. lisatools/sampling/likelihood.py +882 -0
  25. lisatools/sampling/moves/__init__.py +0 -0
  26. lisatools/sampling/moves/skymodehop.py +110 -0
  27. lisatools/sampling/prior.py +646 -0
  28. lisatools/sampling/stopping.py +320 -0
  29. lisatools/sampling/utility.py +411 -0
  30. lisatools/sensitivity.py +1554 -0
  31. lisatools/sources/__init__.py +6 -0
  32. lisatools/sources/bbh/__init__.py +1 -0
  33. lisatools/sources/bbh/waveform.py +106 -0
  34. lisatools/sources/defaultresponse.py +37 -0
  35. lisatools/sources/emri/__init__.py +1 -0
  36. lisatools/sources/emri/waveform.py +79 -0
  37. lisatools/sources/gb/__init__.py +1 -0
  38. lisatools/sources/gb/waveform.py +69 -0
  39. lisatools/sources/utils.py +459 -0
  40. lisatools/sources/waveformbase.py +41 -0
  41. lisatools/stochastic.py +327 -0
  42. lisatools/utils/__init__.py +0 -0
  43. lisatools/utils/constants.py +54 -0
  44. lisatools/utils/exceptions.py +95 -0
  45. lisatools/utils/parallelbase.py +11 -0
  46. lisatools/utils/utility.py +122 -0
  47. lisatools_backend_cpu/git_version.py +7 -0
  48. lisatools_backend_cpu/pycppdetector.cpython-39-darwin.so +0 -0
@@ -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