TransitionListener 2.0.0__py3-none-any.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 (95) hide show
  1. transitionlistener/__init__.py +21 -0
  2. transitionlistener/bubbledynamics.py +2250 -0
  3. transitionlistener/bubbledynamics_fixedstep.py +2929 -0
  4. transitionlistener/check_installation.py +309 -0
  5. transitionlistener/ckm.py +91 -0
  6. transitionlistener/colors.py +288 -0
  7. transitionlistener/config.py +185 -0
  8. transitionlistener/constants.py +64 -0
  9. transitionlistener/counterterms/__init__.py +230 -0
  10. transitionlistener/counterterms/cw.py +298 -0
  11. transitionlistener/counterterms/tensors.py +343 -0
  12. transitionlistener/counterterms/twohdm.py +520 -0
  13. transitionlistener/counterterms/twohdm_solver.py +374 -0
  14. transitionlistener/errors.py +315 -0
  15. transitionlistener/examples/bp_et.yaml +64 -0
  16. transitionlistener/examples/bp_lisa.yaml +70 -0
  17. transitionlistener/examples/bp_muares.yaml +69 -0
  18. transitionlistener/examples/bp_pta.yaml +64 -0
  19. transitionlistener/examples/example_dark_photon.yaml +73 -0
  20. transitionlistener/examples/example_grid.yaml +55 -0
  21. transitionlistener/examples/example_line.yaml +51 -0
  22. transitionlistener/examples/example_point.yaml +73 -0
  23. transitionlistener/examples/example_random_conformal.yaml +42 -0
  24. transitionlistener/examples/example_ultranest.yaml +39 -0
  25. transitionlistener/finiteT.py +468 -0
  26. transitionlistener/generic_potential.py +1521 -0
  27. transitionlistener/gridplots.py +592 -0
  28. transitionlistener/gwfopt.py +604 -0
  29. transitionlistener/helper_functions.py +843 -0
  30. transitionlistener/hydrodynamics.py +647 -0
  31. transitionlistener/interface/__init__.py +95 -0
  32. transitionlistener/interface/check.py +51 -0
  33. transitionlistener/interface/cli.py +211 -0
  34. transitionlistener/interface/config_loader.py +264 -0
  35. transitionlistener/interface/logging_utils.py +30 -0
  36. transitionlistener/interface/output_schema.py +296 -0
  37. transitionlistener/interface/pipeline.py +300 -0
  38. transitionlistener/interface/samplers.py +565 -0
  39. transitionlistener/interface/scans.py +468 -0
  40. transitionlistener/interface/single_point.py +581 -0
  41. transitionlistener/interface/state.py +61 -0
  42. transitionlistener/lineplots.py +597 -0
  43. transitionlistener/logo/TL-logo_large.png +0 -0
  44. transitionlistener/logo/TL-logo_small.png +0 -0
  45. transitionlistener/models/TL_2HDM.py +512 -0
  46. transitionlistener/models/TL_conformal_dark_u1.py +202 -0
  47. transitionlistener/models/TL_dark_U1.py +198 -0
  48. transitionlistener/models/TL_dark_U1_g_parameterization.py +198 -0
  49. transitionlistener/models/TL_dark_flipflop.py +274 -0
  50. transitionlistener/models/templatePotential.py +260 -0
  51. transitionlistener/nucleation.py +412 -0
  52. transitionlistener/observability.py +790 -0
  53. transitionlistener/particles.py +436 -0
  54. transitionlistener/pathDeformation.py +1462 -0
  55. transitionlistener/percolation_adaptive_gridbuilders.py +1385 -0
  56. transitionlistener/percolation_adaptive_rate.py +1049 -0
  57. transitionlistener/percolation_adaptivestepsize.py +1632 -0
  58. transitionlistener/percolation_fixedstepsize.py +618 -0
  59. transitionlistener/phases.py +1499 -0
  60. transitionlistener/plot_settings.py +121 -0
  61. transitionlistener/plots.py +1781 -0
  62. transitionlistener/runtime_options.py +278 -0
  63. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/bandwidths.npy +0 -0
  64. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/density.npy +0 -0
  65. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/freqs.npy +0 -0
  66. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log.txt +16 -0
  67. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log10rhogrid.npy +0 -0
  68. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log10rholabels.txt +30 -0
  69. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/pulsar_list.txt +1 -0
  70. transitionlistener/tab_data/HLV-O2-PLI.csv +82 -0
  71. transitionlistener/tab_data/HLV-O2-noise.csv +83 -0
  72. transitionlistener/tab_data/HLVK-design-PLI.csv +61 -0
  73. transitionlistener/tab_data/HLVK-design-noise.csv +83 -0
  74. transitionlistener/tab_data/NG15_PLI_hc.csv +31 -0
  75. transitionlistener/tab_data/NG15_noise_hc.csv +56 -0
  76. transitionlistener/tab_data/PLI-dataset.csv +327 -0
  77. transitionlistener/tab_data/energydensities.dat +1001 -0
  78. transitionlistener/tab_data/finiteT_b.dat.txt +1999 -0
  79. transitionlistener/tab_data/finiteT_f.dat.txt +1000 -0
  80. transitionlistener/tab_data/geff_SM.dat +10009 -0
  81. transitionlistener/tab_data/muAres_f_Hz_PLI_h2OmegaGW.csv +131 -0
  82. transitionlistener/tab_data/muAres_f_Hz_h2Omega_noise.csv +99 -0
  83. transitionlistener/tab_data/noise-dataset.csv +327 -0
  84. transitionlistener/tab_data/numberdensities.dat +1001 -0
  85. transitionlistener/tab_data/pressures.dat +1001 -0
  86. transitionlistener/thermodynamics.py +686 -0
  87. transitionlistener/transitionObservables.py +1199 -0
  88. transitionlistener/transitionObservables_fixedstep.py +976 -0
  89. transitionlistener/transitions.py +1000 -0
  90. transitionlistener/tunneling1D.py +910 -0
  91. transitionlistener-2.0.0.dist-info/METADATA +817 -0
  92. transitionlistener-2.0.0.dist-info/RECORD +95 -0
  93. transitionlistener-2.0.0.dist-info/WHEEL +4 -0
  94. transitionlistener-2.0.0.dist-info/entry_points.txt +2 -0
  95. transitionlistener-2.0.0.dist-info/licenses/LICENSE.txt +674 -0
