lisaanalysistools 1.1.6__cp312-cp312-macosx_13_0_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.

Potentially problematic release.


This version of lisaanalysistools might be problematic. Click here for more details.

Files changed (44) hide show
  1. lisaanalysistools/git_version.py +7 -0
  2. lisaanalysistools-1.1.6.dist-info/METADATA +295 -0
  3. lisaanalysistools-1.1.6.dist-info/RECORD +44 -0
  4. lisaanalysistools-1.1.6.dist-info/WHEEL +5 -0
  5. lisaanalysistools-1.1.6.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 +58 -0
  9. lisatools/_version.py +34 -0
  10. lisatools/analysiscontainer.py +474 -0
  11. lisatools/cutils/__init__.py +126 -0
  12. lisatools/datacontainer.py +312 -0
  13. lisatools/detector.py +704 -0
  14. lisatools/diagnostic.py +990 -0
  15. lisatools/git_version.py.in +7 -0
  16. lisatools/orbit_files/equalarmlength-orbits-best-fit-to-esa.h5 +0 -0
  17. lisatools/orbit_files/equalarmlength-orbits.h5 +0 -0
  18. lisatools/orbit_files/esa-trailing-orbits.h5 +0 -0
  19. lisatools/sampling/__init__.py +0 -0
  20. lisatools/sampling/likelihood.py +882 -0
  21. lisatools/sampling/moves/__init__.py +0 -0
  22. lisatools/sampling/moves/skymodehop.py +110 -0
  23. lisatools/sampling/prior.py +646 -0
  24. lisatools/sampling/stopping.py +320 -0
  25. lisatools/sampling/utility.py +411 -0
  26. lisatools/sensitivity.py +972 -0
  27. lisatools/sources/__init__.py +6 -0
  28. lisatools/sources/bbh/__init__.py +1 -0
  29. lisatools/sources/bbh/waveform.py +106 -0
  30. lisatools/sources/defaultresponse.py +36 -0
  31. lisatools/sources/emri/__init__.py +1 -0
  32. lisatools/sources/emri/waveform.py +79 -0
  33. lisatools/sources/gb/__init__.py +1 -0
  34. lisatools/sources/gb/waveform.py +69 -0
  35. lisatools/sources/utils.py +459 -0
  36. lisatools/sources/waveformbase.py +41 -0
  37. lisatools/stochastic.py +327 -0
  38. lisatools/utils/__init__.py +0 -0
  39. lisatools/utils/constants.py +54 -0
  40. lisatools/utils/exceptions.py +95 -0
  41. lisatools/utils/parallelbase.py +11 -0
  42. lisatools/utils/utility.py +245 -0
  43. lisatools_backend_cpu/git_version.py +7 -0
  44. lisatools_backend_cpu/pycppdetector.cpython-312-darwin.so +0 -0