@@ -0,0 +1,790 @@
1
+ """Sensitivity curves and observability metrics for gravitational wave experiments.
2
+
3
+ Part of TransitionListener v2.0
4
+ Documentation: https://tasillo.de/TransitionListener/
5
+
6
+ Authors:
7
+ Jonas Matuszak <jonas.matuszak@kit.edu>
8
+ Carlo Tasillo <carlo.tasillo@ific.uv.es>
9
+ """
10
+
11
+ import os
12
+ from collections import OrderedDict
13
+
14
+ import numpy as np
15
+ from scipy import integrate
16
+ try: # Optional PTA dependencies; skipped if environment lacks tempo2/ptarcade stack.
17
+ from ptarcade import signal_builder # type: ignore
18
+ from enterprise.signals.parameter import Uniform
19
+ _PTA_IMPORT_ERROR: Exception | None = None
20
+ except Exception as exc: # pragma: no cover - optional dependency
21
+ signal_builder = None # type: ignore
22
+ Uniform = None # type: ignore
23
+ _PTA_IMPORT_ERROR = exc
24
+
25
+ from . import console, print
26
+ from .colors import *
27
+ from .constants import *
28
+
29
+ import rich
30
+
31
+
32
+ def _require_pta_dependencies() -> None:
33
+ """Raise a clear error if PTA dependencies are unavailable."""
34
+ if _PTA_IMPORT_ERROR is not None:
35
+ raise RuntimeError(
36
+ "PTA likelihood evaluation requires ptarcade/enterprise/tempo2; "
37
+ "set up tempo2 and ptarcade or disable PTA calculations."
38
+ ) from _PTA_IMPORT_ERROR
39
+
40
+ # Mapping of PTA likelihood settings keyed by the label used throughout the codebase.
41
+ PTA_LIKELIHOOD_SETTINGS = OrderedDict(
42
+ # Best-fit likelihoods were obtained from PTArcade ultranest scans of
43
+ # first-order phase-transition GW spectra following arXiv:2403.03723.
44
+ [
45
+ (
46
+ "NG15_14bins",
47
+ {
48
+ "pta_name": "NG15",
49
+ "nbins": 14,
50
+ "threshold": -110.0,
51
+ "bestfit_lnL": -103.12759208991564,
52
+ },
53
+ ),
54
+ (
55
+ "NG15_5bins",
56
+ {
57
+ "pta_name": "NG15",
58
+ "nbins": 5,
59
+ "threshold": -35.0,
60
+ "bestfit_lnL": -29.55312949665563,
61
+ },
62
+ ),
63
+ (
64
+ "NG12_5bins",
65
+ {
66
+ "pta_name": "NG12",
67
+ "nbins": 5,
68
+ "threshold": -45.0,
69
+ "bestfit_lnL": -36.89179733242301,
70
+ },
71
+ ),
72
+ (
73
+ "IPTA2_13bins",
74
+ {
75
+ "pta_name": "IPTA2",
76
+ "nbins": 13,
77
+ "threshold": -115.0,
78
+ "bestfit_lnL": -105.66902507253809,
79
+ },
80
+ ),
81
+ ]
82
+ )
83
+
84
+ PTA_LIKELIHOOD_VARIANTS = ("PTArcade", "mock", "smooth")
85
+
86
+ PTA_DISPLAY_NAMES = {
87
+ "NG15_14bins": "NANOGrav 15yr",
88
+ "NG15_5bins": "NANOGrav 15yr",
89
+ "NG12_5bins": "NANOGrav 12.5yr",
90
+ "IPTA2_13bins": "IPTA DR2",
91
+ }
92
+
93
+ MOCK_LNL_REFERENCE = {
94
+ "f_peak_Hz_log10": -7.70241036708,
95
+ "h2OmegaGW_peak_log10": -8.134671889762169,
96
+ }
97
+
98
+ EXISTING_OBSERVATORIES = {
99
+ "EPTA_18_yrs",
100
+ "NANOGrav_11_yrs",
101
+ "NANOGrav_15_yrs",
102
+ "HLV_O2"
103
+ }
104
+
105
+ DELTA_LNL_WITHIN_THRESHOLD = 4.5
106
+
107
+ class GW_Sensitivity_Data:
108
+ """
109
+ Class used to import the sensitivity data curves of GW observaories.
110
+
111
+ Attributes
112
+ ----------
113
+ self.det_f : float array
114
+ Frequencies from the detector data
115
+ self.det_data : float array
116
+ Detector sensitivity curves
117
+ self.det_names : string tuple
118
+ Deterctor names, ordered as in input file
119
+ self.det_colors : string tuple
120
+ Colors for the detector sensitivities in plots, ordered as in input file
121
+ self.det : dictionary
122
+ Dicitonary for the sensitivitie with the detector name as key
123
+ self.det_thr : float tuple
124
+ Expected detector threshold SNRs
125
+ self.det_tobs : float tuple
126
+ Expected detector observation times
127
+ """
128
+
129
+ def __init__(self):
130
+ """Load sensitivity and power-law integrated curves for supported detectors."""
131
+ data_path = os.path.join(os.path.dirname(__file__), "tab_data")
132
+
133
+ def _csv_path(filename: str) -> str:
134
+ return os.path.join(data_path, filename)
135
+
136
+ detectors = self._load_ground_space_detectors(_csv_path)
137
+
138
+ det_names = (
139
+ "SKA_5_yrs",
140
+ "SKA_10_yrs",
141
+ "SKA_20_yrs",
142
+ "EPTA_18_yrs",
143
+ "NANOGrav_11_yrs",
144
+ "NANOGrav_15_yrs",
145
+ "LISA",
146
+ "B-DECIGO",
147
+ "DECIGO",
148
+ "BBO",
149
+ "ET",
150
+ "muAres",
151
+ "HLV_O2",
152
+ "HLVK_design",
153
+ )
154
+ mu_ares = self._load_mu_ares(_csv_path)
155
+ ground_based = self._load_ground_based(_csv_path)
156
+ ng15 = self._load_ng15(_csv_path)
157
+
158
+ combined_curves = {}
159
+ for curves in (detectors["curves"], mu_ares["curves"], ground_based["curves"], ng15["curves"]):
160
+ combined_curves.update(curves)
161
+ det_files = {name: combined_curves[name] for name in det_names}
162
+
163
+ det_plot_names = (
164
+ r"SKA $5 \, \mathrm{yr}$",
165
+ r"SKA $10 \, \mathrm{yr}$",
166
+ r"SKA $20 \, \mathrm{yr}$",
167
+ r"EPTA $18 \, \mathrm{yr}$",
168
+ r"NANOGrav $11 \, \mathrm{yr}$",
169
+ r"NANOGrav $15 \, \mathrm{yr}$",
170
+ "LISA",
171
+ "B-DECIGO",
172
+ "DECIGO",
173
+ "BBO",
174
+ "ET",
175
+ r"$\mu$Ares",
176
+ "LIGO-VIRGO O2",
177
+ "HLVK design",
178
+ )
179
+ det_single_obs_names = {"LISA", "B-DECIGO", "ET", "muAres"}
180
+ det_no_plot = {"EPTA_18_yrs", "NANOGrav_11_yrs", "B-DECIGO", "DECIGO", "HLVK_design"}
181
+ det_colors = (
182
+ "#FFD700", # SKA_5_yrs – bright gold
183
+ "#FF7F00", # SKA_10_yrs – vivid orange
184
+ "#E8000B", # SKA_20_yrs – pure red
185
+ "white", # EPTA_18_yrs (hidden)
186
+ "white", # NANOGrav_11_yrs (hidden)
187
+ "#C0105A", # NANOGrav_15_yrs – crimson-magenta
188
+ "#4477AA", # LISA – blue
189
+ "white", # B-DECIGO (hidden)
190
+ "white", # DECIGO (hidden)
191
+ "#66CCEE", # BBO – cyan
192
+ "#AA4499", # ET – purple
193
+ "#00C2AB", # muAres – teal
194
+ "#002147", # HLV_O2 – very dark navy blue
195
+ "white", # HLVK_design (hidden)
196
+ )
197
+ det_zorder = (3, 2, 1, 5, 6, 4, 5, 3, 1, 2, 3, 1, 7, 8)
198
+ det_thr = (4, 4, 4, 1.19, 0.697, 5, 10, 8, 10, 10, 5, 10, 1, 1)
199
+ det_tobs = (5*yr, 10*yr, 20*yr, 18*yr, 11*yr, 15*yr, 4*yr, 4*yr, 4*yr, 4*yr, 5*yr, 7*yr, 1*yr, 1*yr)
200
+
201
+ self.det = {}
202
+ for i, name in enumerate(det_names):
203
+ f_noise, noise, f_PLI, PLI = det_files[name]
204
+ self.det[name] = {
205
+ "plot_name": det_plot_names[i],
206
+ "f_noise": f_noise,
207
+ "noise": noise,
208
+ "f_PLI": f_PLI,
209
+ "PLI": PLI,
210
+ "thr": det_thr[i],
211
+ "tobs": det_tobs[i],
212
+ "color": det_colors[i],
213
+ "zorder": det_zorder[i],
214
+ "no_plot": name in det_no_plot,
215
+ "single_obs": name in det_single_obs_names,
216
+ }
217
+
218
+ self.det_names = det_names
219
+ self.PTA_labels = tuple(PTA_LIKELIHOOD_SETTINGS.keys())
220
+ self.PTA_names = tuple(
221
+ str(cfg["pta_name"]) for cfg in PTA_LIKELIHOOD_SETTINGS.values()
222
+ )
223
+ self.PTA_bins = tuple(int(cfg["nbins"]) for cfg in PTA_LIKELIHOOD_SETTINGS.values())
224
+ self.foldername = ""
225
+
226
+ def _load_ground_space_detectors(self, path_func):
227
+ """Load SKA/LISA-style detector curves (Breitbach & Schwaller et al., 1811.11175)."""
228
+ det_PLI_f, *det_PLI_data = np.genfromtxt(path_func("PLI-dataset.csv"), delimiter=",", unpack=True, skip_header=1)
229
+ det_noise_f, *det_noise_data = np.genfromtxt(path_func("noise-dataset.csv"), delimiter=",", unpack=True, skip_header=1)
230
+ names = (
231
+ "SKA_5_yrs",
232
+ "SKA_10_yrs",
233
+ "SKA_20_yrs",
234
+ "EPTA_18_yrs",
235
+ "NANOGrav_11_yrs",
236
+ "NANOGrav_15_yrs",
237
+ "LISA",
238
+ "B-DECIGO",
239
+ "DECIGO",
240
+ "BBO",
241
+ "ET",
242
+ )
243
+ curves = {
244
+ "SKA_5_yrs": (det_noise_f, det_noise_data[0], det_PLI_f, det_PLI_data[0]),
245
+ "SKA_10_yrs": (det_noise_f, det_noise_data[1], det_PLI_f, det_PLI_data[1]),
246
+ "SKA_20_yrs": (det_noise_f, det_noise_data[2], det_PLI_f, det_PLI_data[2]),
247
+ "EPTA_18_yrs": (det_noise_f, det_noise_data[3], det_PLI_f, det_PLI_data[3]),
248
+ "NANOGrav_11_yrs": (det_noise_f, det_noise_data[4], det_PLI_f, det_PLI_data[4]),
249
+ "LISA": (det_noise_f, det_noise_data[5], det_PLI_f, det_PLI_data[5]),
250
+ "B-DECIGO": (det_noise_f, det_noise_data[6], det_PLI_f, det_PLI_data[6]),
251
+ "DECIGO": (det_noise_f, det_noise_data[7], det_PLI_f, det_PLI_data[7]),
252
+ "BBO": (det_noise_f, det_noise_data[8], det_PLI_f, det_PLI_data[8]),
253
+ "ET": (det_noise_f, det_noise_data[9], det_PLI_f, det_PLI_data[9]),
254
+ }
255
+ return {"names": names, "curves": curves}
256
+
257
+ def _load_mu_ares(self, path_func):
258
+ """Load μAres sensitivity and PLI curves (Sesana et al., 1908.11391)."""
259
+ det_muAres_PLI_f, det_muAres_PLI_data = np.genfromtxt(path_func("muAres_f_Hz_PLI_h2OmegaGW.csv"), delimiter=";", unpack=True)
260
+ det_muAres_noise_f, det_muAres_noise_data = np.genfromtxt(path_func("muAres_f_Hz_h2Omega_noise.csv"), delimiter=";", unpack=True)
261
+ curves = {
262
+ "muAres": (det_muAres_noise_f, det_muAres_noise_data, det_muAres_PLI_f, det_muAres_PLI_data)
263
+ }
264
+ return {"names": ("muAres",), "curves": curves}
265
+
266
+ def _load_ground_based(self, path_func):
267
+ """Load the LIGO/Virgo design and O(2) curves (Schmitz et al., 2002.04615)."""
268
+ det_HLV_O2_PLI_f, det_HLV_O2_PLI_data = np.genfromtxt(path_func("HLV-O2-PLI.csv"), delimiter=";", unpack=True)
269
+ det_HLV_O2_noise_f, det_HLV_O2_noise_data = np.genfromtxt(path_func("HLV-O2-noise.csv"), delimiter=";", unpack=True)
270
+ det_HLVK_design_PLI_f, det_HLVK_design_PLI_data = np.genfromtxt(path_func("HLVK-design-PLI.csv"), delimiter=";", unpack=True)
271
+ det_HLVK_design_noise_f, det_HLVK_design_noise_data = np.genfromtxt(path_func("HLVK-design-noise.csv"), delimiter=";", unpack=True)
272
+ curves = {
273
+ "HLV_O2": (det_HLV_O2_noise_f, det_HLV_O2_noise_data, det_HLV_O2_PLI_f, det_HLV_O2_PLI_data),
274
+ "HLVK_design": (
275
+ det_HLVK_design_noise_f,
276
+ det_HLVK_design_noise_data,
277
+ det_HLVK_design_PLI_f,
278
+ det_HLVK_design_PLI_data,
279
+ ),
280
+ }
281
+ return {"names": ("HLV_O2", "HLVK_design"), "curves": curves}
282
+
283
+ def _load_ng15(self, path_func):
284
+ """Load the NANOGrav 15yr data and convert it to h^2Ω_GW (NANOGrav Collab., 2306.16218)."""
285
+ det_NG15_PLI_f, det_NG15_PLI_data_hc = np.genfromtxt(path_func("NG15_PLI_hc.csv"), delimiter=",", unpack=True, skip_header=1)
286
+ det_NG15_noise_f, det_NG15_noise_data_hc = np.genfromtxt(path_func("NG15_noise_hc.csv"), delimiter=",", unpack=True, skip_header=1)
287
+
288
+ def _hc_to_h2OmegaGW(f_Hz, h_c):
289
+ return 2 * np.pi**2 / 3.0 * (f_Hz**2) * (h_c**2) / H100_Hz**2
290
+
291
+ det_NG15_PLI_f = det_NG15_PLI_f[::-1]
292
+ det_NG15_noise_f = det_NG15_noise_f[::-1]
293
+ det_NG15_PLI_data_hc = det_NG15_PLI_data_hc[::-1]
294
+ det_NG15_noise_data_hc = det_NG15_noise_data_hc[::-1]
295
+ det_NG15_PLI_data = _hc_to_h2OmegaGW(det_NG15_PLI_f, det_NG15_PLI_data_hc)
296
+ det_NG15_noise_data = _hc_to_h2OmegaGW(det_NG15_noise_f, det_NG15_noise_data_hc)
297
+
298
+ curves = {
299
+ "NANOGrav_15_yrs": (det_NG15_noise_f, det_NG15_noise_data, det_NG15_PLI_f, det_NG15_PLI_data)
300
+ }
301
+ return {"names": ("NANOGrav_15_yrs",), "curves": curves}
302
+
303
+ # ==================================================
304
+ # PTArcade compatible config and signal models
305
+ # ==================================================
306
+
307
+ class PTAConfig:
308
+ """Minimal PTArcade-style configuration container used by the PTA likelihood wrappers."""
309
+
310
+ def __init__(self, nfreqBins=14, PTA="NG15"):
311
+ """Store the PTA dataset label and the number of GW frequency bins."""
312
+ self.pta_data = PTA # "NG15" or "NG12" or "IPTA2"
313
+ self.corr = False
314
+ self.red_components = 30
315
+ self.gwb_components = nfreqBins
316
+ self.bhb_th_prior = True
317
+ self.mod_sel = False
318
+ self.A_bhb_logmin = False
319
+ self.A_bhb_logmax = False
320
+
321
+ class PTAModel:
322
+ """Adapter exposing a TransitionListener spectrum in the format expected by PTArcade."""
323
+
324
+ def __init__(self, spectrum):
325
+ """Store the stochastic background spectrum callable and metadata."""
326
+ self.spectrum = spectrum
327
+ self.smbhb = False
328
+ self.parameters = {}
329
+
330
+ class PTAModel_SMBHB:
331
+ """PTArcade adapter including the nuisance parameters for an SMBHB foreground."""
332
+
333
+ def __init__(self, spectrum):
334
+ """Store the spectrum and freeze the astrophysical nuisance priors."""
335
+ self.spectrum = spectrum
336
+ self.smbhb = False
337
+ self.parameters = {
338
+ # The ranges are irrelevant for the likelihood calculation
339
+ 'A': Uniform(0, 0)('A'),
340
+ 'gamma': Uniform(0, 0)('gamma')
341
+ }
342
+
343
+
344
+
345
+ class Observability(GW_Sensitivity_Data):
346
+ """Compute SNRs, PTA likelihoods and derived quantities for a GW spectrum."""
347
+
348
+ def __init__(self, gwspec_dict: dict, verbose: bool=False,
349
+ include_smbhb: bool=False):
350
+ """Store the supplied spectrum and immediately evaluate all observables."""
351
+ super(Observability, self).__init__()
352
+ self.gwspec_dict = gwspec_dict
353
+ self.verbose = verbose
354
+
355
+ if include_smbhb:
356
+ self.my_spec = gwspec_dict["h2OmegaGW_with_astrobg_fun"]
357
+ else:
358
+ self.my_spec = gwspec_dict["h2OmegaGW_fun"]
359
+ self.my_spec_with_astrobg = gwspec_dict["h2OmegaGW_with_astrobg_fun"]
360
+ self.add_spec_info = gwspec_dict["additional_spec_info"]
361
+
362
+ self.h2OmegaGW = gwspec_dict["h2OmegaGW"]
363
+ self.h2OmegaGW_with_astrobg = gwspec_dict["h2OmegaGW_with_astrobg"]
364
+
365
+ self.calc_SNR()
366
+ self.pta_likelihoods = {label: {} for label in self.PTA_labels}
367
+ self.ptas = {}
368
+ if signal_builder is not None and _PTA_IMPORT_ERROR is None:
369
+ for label, settings in PTA_LIKELIHOOD_SETTINGS.items():
370
+ if include_smbhb:
371
+ input_payload = {
372
+ "config": PTAConfig(
373
+ int(settings["nbins"]),
374
+ str(settings["pta_name"]),
375
+ ),
376
+ "model": PTAModel_SMBHB(self.my_spec_with_astrobg),
377
+ }
378
+ else:
379
+ input_payload = {
380
+ "config": PTAConfig(
381
+ nfreqBins=int(settings["nbins"]),
382
+ PTA=str(settings["pta_name"]),
383
+ ),
384
+ "model": PTAModel(self.my_spec),
385
+ }
386
+ self.ptas[label] = signal_builder.ceffyl_builder(input_payload)
387
+ if include_smbhb:
388
+ self.calc_PTA_logL_with_astronoise(
389
+ gwspec_dict.astroparams_dict["A"],
390
+ gwspec_dict.astroparams_dict["gamma"],
391
+ )
392
+ else:
393
+ self.calc_PTA_logL()
394
+ elif self.verbose:
395
+ msg = "[yellow]PTA likelihood dependencies unavailable; continuing with SNR-only observability outputs.[/yellow]"
396
+ if _PTA_IMPORT_ERROR is not None:
397
+ msg += f"\n [dim]Import error: {_PTA_IMPORT_ERROR}[/dim]"
398
+ console.print(msg)
399
+ self.calc_DNeff_GW()
400
+ self.observability_dict = self.return_gw_info_dict()
401
+
402
+ if self.verbose:
403
+ self.print_additional_spec_info()
404
+
405
+ def __iter__(self):
406
+ """Enable iteration over the assembled observability dictionary."""
407
+ return iter(self.observability_dict)
408
+
409
+ def __getitem__(self, key: str):
410
+ """Access individual observables by key."""
411
+ return self.observability_dict[key]
412
+
413
+ def __len__(self):
414
+ """Return the number of entries in the observability dictionary."""
415
+ return len(self.observability_dict)
416
+
417
+ def keys(self):
418
+ """Expose the keys present in the observability dictionary."""
419
+ return self.observability_dict.keys()
420
+
421
+ def _get_additional_value(self, key: str, variant: str) -> float:
422
+ """Helper to access derived additional-spectrum values safely."""
423
+ entry = self.add_spec_info.get(key, {})
424
+ if isinstance(entry, dict):
425
+ value = entry.get(variant, np.nan)
426
+ if isinstance(value, (float, np.floating, int, np.integer)):
427
+ return float(value)
428
+ return float("nan")
429
+
430
+ def _flatten_additional_spec_info(self) -> OrderedDict[str, float | str]:
431
+ """Return an ordered mapping with lin/log/latex variants for output."""
432
+ flattened: OrderedDict[str, float | str] = OrderedDict()
433
+ for key, entry in self.add_spec_info.items():
434
+ if not isinstance(entry, dict):
435
+ continue
436
+ lin = entry.get("lin", np.nan)
437
+ log10 = entry.get("log10", np.nan)
438
+ latex = entry.get("latex")
439
+
440
+ if np.isscalar(lin):
441
+ flattened[f"{key}_lin"] = float(lin)
442
+ else:
443
+ flattened[f"{key}_lin"] = lin
444
+
445
+ if np.isscalar(log10):
446
+ flattened[f"{key}_log10"] = float(log10)
447
+ else:
448
+ flattened[f"{key}_log10"] = log10
449
+
450
+ if latex is not None:
451
+ flattened[f"{key}_latex"] = str(latex)
452
+
453
+ return flattened
454
+
455
+
456
+ def calc_SNR(self):
457
+ """Compute the signal-to-noise ratio for each detector sensitivity curve."""
458
+ def calc_SNR_for_det(detector_name):
459
+ sensitivity = self.det[detector_name]["noise"]
460
+ freq = self.det[detector_name]["f_noise"]
461
+ frac = np.array(
462
+ (self.my_spec(freq)/sensitivity)**2)
463
+ integral = integrate.simpson(frac, freq)
464
+ tobs = self.det[detector_name]["tobs"]
465
+ snr2 = 2. * tobs * integral
466
+ if self.det[detector_name]["single_obs"]:
467
+ snr2 /= 2.
468
+ return np.sqrt(snr2)
469
+
470
+ SNR = np.zeros(len(self.det_names))
471
+ detectable = np.zeros(len(self.det_names), dtype=bool)
472
+ for i, name in enumerate(self.det_names):
473
+ SNR[i] = calc_SNR_for_det(name)
474
+ log_thr = np.log10(self.det[name]["thr"])
475
+ detectable[i] = bool(np.log10(SNR[i]) > log_thr)
476
+
477
+ if self.verbose:
478
+ existing_detectors = {
479
+ "EPTA_18_yrs",
480
+ "NANOGrav_11_yrs",
481
+ "NANOGrav_15_yrs",
482
+ "HLV_O2",
483
+ }
484
+ table = rich.table.Table(
485
+ title="SNR VALUES AND OBSERVABILITY",
486
+ title_justify="left",
487
+ box=rich.box.ROUNDED,
488
+ )
489
+ table.add_column("Detector", style="cyan", no_wrap=True)
490
+ table.add_column("SNR", style="orange1", justify="right")
491
+ table.add_column(
492
+ "Should have been observed already?",
493
+ style="green",
494
+ justify="center",
495
+ )
496
+ table.add_column("Observable?", style="green", justify="center")
497
+
498
+ for n, v, d in zip(self.det_names, SNR, detectable):
499
+ existing = n in EXISTING_OBSERVATORIES
500
+ mark = "[green]\u2714[/green]" if d else "[red]\u274C[/red]"
501
+ observed_col = mark if existing else ""
502
+ future_col = mark if not existing else ""
503
+ table.add_row(n, f"{v:.4e}", observed_col, future_col)
504
+ console.print("\n", table)
505
+
506
+ self.SNR = SNR
507
+ self.detectable = detectable
508
+ return SNR
509
+
510
+ def calc_DNeff_GW(self):
511
+ """Compute the contribution to the effective number of relativistic species from GWs."""
512
+ def integrand(x):
513
+ return self.my_spec(x)/x # h2OmegaGW(f) / f as a function of f_Hz
514
+
515
+ f_min = 1.5e-11 # Hz
516
+ f_max = 1e10 # Hz
517
+ freqs = np.logspace(np.log(f_min), np.log(f_max), 100000)
518
+ integrand_values = integrand(freqs)
519
+ h2OmegaGW = integrate.simpson(integrand_values, freqs)
520
+ DNeff_GW = (8/7) * (11/4)**(4/3) * h2OmegaGW / Omega_gamma_h2
521
+ self.DNeff_GW = DNeff_GW
522
+ self.add_spec_info["DNeff_GW"] = {
523
+ "lin": float(DNeff_GW),
524
+ "log10": self._safe_log10(DNeff_GW),
525
+ "latex": r"$\Delta N_\mathrm{eff}^{\mathrm{GW}}$",
526
+ "label": "DNeff_GW",
527
+ }
528
+ return DNeff_GW
529
+
530
+ def calc_PTA_logL(self):
531
+ """Evaluate PTA log-likelihoods using PTArcade for the stored spectrum."""
532
+ _require_pta_dependencies()
533
+
534
+ logL_values: dict[str, float] = {}
535
+ for label in PTA_LIKELIHOOD_SETTINGS:
536
+ pta = self.ptas[label]
537
+ logL_values[label] = float(pta.ln_likelihood(np.array([])))
538
+
539
+ self._populate_pta_likelihoods(logL_values)
540
+
541
+ if self.verbose:
542
+ self._print_pta_likelihood_table()
543
+
544
+ return self.logL_PTAs
545
+
546
+ def _populate_pta_likelihoods(self, logL_values: dict[str, float]):
547
+ """Store PTA likelihood variants, mock approximations, and derived metrics."""
548
+ self.logL_PTAs = np.array(
549
+ [logL_values.get(label, np.nan) for label in PTA_LIKELIHOOD_SETTINGS]
550
+ )
551
+
552
+ pta_likelihoods = {}
553
+ for label, settings in PTA_LIKELIHOOD_SETTINGS.items():
554
+ actual = logL_values.get(label, np.nan)
555
+ threshold = float(settings["threshold"])
556
+ bestfit = float(settings["bestfit_lnL"])
557
+ mock = self._compute_mock_likelihood(threshold)
558
+ smooth = self._compute_smoothened_likelihood(actual, mock, threshold)
559
+
560
+ variant_data = {}
561
+ for variant, value in (
562
+ ("PTArcade", actual),
563
+ ("mock", mock),
564
+ ("smooth", smooth),
565
+ ):
566
+ delta = self._compute_delta_lnL(value, bestfit)
567
+ sigma = self._delta_to_sigma(delta)
568
+ within = self._within_three_sigma(delta) if variant == "PTArcade" else None
569
+ variant_data[variant] = {
570
+ "lnL": value,
571
+ "delta_lnL": delta,
572
+ "sigma": sigma,
573
+ "within_3sigma": within,
574
+ }
575
+
576
+ pta_likelihoods[label] = variant_data
577
+
578
+ self.pta_likelihoods = pta_likelihoods
579
+
580
+ def _compute_mock_likelihood(self, threshold: float) -> float:
581
+ """Compute the mock likelihood using peak frequency and amplitude."""
582
+ log10_h2 = self._get_additional_value("h2OmegaGW_peak", "log10")
583
+ log10_f = self._get_additional_value("f_peak_Hz", "log10")
584
+
585
+ if not (np.isfinite(log10_h2) and np.isfinite(log10_f)):
586
+ return np.nan
587
+
588
+ diff_h2 = log10_h2 - MOCK_LNL_REFERENCE["h2OmegaGW_peak_log10"]
589
+ diff_f = log10_f - MOCK_LNL_REFERENCE["f_peak_Hz_log10"]
590
+ return threshold - diff_h2 ** 2 - diff_f ** 2
591
+
592
+ @staticmethod
593
+ def _compute_smoothened_likelihood(actual: float, mock: float, threshold: float) -> float:
594
+ """Return PTArcade likelihood when above threshold, else the mock approximation."""
595
+ if np.isfinite(actual) and actual >= threshold:
596
+ return actual
597
+ return mock if np.isfinite(mock) else actual
598
+
599
+ @staticmethod
600
+ def _compute_delta_lnL(value: float, bestfit: float) -> float:
601
+ """Return the log-likelihood distance to the best-fit value."""
602
+ if not np.isfinite(value):
603
+ return np.inf
604
+ return bestfit - value
605
+
606
+ @staticmethod
607
+ def _delta_to_sigma(delta_lnL: float) -> float:
608
+ """Translate a log-likelihood difference into an approximate sigma distance."""
609
+ if not np.isfinite(delta_lnL):
610
+ return np.inf
611
+ return np.sqrt(2.0 * max(delta_lnL, 0.0))
612
+
613
+ @staticmethod
614
+ def _within_three_sigma(delta_lnL: float) -> bool:
615
+ """Return True if the delta log-likelihood is within the 3-sigma criterion."""
616
+ return bool(np.isfinite(delta_lnL) and delta_lnL < DELTA_LNL_WITHIN_THRESHOLD)
617
+
618
+ def _print_pta_likelihood_table(self):
619
+ """Pretty-print PTA likelihood variants and their derived metrics."""
620
+ table = rich.table.Table(
621
+ title="LOG LIKELIHOODS FOR PULSAR TIMING ARRAYS",
622
+ title_justify="left",
623
+ box=rich.box.ROUNDED,
624
+ )
625
+ table.add_column("PTA", style="cyan", no_wrap=True)
626
+ table.add_column("Bins", style="cyan", justify="center")
627
+ table.add_column("Variant", style="magenta", no_wrap=True)
628
+ table.add_column("lnL", style="orange1", justify="right")
629
+ table.add_column("Δ lnL", style="orange1", justify="right")
630
+ table.add_column("σ", style="orange1", justify="right")
631
+ table.add_column("Within 3σ?", style="green", justify="center")
632
+
633
+ for label, settings in PTA_LIKELIHOOD_SETTINGS.items():
634
+ variant_data = self.pta_likelihoods.get(label, {})
635
+ bins = int(settings["nbins"])
636
+ display_name = PTA_DISPLAY_NAMES.get(label, label)
637
+ for idx, variant in enumerate(PTA_LIKELIHOOD_VARIANTS):
638
+ metrics = variant_data.get(variant, {})
639
+ lnL_val = metrics.get("lnL", np.nan)
640
+ delta_val = metrics.get("delta_lnL", np.nan)
641
+ sigma_val = metrics.get("sigma", np.nan)
642
+ within = metrics.get("within_3sigma") if variant == "PTArcade" else None
643
+
644
+ lnL_str = self._format_float(lnL_val, decimals=2)
645
+ delta_str = self._format_float(delta_val, decimals=2)
646
+ sigma_str = self._format_float(sigma_val, decimals=2)
647
+ mark = ""
648
+ if variant == "PTArcade" and isinstance(within, bool):
649
+ mark = "[green]\u2714[/green]" if within else "[red]\u274C[/red]"
650
+
651
+ end_section = variant == PTA_LIKELIHOOD_VARIANTS[-1]
652
+ table.add_row(
653
+ display_name if idx == 0 else "",
654
+ str(bins),
655
+ variant,
656
+ lnL_str,
657
+ delta_str,
658
+ sigma_str,
659
+ f"{mark}",
660
+ end_section=end_section,
661
+ )
662
+ console.print("\n", table)
663
+
664
+ @staticmethod
665
+ def _format_float(value: float, decimals: int = 10) -> str:
666
+ """Format floating-point values for readable table output."""
667
+ if not np.isfinite(value):
668
+ return "nan"
669
+ return f"{value:.{decimals}f}"
670
+
671
+ @staticmethod
672
+ def _format_scientific(value: float) -> str:
673
+ """Format a floating-point value using scientific notation."""
674
+ if not np.isfinite(value):
675
+ return "nan"
676
+ return f"{value:.4e}"
677
+
678
+ @staticmethod
679
+ def _safe_log10(value: float) -> float:
680
+ """Safely compute the base-10 logarithm of a positive value."""
681
+ try:
682
+ numeric = float(value)
683
+ except (TypeError, ValueError):
684
+ return float("nan")
685
+ if numeric > 0:
686
+ return float(np.log10(numeric))
687
+ return float("nan")
688
+
689
+ def calc_PTA_logL_with_astronoise(self, A: float, gamma: float):
690
+ """Evaluate PTA log-likelihoods including an astrophysical background."""
691
+ _require_pta_dependencies()
692
+
693
+ logL_values: dict[str, float] = {}
694
+ for label in PTA_LIKELIHOOD_SETTINGS:
695
+ pta = self.ptas[label]
696
+ logL_values[label] = float(pta.ln_likelihood(np.array([A, gamma])))
697
+
698
+ self._populate_pta_likelihoods(logL_values)
699
+
700
+ if self.verbose:
701
+ self._print_pta_likelihood_table()
702
+
703
+ return self.logL_PTAs
704
+
705
+ def print_additional_spec_info(self):
706
+ """Render a table summarising derived GW spectrum properties."""
707
+ table = rich.table.Table(
708
+ title="Additional information on GW spectrum",
709
+ title_justify="left",
710
+ show_header=True,
711
+ header_style="bold",
712
+ box=rich.box.ROUNDED,
713
+ )
714
+ table.add_column("Quantity", style="cyan", no_wrap=True)
715
+ table.add_column("Value", style="orange1", justify="right")
716
+
717
+ for key, entry in self.add_spec_info.items():
718
+ if not isinstance(entry, dict):
719
+ continue
720
+ label = entry.get("label", key)
721
+ lin = entry.get("lin", np.nan)
722
+ table.add_row(
723
+ str(label),
724
+ self._format_scientific(lin),
725
+ )
726
+
727
+ console.print("\n", table)
728
+
729
+ def initialise_gw_info_dict(self):
730
+ """Initialise an empty return object.
731
+
732
+ Parameters
733
+ ----------
734
+
735
+ Returns
736
+ -------
737
+
738
+ """
739
+ gw_info_dict = {}
740
+ # Add SNR and detectable for each detector
741
+ for name in self.det_names:
742
+ gw_info_dict[f"{name}_SNR"] = np.nan
743
+ gw_info_dict[f"{name}_detectable"] = False
744
+ # Add logL for each PTA
745
+ for label in self.PTA_labels:
746
+ variant_data = self.pta_likelihoods.get(label, {})
747
+ actual_metrics = variant_data.get("PTArcade", {})
748
+ gw_info_dict[f"lnL_{label}"] = np.nan
749
+ for variant in PTA_LIKELIHOOD_VARIANTS:
750
+ metrics = variant_data.get(variant, {})
751
+ gw_info_dict[f"lnL_{variant}_{label}"] = np.nan
752
+ gw_info_dict[f"delta_lnL_{variant}_{label}"] = np.nan
753
+ gw_info_dict[f"sigma_{variant}_{label}"] = np.nan
754
+ gw_info_dict[f"within_3sigma_{variant}_{label}"] = np.nan
755
+ # Add additional spectrum info
756
+ for key, value in self._flatten_additional_spec_info().items():
757
+ gw_info_dict[key] = value
758
+ # Add DNeff
759
+ gw_info_dict["DNeff_GW"] = np.nan
760
+ return gw_info_dict
761
+
762
+
763
+ def return_gw_info_dict(self):
764
+ """Collect SNRs, likelihoods and auxiliary info into a result dictionary."""
765
+ gw_info_dict = {}
766
+ # Add SNR and detectable for each detector
767
+ for name, snr, detectable in zip(self.det_names, self.SNR, self.detectable):
768
+ gw_info_dict[f"{name}_SNR"] = snr
769
+ gw_info_dict[f"{name}_detectable"] = detectable
770
+ # Add logL for each PTA
771
+ for label in self.PTA_labels:
772
+ variant_data = self.pta_likelihoods.get(label, {})
773
+ actual_metrics = variant_data.get("PTArcade", {})
774
+ gw_info_dict[f"lnL_{label}"] = actual_metrics.get("lnL", np.nan)
775
+ for variant in PTA_LIKELIHOOD_VARIANTS:
776
+ metrics = variant_data.get(variant, {})
777
+ gw_info_dict[f"lnL_{variant}_{label}"] = metrics.get("lnL", np.nan)
778
+ gw_info_dict[f"delta_lnL_{variant}_{label}"] = metrics.get("delta_lnL", np.nan)
779
+ gw_info_dict[f"sigma_{variant}_{label}"] = metrics.get("sigma", np.nan)
780
+ within_val = metrics.get("within_3sigma", np.nan)
781
+ if isinstance(within_val, (bool, np.bool_)):
782
+ gw_info_dict[f"within_3sigma_{variant}_{label}"] = bool(within_val)
783
+ else:
784
+ gw_info_dict[f"within_3sigma_{variant}_{label}"] = np.nan
785
+ # Add additional spectrum info
786
+ for key, value in self._flatten_additional_spec_info().items():
787
+ gw_info_dict[key] = value
788
+ # Add DNeff
789
+ gw_info_dict["DNeff_GW"] = self.DNeff_GW
790
+ return gw_info_dict