@@ -0,0 +1,972 @@
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
+
34
+ class Sensitivity(ABC):
35
+ """Base Class for PSD information.
36
+
37
+ The initialization function is only needed if using a file input.
38
+
39
+ """
40
+
41
+ channel: str = None
42
+
43
+ @staticmethod
44
+ def get_xp(array: np.ndarray) -> object:
45
+ """Numpy or Cupy (or float)"""
46
+ try:
47
+ return get_array_module(array)
48
+ except ValueError:
49
+ if isinstance(array, float):
50
+ return np
51
+ raise ValueError(
52
+ "array must be a numpy or cupy array (it can be a float as well)."
53
+ )
54
+
55
+ @staticmethod
56
+ def transform(
57
+ f: float | np.ndarray,
58
+ Spm: float | np.ndarray,
59
+ Sop: float | np.ndarray,
60
+ **kwargs: dict,
61
+ ) -> float | np.ndarray:
62
+ """Transform from the base sensitivity functions to the TDI PSDs.
63
+
64
+ Args:
65
+ f: Frequency array.
66
+ Spm: Acceleration term.
67
+ Sop: OMS term.
68
+ **kwargs: For interoperability.
69
+
70
+ Returns:
71
+ Transformed TDI PSD values.
72
+
73
+ """
74
+ raise NotImplementedError
75
+
76
+ @classmethod
77
+ def get_Sn(
78
+ cls,
79
+ f: float | np.ndarray,
80
+ model: Optional[lisa_models.LISAModel | str] = lisa_models.scirdv1,
81
+ **kwargs: dict,
82
+ ) -> float | np.ndarray:
83
+ """Calculate the PSD
84
+
85
+ Args:
86
+ f: Frequency array.
87
+ model: Noise model. Object of type :class:`lisa_models.LISAModel`.
88
+ It can also be a string corresponding to one of the stock models.
89
+ The model object must include attributes for ``Soms_d`` (shot noise)
90
+ and ``Sa_a`` (acceleration noise) or a spline as attribute ``Sn_spl``.
91
+ In the case of a spline, this must be a dictionary with
92
+ channel names as keys and callable PSD splines. For example,
93
+ if using ``scipy.interpolate.CubicSpline``, an input option
94
+ can be:
95
+
96
+ ```
97
+ noise_model.Sn_spl = {
98
+ "A": CubicSpline(f, Sn_A)),
99
+ "E": CubicSpline(f, Sn_E)),
100
+ "T": CubicSpline(f, Sn_T))
101
+ }
102
+ ```
103
+ **kwargs: For interoperability.
104
+
105
+ Returns:
106
+ PSD values.
107
+
108
+ """
109
+ # spline or stock computation
110
+ if hasattr(model, "Sn_spl") and model.Sn_spl is not None:
111
+ spl = model.Sn_spl
112
+ if cls.channel not in spl:
113
+ raise ValueError("Calling a channel that is not available.")
114
+
115
+ Sout = spl[cls.channel](f)
116
+
117
+ else:
118
+ model = lisa_models.check_lisa_model(model)
119
+ assert hasattr(model, "Soms_d") and hasattr(model, "Sa_a")
120
+
121
+ # get noise values
122
+ Spm, Sop = model.lisanoises(f)
123
+
124
+ # transform as desired for TDI combination
125
+ Sout = cls.transform(f, Spm, Sop, **kwargs)
126
+
127
+ # will add zero if ignored
128
+ stochastic_contribution = cls.stochastic_transform(
129
+ f, cls.get_stochastic_contribution(f, **kwargs), **kwargs
130
+ )
131
+
132
+ Sout += stochastic_contribution
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 transform(
214
+ f: float | np.ndarray,
215
+ Spm: float | np.ndarray,
216
+ Sop: float | np.ndarray,
217
+ **kwargs: dict,
218
+ ) -> float | np.ndarray:
219
+ __doc__ = (
220
+ "Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
221
+ + Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
222
+ )
223
+
224
+ x = 2.0 * np.pi * lisaLT * f
225
+ return 16.0 * np.sin(x) ** 2 * (2.0 * (1.0 + np.cos(x) ** 2) * Spm + Sop)
226
+
227
+ @staticmethod
228
+ def stochastic_transform(
229
+ f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
230
+ ) -> float | np.ndarray:
231
+ __doc__ = (
232
+ "Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
233
+ + Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
234
+ )
235
+ x = 2.0 * np.pi * lisaLT * f
236
+ t = 4.0 * x**2 * np.sin(x) ** 2
237
+ return Sh * t
238
+
239
+
240
+ class Y1TDISens(X1TDISens):
241
+ channel: str = "Y"
242
+ __doc__ = X1TDISens.__doc__
243
+ pass
244
+
245
+
246
+ class Z1TDISens(X1TDISens):
247
+ channel: str = "Z"
248
+ __doc__ = X1TDISens.__doc__
249
+ pass
250
+
251
+
252
+ class XY1TDISens(Sensitivity):
253
+ channel: str = "XY"
254
+
255
+ @staticmethod
256
+ def transform(
257
+ f: float | np.ndarray,
258
+ Spm: float | np.ndarray,
259
+ Sop: float | np.ndarray,
260
+ **kwargs: dict,
261
+ ) -> float | np.ndarray:
262
+ __doc__ = (
263
+ "Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
264
+ + Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
265
+ )
266
+
267
+ x = 2.0 * np.pi * lisaLT * f
268
+ ## TODO Check the acceleration noise term
269
+ return -4.0 * np.sin(2 * x) * np.sin(x) * (Sop + 4.0 * Spm)
270
+
271
+ @staticmethod
272
+ def stochastic_transform(
273
+ f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
274
+ ) -> float | np.ndarray:
275
+ __doc__ = (
276
+ "Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
277
+ + Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
278
+ )
279
+ x = 2.0 * np.pi * lisaLT * f
280
+ # TODO: check these functions
281
+ # GB = -0.5 of X
282
+ t = -0.5 * (4.0 * x**2 * np.sin(x) ** 2)
283
+ return Sh * t
284
+
285
+
286
+ class ZX1TDISens(XY1TDISens):
287
+ channel: str = "ZX"
288
+ __doc__ = XY1TDISens.__doc__
289
+ pass
290
+
291
+
292
+ class YZ1TDISens(XY1TDISens):
293
+ channel: str = "YZ"
294
+ __doc__ = XY1TDISens.__doc__
295
+ pass
296
+
297
+
298
+ class X2TDISens(Sensitivity):
299
+ channel: str = "X"
300
+
301
+ @staticmethod
302
+ def transform(
303
+ f: float | np.ndarray,
304
+ Spm: float | np.ndarray,
305
+ Sop: float | np.ndarray,
306
+ **kwargs: dict,
307
+ ) -> float | np.ndarray:
308
+ __doc__ = (
309
+ "Transform from the base sensitivity functions to the XYZ TDI PSDs.\n\n"
310
+ + Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
311
+ )
312
+
313
+ x = 2.0 * np.pi * lisaLT * f
314
+ ## TODO Check the acceleration noise term
315
+ return (64.0 * np.sin(x) ** 2 * np.sin(2 * x) ** 2 * Sop) + (
316
+ 256.0 * (3 + np.cos(2 * x)) * np.cos(x) ** 2 * np.sin(x) ** 4 * Spm
317
+ )
318
+
319
+ @staticmethod
320
+ def stochastic_transform(
321
+ f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
322
+ ) -> float | np.ndarray:
323
+ __doc__ = (
324
+ "Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
325
+ + Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
326
+ )
327
+ x = 2.0 * np.pi * lisaLT * f
328
+ # TODO: check these functions for TDI2
329
+ t = 4.0 * x**2 * np.sin(x) ** 2
330
+ return Sh * t
331
+
332
+
333
+ class Y2TDISens(X2TDISens):
334
+ channel: str = "Y"
335
+ __doc__ = X2TDISens.__doc__
336
+ pass
337
+
338
+
339
+ class Z2TDISens(X2TDISens):
340
+ channel: str = "Z"
341
+ __doc__ = X2TDISens.__doc__
342
+ pass
343
+
344
+
345
+ class A1TDISens(Sensitivity):
346
+ channel: str = "A"
347
+
348
+ @staticmethod
349
+ def transform(
350
+ f: float | np.ndarray,
351
+ Spm: float | np.ndarray,
352
+ Sop: float | np.ndarray,
353
+ **kwargs: dict,
354
+ ) -> float | np.ndarray:
355
+ __doc__ = (
356
+ "Transform from the base sensitivity functions to the A,E TDI PSDs.\n\n"
357
+ + Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
358
+ )
359
+
360
+ x = 2.0 * np.pi * lisaLT * f
361
+ Sa = (
362
+ 8.0
363
+ * np.sin(x) ** 2
364
+ * (
365
+ 2.0 * Spm * (3.0 + 2.0 * np.cos(x) + np.cos(2 * x))
366
+ + Sop * (2.0 + np.cos(x))
367
+ )
368
+ )
369
+
370
+ return Sa
371
+
372
+ @staticmethod
373
+ def stochastic_transform(
374
+ f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
375
+ ) -> float | np.ndarray:
376
+ __doc__ = (
377
+ "Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
378
+ + Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
379
+ )
380
+ x = 2.0 * np.pi * lisaLT * f
381
+ t = 4.0 * x**2 * np.sin(x) ** 2
382
+ return 1.5 * (Sh * t)
383
+
384
+
385
+ class E1TDISens(A1TDISens):
386
+ channel: str = "E"
387
+ __doc__ = A1TDISens.__doc__
388
+ pass
389
+
390
+
391
+ class T1TDISens(Sensitivity):
392
+ channel: str = "T"
393
+
394
+ @staticmethod
395
+ def transform(
396
+ f: float | np.ndarray,
397
+ Spm: float | np.ndarray,
398
+ Sop: float | np.ndarray,
399
+ **kwargs: dict,
400
+ ) -> float | np.ndarray:
401
+ __doc__ = (
402
+ "Transform from the base sensitivity functions to the T TDI PSDs.\n\n"
403
+ + Sensitivity.transform.__doc__.split("PSDs.\n\n")[-1]
404
+ )
405
+
406
+ x = 2.0 * np.pi * lisaLT * f
407
+ return (
408
+ 16.0 * Sop * (1.0 - np.cos(x)) * np.sin(x) ** 2
409
+ + 128.0 * Spm * np.sin(x) ** 2 * np.sin(0.5 * x) ** 4
410
+ )
411
+
412
+ @staticmethod
413
+ def stochastic_transform(
414
+ f: float | np.ndarray, Sh: float | np.ndarray, **kwargs: dict
415
+ ) -> float | np.ndarray:
416
+ __doc__ = (
417
+ "Transform from the base stochastic functions to the XYZ stochastic TDI information.\n\n"
418
+ + Sensitivity.stochastic_transform.__doc__.split("PSDs.\n\n")[-1]
419
+ )
420
+ x = 2.0 * np.pi * lisaLT * f
421
+ t = 4.0 * x**2 * np.sin(x) ** 2
422
+ return 0.0 * (Sh * t)
423
+
424
+
425
+ class LISASens(Sensitivity):
426
+ @classmethod
427
+ def get_Sn(
428
+ cls,
429
+ f: float | np.ndarray,
430
+ model: Optional[lisa_models.LISAModel | str] = lisa_models.scirdv1,
431
+ average: bool = True,
432
+ **kwargs: dict,
433
+ ) -> float | np.ndarray:
434
+ """Compute the base LISA sensitivity function.
435
+
436
+ Args:
437
+ f: Frequency array.
438
+ model: Noise model. Object of type :class:`lisa_models.LISAModel`. It can also be a string corresponding to one of the stock models.
439
+ average: Whether to apply averaging factors to sensitivity curve.
440
+ Antenna response: ``av_resp = np.sqrt(5) if average else 1.0``
441
+ Projection effect: ``Proj = 2.0 / np.sqrt(3) if average else 1.0``
442
+ **kwargs: Keyword arguments to pass to :func:`get_stochastic_contribution`. # TODO: fix
443
+
444
+ Returns:
445
+ Sensitivity array.
446
+
447
+ """
448
+ model = lisa_models.check_lisa_model(model)
449
+ assert hasattr(model, "Soms_d") and hasattr(model, "Sa_a")
450
+
451
+ # get noise values
452
+ Sa_d, Sop = model.lisanoises(f, unit="displacement")
453
+
454
+ all_m = np.sqrt(4.0 * Sa_d + Sop)
455
+ ## Average the antenna response
456
+ av_resp = np.sqrt(5) if average else 1.0
457
+
458
+ ## Projection effect
459
+ Proj = 2.0 / np.sqrt(3) if average else 1.0
460
+
461
+ ## Approximative transfer function
462
+ f0 = 1.0 / (2.0 * lisaLT)
463
+ a = 0.41
464
+ T = np.sqrt(1 + (f / (a * f0)) ** 2)
465
+ sens = (av_resp * Proj * T * all_m / lisaL) ** 2
466
+
467
+ # will add zero if ignored
468
+ sens += cls.get_stochastic_contribution(f, **kwargs)
469
+ return sens
470
+
471
+
472
+ class CornishLISASens(LISASens):
473
+ """PSD from https://arxiv.org/pdf/1803.01944.pdf
474
+
475
+ Power Spectral Density for the LISA detector assuming it has been active for a year.
476
+ I found an analytic version in one of Niel Cornish's paper which he submitted to the arXiv in
477
+ 2018. I evaluate the PSD at the frequency bins found in the signal FFT.
478
+
479
+ PSD obtained from: https://arxiv.org/pdf/1803.01944.pdf
480
+
481
+ """
482
+
483
+ @staticmethod
484
+ def get_Sn(
485
+ f: float | np.ndarray, average: bool = True, **kwargs: dict
486
+ ) -> float | np.ndarray:
487
+ # TODO: documentation here
488
+
489
+ sky_averaging_constant = 20.0 / 3.0 if average else 1.0
490
+
491
+ L = 2.5 * 10**9 # Length of LISA arm
492
+ f0 = 19.09 * 10 ** (-3) # transfer frequency
493
+
494
+ # Optical Metrology Sensor
495
+ Poms = ((1.5e-11) * (1.5e-11)) * (1 + np.power((2e-3) / f, 4))
496
+
497
+ # Acceleration Noise
498
+ Pacc = (
499
+ (3e-15)
500
+ * (3e-15)
501
+ * (1 + (4e-4 / f) * (4e-4 / f))
502
+ * (1 + np.power(f / (8e-3), 4))
503
+ )
504
+
505
+ # constants for Galactic background after 1 year of observation
506
+ alpha = 0.171
507
+ beta = 292
508
+ k = 1020
509
+ gamma = 1680
510
+ f_k = 0.00215
511
+
512
+ # Galactic background contribution
513
+ Sc = (
514
+ 9e-45
515
+ * np.power(f, -7 / 3)
516
+ * np.exp(-np.power(f, alpha) + beta * f * np.sin(k * f))
517
+ * (1 + np.tanh(gamma * (f_k - f)))
518
+ )
519
+
520
+ # PSD
521
+ PSD = (sky_averaging_constant) * (
522
+ (10 / (3 * L * L))
523
+ * (Poms + (4 * Pacc) / (np.power(2 * np.pi * f, 4)))
524
+ * (1 + 0.6 * (f / f0) * (f / f0))
525
+ + Sc
526
+ )
527
+
528
+ return PSD
529
+
530
+
531
+ class FlatPSDFunction(LISASens):
532
+ """White Noise PSD function."""
533
+
534
+ @classmethod
535
+ def get_Sn(
536
+ cls, f: float | np.ndarray, val: float, **kwargs: dict
537
+ ) -> float | np.ndarray:
538
+ # TODO: documentation here
539
+ xp = cls.get_xp(f)
540
+ out = xp.full_like(f, val)
541
+ if isinstance(f, float):
542
+ out = out.item()
543
+ return out
544
+
545
+
546
+ class SensitivityMatrix:
547
+ """Container to hold sensitivity information.
548
+
549
+ Args:
550
+ f: Frequency array.
551
+ sens_mat: Input sensitivity list. The shape of the nested lists should represent the shape of the
552
+ desired matrix. Each entry in the list must be an array, :class:`Sensitivity`-derived object,
553
+ or a string corresponding to the :class:`Sensitivity` object.
554
+ **sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
555
+
556
+ """
557
+
558
+ def __init__(
559
+ self,
560
+ f: np.ndarray,
561
+ sens_mat: (
562
+ List[List[np.ndarray | Sensitivity]]
563
+ | List[np.ndarray | Sensitivity]
564
+ | np.ndarray
565
+ | Sensitivity
566
+ ),
567
+ *sens_args: tuple,
568
+ **sens_kwargs: dict,
569
+ ) -> None:
570
+ self.frequency_arr = f
571
+ self.data_length = len(self.frequency_arr)
572
+ self.sens_args = sens_args
573
+ self.sens_kwargs = sens_kwargs
574
+ self.sens_mat = sens_mat
575
+
576
+ @property
577
+ def frequency_arr(self) -> np.ndarray:
578
+ return self._frequency_arr
579
+
580
+ @frequency_arr.setter
581
+ def frequency_arr(self, frequency_arr: np.ndarray) -> None:
582
+ assert frequency_arr.dtype == np.float64 or frequency_arr.dtype == float
583
+ assert frequency_arr.ndim == 1
584
+ self._frequency_arr = frequency_arr
585
+
586
+ def update_frequency_arr(self, frequency_arr: np.ndarray) -> None:
587
+ """Update class with new frequency array.
588
+
589
+ Args:
590
+ frequency_arr: Frequency array.
591
+
592
+ """
593
+ self.frequency_arr = frequency_arr
594
+ self.sens_mat = self.sens_mat_input
595
+
596
+ def update_model(self, model: lisa_models.LISAModel) -> None:
597
+ """Update class with new sensitivity model.
598
+
599
+ Args:
600
+ model: Noise model. Object of type :class:`lisa_models.LISAModel`. It can also be a string corresponding to one of the stock models.
601
+
602
+ """
603
+ self.sens_kwargs["model"] = model
604
+ self.sens_mat = self.sens_mat_input
605
+
606
+ def update_stochastic(self, **kwargs: dict) -> None:
607
+ """Update class with new stochastic function.
608
+
609
+ Args:
610
+ **kwargs: Keyword arguments update for :func:`lisatools.sensitivity.Sensitivity.get_stochastic_contribution`.
611
+ This operation will combine the new and old kwarg dictionaries, updating any
612
+ old information with any added corresponding new information. **Note**: any old information
613
+ that is not updated will remain in place.
614
+
615
+ """
616
+ self.sens_kwargs = {**self.sens_kwargs, **kwargs}
617
+ self.sens_mat = self.sens_mat_input
618
+
619
+ @property
620
+ def sens_mat(self) -> np.ndarray:
621
+ """Get sensitivity matrix."""
622
+ return self._sens_mat
623
+
624
+ @sens_mat.setter
625
+ def sens_mat(
626
+ self,
627
+ sens_mat: (
628
+ List[List[np.ndarray | Sensitivity]]
629
+ | List[np.ndarray | Sensitivity]
630
+ | np.ndarray
631
+ | Sensitivity
632
+ ),
633
+ ) -> None:
634
+ """Set sensitivity matrix."""
635
+ self.sens_mat_input = deepcopy(sens_mat)
636
+ self._sens_mat = np.asarray(sens_mat, dtype=object)
637
+
638
+ # not an
639
+ new_out = np.full(len(self._sens_mat.flatten()), None, dtype=object)
640
+ self.return_shape = self._sens_mat.shape
641
+ for i in range(len(self._sens_mat.flatten())):
642
+ current_sens = self._sens_mat.flatten()[i]
643
+ if hasattr(current_sens, "get_Sn") or isinstance(current_sens, str):
644
+ new_out[i] = get_sensitivity(
645
+ self.frequency_arr,
646
+ *self.sens_args,
647
+ sens_fn=current_sens,
648
+ **self.sens_kwargs,
649
+ )
650
+
651
+ elif isinstance(current_sens, np.ndarray) or isinstance(
652
+ current_sens, cp.ndarray
653
+ ):
654
+ new_out[i] = current_sens
655
+ else:
656
+ raise ValueError
657
+
658
+ xp = get_array_module(new_out[0])
659
+ # setup in array form
660
+ self._sens_mat = xp.asarray(list(new_out), dtype=float).reshape(
661
+ self.return_shape + (-1,)
662
+ )
663
+
664
+ xp = get_array_module(self.sens_mat)
665
+
666
+ # setup detC
667
+ """Determinant and inverse of TDI matrix."""
668
+ if self.sens_mat.ndim < 3:
669
+ self.detC = xp.prod(self.sens_mat, axis=0)
670
+ self.invC = 1 / self.sens_mat
671
+
672
+ else:
673
+ self.detC = xp.linalg.det(self.sens_mat.transpose(2, 0, 1))
674
+ invC = xp.zeros_like(self.sens_mat.transpose(2, 0, 1))
675
+ invC[self.detC != 0.0] = xp.linalg.inv(
676
+ self.sens_mat.transpose(2, 0, 1)[self.detC != 0.0]
677
+ )
678
+ invC[self.detC == 0.0] = 1e-100
679
+ self.invC = invC.transpose(1, 2, 0)
680
+
681
+ def __getitem__(self, index: Any) -> np.ndarray:
682
+ """Indexing the class indexes the array."""
683
+ return self.sens_mat[index]
684
+
685
+ @property
686
+ def ndim(self) -> int:
687
+ """Dimensionality of sens mat array."""
688
+ return self.sens_mat.ndim
689
+
690
+ def flatten(self) -> np.ndarray:
691
+ """Flatten sens mat array."""
692
+ return self.sens_mat.reshape(-1, self.sens_mat.shape[-1])
693
+
694
+ @property
695
+ def shape(self) -> tuple:
696
+ """Shape of sens mat array."""
697
+ return self.sens_mat.shape
698
+
699
+ def loglog(
700
+ self,
701
+ ax: Optional[plt.Axes] = None,
702
+ fig: Optional[plt.Figure] = None,
703
+ inds: Optional[int | tuple] = None,
704
+ char_strain: Optional[bool] = False,
705
+ **kwargs: dict,
706
+ ) -> Tuple[plt.Figure, plt.Axes]:
707
+ """Produce a log-log plot of the sensitivity.
708
+
709
+ Args:
710
+ ax: Matplotlib Axes objects to add plots. Either a list of Axes objects or a single Axes object.
711
+ fig: Matplotlib figure object.
712
+ inds: Integer index to select out which data to add to a single access.
713
+ A list can be provided if ax is a list. They must be the same length.
714
+ char_strain: If ``True``, plot in characteristic strain representation. **Note**: assumes the sensitivity
715
+ is input as power spectral density.
716
+ **kwargs: Keyword arguments to be passed to ``loglog`` function in matplotlib.
717
+
718
+ Returns:
719
+ Matplotlib figure and axes objects in a 2-tuple.
720
+
721
+
722
+ """
723
+ if (ax is None and fig is None) or (
724
+ ax is not None and (isinstance(ax, list) or isinstance(ax, np.ndarray))
725
+ ):
726
+ if ax is None and fig is None:
727
+ outer_shape = self.shape[:-1]
728
+ if len(outer_shape) == 2:
729
+ nrows = outer_shape[0]
730
+ ncols = outer_shape[1]
731
+ elif len(outer_shape) == 1:
732
+ nrows = 1
733
+ ncols = outer_shape[0]
734
+
735
+ fig, ax = plt.subplots(nrows, ncols, sharex=True, sharey=True)
736
+ try:
737
+ ax = ax.ravel()
738
+ except AttributeError:
739
+ ax = [ax] # just one axis object, no list
740
+
741
+ else:
742
+ assert len(ax) == np.prod(self.shape[:-1])
743
+
744
+ for i in range(np.prod(self.shape[:-1])):
745
+ plot_in = self.flatten()[i]
746
+ if char_strain:
747
+ plot_in = np.sqrt(self.frequency_arr * plot_in)
748
+ ax[i].loglog(self.frequency_arr, plot_in, **kwargs)
749
+
750
+ elif fig is not None:
751
+ raise NotImplementedError
752
+
753
+ elif isinstance(ax, plt.axes):
754
+ if inds is None:
755
+ raise ValueError(
756
+ "When passing a single axes object for `ax`, but also pass `inds` kwarg."
757
+ )
758
+ plot_in = self.sens_mat[inds]
759
+ if char_strain:
760
+ plot_in = np.sqrt(self.frequency_arr * plot_in)
761
+ ax.loglog(self.frequency_arr, plot_in, **kwargs)
762
+
763
+ else:
764
+ raise ValueError(
765
+ "ax must be a list of axes objects or a single axes object."
766
+ )
767
+
768
+ return (fig, ax)
769
+
770
+
771
+ class XYZ1SensitivityMatrix(SensitivityMatrix):
772
+ """Default sensitivity matrix for XYZ (TDI 1)
773
+
774
+ This is 3x3 symmetric matrix.
775
+
776
+ Args:
777
+ f: Frequency array.
778
+ **sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
779
+
780
+ """
781
+
782
+ def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
783
+ sens_mat = [
784
+ [X1TDISens, XY1TDISens, ZX1TDISens],
785
+ [XY1TDISens, Y1TDISens, YZ1TDISens],
786
+ [ZX1TDISens, YZ1TDISens, Z1TDISens],
787
+ ]
788
+ super().__init__(f, sens_mat, **sens_kwargs)
789
+
790
+
791
+ class AET1SensitivityMatrix(SensitivityMatrix):
792
+ """Default sensitivity matrix for AET (TDI 1)
793
+
794
+ This is just an array because no cross-terms.
795
+
796
+ Args:
797
+ f: Frequency array.
798
+ **sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
799
+
800
+ """
801
+
802
+ def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
803
+ sens_mat = [A1TDISens, E1TDISens, T1TDISens]
804
+ super().__init__(f, sens_mat, **sens_kwargs)
805
+
806
+
807
+ class AE1SensitivityMatrix(SensitivityMatrix):
808
+ """Default sensitivity matrix for AE (no T) (TDI 1)
809
+
810
+ Args:
811
+ f: Frequency array.
812
+ **sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
813
+
814
+ """
815
+
816
+ def __init__(self, f: np.ndarray, **sens_kwargs: dict) -> None:
817
+ sens_mat = [A1TDISens, E1TDISens]
818
+ super().__init__(f, sens_mat, **sens_kwargs)
819
+
820
+
821
+ class LISASensSensitivityMatrix(SensitivityMatrix):
822
+ """Default sensitivity matrix adding :class:`LISASens` for the specified number of channels.
823
+
824
+ Args:
825
+ f: Frequency array.
826
+ nchannels: Number of channels.
827
+ **sens_kwargs: Keyword arguments to pass to :func:`Sensitivity.get_Sn`.
828
+
829
+ """
830
+
831
+ def __init__(self, f: np.ndarray, nchannels: int, **sens_kwargs: dict) -> None:
832
+ sens_mat = [LISASens for _ in range(nchannels)]
833
+ super().__init__(f, sens_mat, **sens_kwargs)
834
+
835
+
836
+ def get_sensitivity(
837
+ f: float | np.ndarray,
838
+ *args: tuple,
839
+ sens_fn: Optional[Sensitivity | str] = LISASens,
840
+ return_type="PSD",
841
+ fill_nans: float = 1e10,
842
+ **kwargs,
843
+ ) -> float | np.ndarray:
844
+ """Generic sensitivity generator
845
+
846
+ Same interface to many sensitivity curves.
847
+
848
+ Args:
849
+ f: Frequency array.
850
+ *args: Any additional arguments for the sensitivity function ``get_Sn`` method.
851
+ sens_fn: String or class that represents the name of the desired PSD function.
852
+ return_type: Described the desired output. Choices are ASD,
853
+ PSD, or char_strain (characteristic strain). Default is ASD.
854
+ fill_nans: Value to fill nans in sensitivity (at 0 frequency).
855
+ If ``None``, thens nans will be left in the array.
856
+ **kwargs: Keyword arguments to pass to sensitivity function ``get_Sn`` method.
857
+
858
+ Return:
859
+ Sensitivity values.
860
+
861
+ """
862
+
863
+ if isinstance(sens_fn, str):
864
+ sensitivity = check_sensitivity(sens_fn)
865
+
866
+ elif hasattr(sens_fn, "get_Sn"):
867
+ sensitivity = sens_fn
868
+
869
+ else:
870
+ raise ValueError(
871
+ "sens_fn must be a string for a stock option or a class with a get_Sn method."
872
+ )
873
+
874
+ PSD = sensitivity.get_Sn(f, *args, **kwargs)
875
+
876
+ if fill_nans is not None:
877
+ assert isinstance(fill_nans, float)
878
+ PSD[np.isnan(PSD)] = fill_nans
879
+
880
+ if return_type == "PSD":
881
+ return PSD
882
+
883
+ elif return_type == "ASD":
884
+ return PSD ** (1 / 2)
885
+
886
+ elif return_type == "char_strain":
887
+ return (f * PSD) ** (1 / 2)
888
+
889
+ else:
890
+ raise ValueError("return_type must be PSD, ASD, or char_strain.")
891
+
892
+
893
+ __stock_sens_options__ = [
894
+ "X1TDISens",
895
+ "Y1TDISens",
896
+ "Z1TDISens",
897
+ "XY1TDISens",
898
+ "YZ1TDISens",
899
+ "ZX1TDISens",
900
+ "A1TDISens",
901
+ "E1TDISens",
902
+ "T1TDISens",
903
+ "X2TDISens",
904
+ "Y2TDISens",
905
+ "Z2TDISens",
906
+ "LISASens",
907
+ "CornishLISASens",
908
+ "FlatPSDFunction",
909
+ ]
910
+
911
+
912
+ def get_stock_sensitivity_options() -> List[Sensitivity]:
913
+ """Get stock options for sensitivity curves.
914
+
915
+ Returns:
916
+ List of stock sensitivity options.
917
+
918
+ """
919
+ return __stock_sens_options__
920
+
921
+
922
+ __stock_sensitivity_mat_options__ = [
923
+ "XYZ1SensitivityMatrix",
924
+ "AET1SensitivityMatrix",
925
+ "AE1SensitivityMatrix",
926
+ ]
927
+
928
+
929
+ def get_stock_sensitivity_matrix_options() -> List[SensitivityMatrix]:
930
+ """Get stock options for sensitivity matrix.
931
+
932
+ Returns:
933
+ List of stock sensitivity matrix options.
934
+
935
+ """
936
+ return __stock_sensitivity_mat_options__
937
+
938
+
939
+ def get_stock_sensitivity_from_str(sensitivity: str) -> Sensitivity:
940
+ """Return a LISA sensitivity from a ``str`` input.
941
+
942
+ Args:
943
+ sensitivity: Sensitivity indicated with a ``str``.
944
+
945
+ Returns:
946
+ Sensitivity associated to that ``str``.
947
+
948
+ """
949
+ if sensitivity not in __stock_sens_options__:
950
+ raise ValueError(
951
+ "Requested string sensitivity is not available. See lisatools.sensitivity documentation."
952
+ )
953
+ return globals()[sensitivity]
954
+
955
+
956
+ def check_sensitivity(sensitivity: Any) -> Sensitivity:
957
+ """Check input sensitivity.
958
+
959
+ Args:
960
+ sensitivity: Sensitivity to check.
961
+
962
+ Returns:
963
+ Sensitivity checked. Adjusted from ``str`` if ``str`` input.
964
+
965
+ """
966
+ if isinstance(sensitivity, str):
967
+ sensitivity = get_stock_sensitivity_from_str(sensitivity)
968
+
969
+ if not issubclass(sensitivity, Sensitivity):
970
+ raise ValueError("sensitivity argument not given correctly.")
971
+
972
+ return sensitivity