nullcal 0.2.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 (42) hide show
  1. nullcal/__init__.py +16 -0
  2. nullcal/calibration.py +189 -0
  3. nullcal/clustering/__init__.py +0 -0
  4. nullcal/clustering/base.py +39 -0
  5. nullcal/clustering/injection.py +63 -0
  6. nullcal/clustering/precompute.py +30 -0
  7. nullcal/clustering/single.py +208 -0
  8. nullcal/clustering/time_frequency_map.py +42 -0
  9. nullcal/data.py +93 -0
  10. nullcal/likelihood/__init__.py +9 -0
  11. nullcal/likelihood/recalibration_likelihood.py +227 -0
  12. nullcal/metadata/__init__.py +0 -0
  13. nullcal/metadata/yaml.py +30 -0
  14. nullcal/null_stream/__init__.py +0 -0
  15. nullcal/null_stream/calibration.py +40 -0
  16. nullcal/null_stream/null_stream.py +252 -0
  17. nullcal/null_stream/projector.py +48 -0
  18. nullcal/null_stream/whiten.py +106 -0
  19. nullcal/result/__init__.py +5 -0
  20. nullcal/result/result.py +50 -0
  21. nullcal/result/utils.py +13 -0
  22. nullcal/sampler.py +113 -0
  23. nullcal/studies/__init__.py +1 -0
  24. nullcal/studies/lwa_leakage.py +513 -0
  25. nullcal/studies/spline_resolution.py +148 -0
  26. nullcal/time_frequency_transform/README.md +7 -0
  27. nullcal/time_frequency_transform/__init__.py +23 -0
  28. nullcal/time_frequency_transform/inverse_wavelet_freq_funcs.py +42 -0
  29. nullcal/time_frequency_transform/inverse_wavelet_time_funcs.py +49 -0
  30. nullcal/time_frequency_transform/stft.py +45 -0
  31. nullcal/time_frequency_transform/transform_freq_funcs.py +180 -0
  32. nullcal/time_frequency_transform/transform_time_funcs.py +60 -0
  33. nullcal/time_frequency_transform/utils.py +21 -0
  34. nullcal/time_frequency_transform/wavelet_transforms.py +249 -0
  35. nullcal/utils/__init__.py +0 -0
  36. nullcal/utils/log.py +72 -0
  37. nullcal/utils/snr.py +29 -0
  38. nullcal/version.py +9 -0
  39. nullcal-0.2.0.dist-info/METADATA +222 -0
  40. nullcal-0.2.0.dist-info/RECORD +42 -0
  41. nullcal-0.2.0.dist-info/WHEEL +4 -0
  42. nullcal-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,513 @@
1
+ """Finite-arm ET response and one-bin LWA calibration-bias utilities.
2
+
3
+ The arm transfer follows Eq. (6) of Virtuoso and Milotti,
4
+ arXiv:2412.01693. The three directed Michelsons share the physical arms of
5
+ an equilateral triangle; their zero-frequency detector tensors therefore sum
6
+ to zero exactly.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+ from scipy.optimize import minimize
13
+
14
+ SPEED_OF_LIGHT_METRES_PER_SECOND = 299_792_458.0
15
+ ARM_LENGTH_METRES = 10_000.0
16
+ POLARIZATION_REFERENCE_ALIGNMENT_LIMIT = 0.9
17
+ EXACT_ZERO_OBJECTIVE_TOLERANCE = 1e-24
18
+
19
+ # Pinned samples from bilby's ET_D_psd.txt at commit
20
+ # 75f834b14c0f2d8314d61b7276c39ed0ad1c00c9. Values between these study
21
+ # nodes are interpolated in log(f)-log(PSD), matching the positive scale of a
22
+ # tabulated noise curve without claiming an analytic ET-D model.
23
+ ET_D_FREQUENCIES_HZ = np.array(
24
+ [20.0, 30.0, 50.0, 75.0, 100.0, 150.0, 200.0, 250.0, 300.0, 400.0, 500.0, 750.0, 1000.0, 1500.0, 2000.0]
25
+ )
26
+ ET_D_PSD_PER_HZ = np.array(
27
+ [
28
+ 8.83385322431945442e-49,
29
+ 8.20501290795612654e-49,
30
+ 3.58369237696470193e-49,
31
+ 1.97862355828117043e-49,
32
+ 1.54691772612811412e-49,
33
+ 1.20395090018587473e-49,
34
+ 1.06971126111160879e-49,
35
+ 1.02194370960870872e-49,
36
+ 1.02432923291918803e-49,
37
+ 1.12792810871159091e-49,
38
+ 1.32202682728210237e-49,
39
+ 2.12804321065693099e-49,
40
+ 3.33030480074998683e-49,
41
+ 6.84831434812583600e-49,
42
+ 1.18267671514987749e-48,
43
+ ]
44
+ )
45
+
46
+ _SQRT_THREE = np.sqrt(3.0)
47
+ _TRIANGLE_DIRECTIONS = np.array([[1.0, 0.0, 0.0], [-0.5, _SQRT_THREE / 2.0, 0.0], [-0.5, -_SQRT_THREE / 2.0, 0.0]])
48
+ _MICHELSON_ARMS = (
49
+ (_TRIANGLE_DIRECTIONS[0], -_TRIANGLE_DIRECTIONS[2]),
50
+ (_TRIANGLE_DIRECTIONS[1], -_TRIANGLE_DIRECTIONS[0]),
51
+ (_TRIANGLE_DIRECTIONS[2], -_TRIANGLE_DIRECTIONS[1]),
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Population:
57
+ """Isotropic source directions, polarizations, and inclinations."""
58
+
59
+ direction: np.ndarray
60
+ polarization: np.ndarray
61
+ cosine_inclination: np.ndarray
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class NoisePair:
66
+ """Signal-present and signal-absent data sharing one noise draw."""
67
+
68
+ signal_present: np.ndarray
69
+ signal_absent: np.ndarray
70
+ exact_signal: np.ndarray
71
+ whitened_present: np.ndarray
72
+ whitened_absent: np.ndarray
73
+ whitened_signal: np.ndarray
74
+ whitened_lwa_signal: np.ndarray
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class MapRecovery:
79
+ """One-frequency maximum-a-posteriori LWA calibration recovery."""
80
+
81
+ amplitude: np.ndarray
82
+ latent_phase: np.ndarray
83
+ physical_phase: np.ndarray
84
+ objective: float
85
+ iterations: int
86
+ success: bool
87
+
88
+
89
+ def _unit_vector(vector: np.ndarray, name: str) -> np.ndarray:
90
+ vector = np.asarray(vector, dtype=float)
91
+ if vector.shape != (3,) or np.any(~np.isfinite(vector)):
92
+ raise ValueError(f"{name} must be a finite three-vector")
93
+ norm = np.linalg.norm(vector)
94
+ if norm == 0.0:
95
+ raise ValueError(f"{name} must be nonzero")
96
+ return vector / norm
97
+
98
+
99
+ def arm_transfer(
100
+ frequencies_hz: np.ndarray,
101
+ arm_direction: np.ndarray,
102
+ source_direction: np.ndarray,
103
+ *,
104
+ arm_length_metres: float = ARM_LENGTH_METRES,
105
+ ) -> np.ndarray:
106
+ """Evaluate the round-trip finite-arm transfer of one directed arm."""
107
+ frequencies = np.asarray(frequencies_hz, dtype=float)
108
+ if frequencies.ndim != 1 or np.any(~np.isfinite(frequencies)) or np.any(frequencies < 0.0):
109
+ raise ValueError("frequencies_hz must be a finite, nonnegative one-dimensional array")
110
+ if not np.isfinite(arm_length_metres) or arm_length_metres <= 0.0:
111
+ raise ValueError("arm_length_metres must be positive and finite")
112
+ arm = _unit_vector(arm_direction, "arm_direction")
113
+ direction = _unit_vector(source_direction, "source_direction")
114
+ travel_time = arm_length_metres / SPEED_OF_LIGHT_METRES_PER_SECOND
115
+ frequency_time = frequencies * travel_time
116
+ projection = float(np.dot(arm, direction))
117
+ return (
118
+ 0.5
119
+ * np.exp(-2j * np.pi * frequency_time)
120
+ * (
121
+ np.exp(1j * np.pi * frequency_time * (1.0 - projection)) * np.sinc(frequency_time * (1.0 + projection))
122
+ + np.exp(-1j * np.pi * frequency_time * (1.0 + projection)) * np.sinc(frequency_time * (1.0 - projection))
123
+ )
124
+ )
125
+
126
+
127
+ def _polarization_tensors(source_direction: np.ndarray, polarization: float) -> tuple[np.ndarray, np.ndarray]:
128
+ direction = _unit_vector(source_direction, "source_direction")
129
+ if not np.isfinite(polarization):
130
+ raise ValueError("polarization must be finite")
131
+ reference = np.array([0.0, 0.0, 1.0])
132
+ if abs(np.dot(reference, direction)) > POLARIZATION_REFERENCE_ALIGNMENT_LIMIT:
133
+ reference = np.array([0.0, 1.0, 0.0])
134
+ first = _unit_vector(np.cross(reference, direction), "polarization basis")
135
+ second = np.cross(direction, first)
136
+ cosine = np.cos(polarization)
137
+ sine = np.sin(polarization)
138
+ rotated_first = cosine * first + sine * second
139
+ rotated_second = -sine * first + cosine * second
140
+ plus = np.outer(rotated_first, rotated_first) - np.outer(rotated_second, rotated_second)
141
+ cross = np.outer(rotated_first, rotated_second) + np.outer(rotated_second, rotated_first)
142
+ return plus, cross
143
+
144
+
145
+ def et_response_matrix(
146
+ frequencies_hz: np.ndarray,
147
+ source_direction: np.ndarray,
148
+ polarization: float,
149
+ *,
150
+ arm_length_metres: float = ARM_LENGTH_METRES,
151
+ ) -> np.ndarray:
152
+ """Return the finite-arm ET response with shape (frequency, detector, polarization)."""
153
+ frequencies = np.asarray(frequencies_hz, dtype=float)
154
+ plus, cross = _polarization_tensors(source_direction, polarization)
155
+ tensors = (plus, cross)
156
+ response = np.empty((frequencies.size, len(_MICHELSON_ARMS), len(tensors)), dtype=complex)
157
+ for detector, (first_arm, second_arm) in enumerate(_MICHELSON_ARMS):
158
+ first_transfer = arm_transfer(frequencies, first_arm, source_direction, arm_length_metres=arm_length_metres)
159
+ second_transfer = arm_transfer(frequencies, second_arm, source_direction, arm_length_metres=arm_length_metres)
160
+ for mode, tensor in enumerate(tensors):
161
+ first_pattern = np.einsum("i,ij,j->", first_arm, tensor, first_arm)
162
+ second_pattern = np.einsum("i,ij,j->", second_arm, tensor, second_arm)
163
+ response[:, detector, mode] = 0.5 * (first_transfer * first_pattern - second_transfer * second_pattern)
164
+ return response
165
+
166
+
167
+ def et_d_psd(frequencies_hz: np.ndarray) -> np.ndarray:
168
+ """Interpolate the pinned ET-D one-sided PSD samples in log-log space."""
169
+ frequencies = np.asarray(frequencies_hz, dtype=float)
170
+ if frequencies.ndim != 1 or np.any(~np.isfinite(frequencies)):
171
+ raise ValueError("frequencies_hz must be a finite one-dimensional array")
172
+ if np.any(frequencies < ET_D_FREQUENCIES_HZ[0]) or np.any(frequencies > ET_D_FREQUENCIES_HZ[-1]):
173
+ raise ValueError("ET-D interpolation is restricted to 20-2000 Hz")
174
+ interpolated = np.exp(
175
+ np.interp(
176
+ np.log(frequencies),
177
+ np.log(ET_D_FREQUENCIES_HZ),
178
+ np.log(ET_D_PSD_PER_HZ),
179
+ )
180
+ )
181
+ for frequency, psd in zip(ET_D_FREQUENCIES_HZ, ET_D_PSD_PER_HZ, strict=True):
182
+ interpolated = np.where(frequencies == frequency, psd, interpolated)
183
+ return interpolated
184
+
185
+
186
+ def generate_population(size: int, *, seed: int) -> Population:
187
+ """Draw an isotropic source population from a recorded random seed."""
188
+ if size <= 0:
189
+ raise ValueError("size must be positive")
190
+ rng = np.random.default_rng(seed)
191
+ cosine_theta = rng.uniform(-1.0, 1.0, size)
192
+ azimuth = rng.uniform(0.0, 2.0 * np.pi, size)
193
+ sine_theta = np.sqrt(1.0 - cosine_theta**2)
194
+ direction = np.column_stack((sine_theta * np.cos(azimuth), sine_theta * np.sin(azimuth), cosine_theta))
195
+ return Population(
196
+ direction=direction,
197
+ polarization=rng.uniform(0.0, np.pi, size),
198
+ cosine_inclination=rng.uniform(-1.0, 1.0, size),
199
+ )
200
+
201
+
202
+ def null_p_value(whitened_null_sample: np.ndarray) -> np.ndarray:
203
+ """Return the single-bin p-value under unit complex Gaussian null noise.
204
+
205
+ Under the stated null, the normalized ET sum has independent real and
206
+ imaginary components with variance one half, so twice its squared
207
+ magnitude follows chi-square with two degrees of freedom.
208
+ """
209
+ sample = np.asarray(whitened_null_sample, dtype=complex)
210
+ return np.exp(-(np.abs(sample) ** 2))
211
+
212
+
213
+ def simulate_identical_noise_pair(
214
+ lwa_response: np.ndarray,
215
+ exact_response: np.ndarray,
216
+ polarization: np.ndarray,
217
+ *,
218
+ network_snr: float,
219
+ psd: float,
220
+ delta_f: float,
221
+ rng: np.random.Generator,
222
+ ) -> NoisePair:
223
+ """Generate exact-response signal data and its identical-noise null pair."""
224
+ lwa_response = np.asarray(lwa_response, dtype=complex)
225
+ exact_response = np.asarray(exact_response, dtype=complex)
226
+ polarization = np.asarray(polarization, dtype=complex)
227
+ if lwa_response.shape != (3, 2) or exact_response.shape != (3, 2) or polarization.shape != (2,):
228
+ raise ValueError("responses must be (3, 2) and polarization must be (2,)")
229
+ if network_snr <= 0.0 or psd <= 0.0 or delta_f <= 0.0:
230
+ raise ValueError("network_snr, psd, and delta_f must be positive")
231
+ raw_lwa_signal = lwa_response @ polarization
232
+ raw_norm = np.linalg.norm(raw_lwa_signal)
233
+ if raw_norm == 0.0:
234
+ raise ValueError("the LWA network response must be nonzero")
235
+ signal_scale = network_snr / raw_norm
236
+ whitened_lwa_signal = signal_scale * raw_lwa_signal
237
+ whitened_signal = signal_scale * (exact_response @ polarization)
238
+ whitening_scale = np.sqrt(psd / (2.0 * delta_f))
239
+ whitened_noise = (rng.normal(size=3) + 1j * rng.normal(size=3)) / np.sqrt(2.0)
240
+ physical_noise = whitening_scale * whitened_noise
241
+ exact_signal = whitening_scale * whitened_signal
242
+ signal_absent = physical_noise
243
+ signal_present = physical_noise + exact_signal
244
+ return NoisePair(
245
+ signal_present=signal_present,
246
+ signal_absent=signal_absent,
247
+ exact_signal=exact_signal,
248
+ whitened_present=signal_present / whitening_scale,
249
+ whitened_absent=signal_absent / whitening_scale,
250
+ whitened_signal=whitened_signal,
251
+ whitened_lwa_signal=whitened_lwa_signal,
252
+ )
253
+
254
+
255
+ def _calibration_factors(parameters: np.ndarray) -> np.ndarray:
256
+ amplitude = parameters[:3]
257
+ latent_phase = parameters[3:]
258
+ imaginary_phase = 1j * latent_phase
259
+ return (1.0 + amplitude) * (2.0 + imaginary_phase) / (2.0 - imaginary_phase)
260
+
261
+
262
+ def lwa_null_residual_energy(whitened_data: np.ndarray, calibration_factors: np.ndarray) -> float:
263
+ """Return the calibrated ET-LWA null-projection energy.
264
+
265
+ The uncalibrated ET response has left-null vector ``(1, 1, 1)``.
266
+ Multiplying detector rows by factors ``c_i`` therefore changes the
267
+ left-null vector to ``1 / conj(c_i)``. Normalizing that vector gives the
268
+ rank-one projector energy without a potentially ill-conditioned Gram
269
+ solve.
270
+ """
271
+ data = np.asarray(whitened_data, dtype=complex)
272
+ factors = np.asarray(calibration_factors, dtype=complex)
273
+ if data.shape != (3,) or factors.shape != (3,):
274
+ raise ValueError("whitened_data and calibration_factors must each contain three detectors")
275
+ if np.any(factors == 0.0):
276
+ raise ValueError("calibration_factors must be nonzero")
277
+ inverse_factors = 1.0 / factors
278
+ residual = np.sum(data * inverse_factors)
279
+ return float(np.abs(residual) ** 2 / np.sum(np.abs(inverse_factors) ** 2))
280
+
281
+
282
+ def recover_lwa_map(lwa_response: np.ndarray, whitened_data: np.ndarray, *, prior_sigma: float) -> MapRecovery:
283
+ """Recover the exact one-bin MAP of the LWA nullcal model.
284
+
285
+ This is the frequency-domain specialization of nullcal's projector
286
+ likelihood with equal detector PSDs and independent zero-mean Gaussian
287
+ amplitude and latent-phase priors.
288
+ """
289
+ response = np.asarray(lwa_response, dtype=complex)
290
+ data = np.asarray(whitened_data, dtype=complex)
291
+ if response.shape != (3, 2) or data.shape != (3,):
292
+ raise ValueError("lwa_response must be (3, 2) and whitened_data must be (3,)")
293
+ if not np.isfinite(prior_sigma) or prior_sigma <= 0.0:
294
+ raise ValueError("prior_sigma must be positive and finite")
295
+
296
+ def objective(parameters):
297
+ residual_energy = lwa_null_residual_energy(data, _calibration_factors(parameters))
298
+ return float(residual_energy + np.dot(parameters, parameters) / prior_sigma**2)
299
+
300
+ zero_parameters = np.zeros(6)
301
+ zero_objective = objective(zero_parameters)
302
+ if zero_objective < EXACT_ZERO_OBJECTIVE_TOLERANCE:
303
+ return MapRecovery(
304
+ amplitude=zero_parameters[:3],
305
+ latent_phase=zero_parameters[3:],
306
+ physical_phase=zero_parameters[3:],
307
+ objective=zero_objective,
308
+ iterations=0,
309
+ success=True,
310
+ )
311
+ solution = minimize(
312
+ objective,
313
+ zero_parameters,
314
+ method="L-BFGS-B",
315
+ bounds=[(-0.5, 0.5)] * 3 + [(-1.0, 1.0)] * 3,
316
+ options={"maxiter": 200},
317
+ )
318
+ if not solution.success:
319
+ solution = minimize(
320
+ objective,
321
+ solution.x,
322
+ method="Powell",
323
+ bounds=[(-0.5, 0.5)] * 3 + [(-1.0, 1.0)] * 3,
324
+ options={"maxiter": 400},
325
+ )
326
+ amplitude = solution.x[:3]
327
+ latent_phase = solution.x[3:]
328
+ return MapRecovery(
329
+ amplitude=amplitude,
330
+ latent_phase=latent_phase,
331
+ physical_phase=2.0 * np.arctan(latent_phase / 2.0),
332
+ objective=float(solution.fun),
333
+ iterations=int(solution.nit),
334
+ success=bool(solution.success),
335
+ )
336
+
337
+
338
+ def _quantile(values: np.ndarray, probability: float) -> float:
339
+ return float(np.quantile(values, probability))
340
+
341
+
342
+ def _source_block_bootstrap_p95_upper(
343
+ values: np.ndarray,
344
+ *,
345
+ resamples: int,
346
+ seed: int,
347
+ ) -> float:
348
+ """Return a one-sided 95% bootstrap bound for a population p95.
349
+
350
+ Rows are independent source draws and columns are repeated noise draws for
351
+ that source. Resampling whole rows preserves that clustered design.
352
+ """
353
+ if resamples <= 0:
354
+ raise ValueError("bootstrap resamples must be positive")
355
+ rng = np.random.default_rng(seed)
356
+ source_indices = rng.integers(0, values.shape[0], size=(resamples, values.shape[0]))
357
+ resampled = values[source_indices].reshape(resamples, -1)
358
+ estimates = np.quantile(resampled, 0.95, axis=1)
359
+ return max(float(np.quantile(values, 0.95)), float(np.quantile(estimates, 0.95)))
360
+
361
+
362
+ def evaluate_frequency( # noqa: PLR0915 - the study keeps one explicit paired-data loop
363
+ frequency_hz: float,
364
+ population: Population,
365
+ *,
366
+ noise_realizations: int,
367
+ noise_seed: int,
368
+ network_snr: float,
369
+ prior_sigma: float,
370
+ significance_alpha: float,
371
+ bootstrap_resamples: int,
372
+ bootstrap_seed: int,
373
+ ) -> dict[str, float | int]:
374
+ """Evaluate leakage significance and paired LWA-MAP bias at one frequency."""
375
+ if noise_realizations <= 0:
376
+ raise ValueError("noise_realizations must be positive")
377
+ if not 0.0 < significance_alpha < 1.0:
378
+ raise ValueError("significance_alpha must lie strictly between zero and one")
379
+ population_size = population.direction.shape[0]
380
+ if population.polarization.shape != (population_size,) or population.cosine_inclination.shape != (population_size,):
381
+ raise ValueError("population arrays must have a common leading size")
382
+
383
+ psd = float(et_d_psd(np.array([frequency_hz]))[0])
384
+ angular_frequency_arm = 2.0 * np.pi * frequency_hz * ARM_LENGTH_METRES / SPEED_OF_LIGHT_METRES_PER_SECOND
385
+ rng = np.random.default_rng(noise_seed)
386
+ sky_factors = []
387
+ leakage_snrs = []
388
+ physical_signal_norms = []
389
+ present_statistics = []
390
+ absent_statistics = []
391
+ paired_statistic_increases = []
392
+ amplitude_biases_percent = []
393
+ phase_biases_degrees = []
394
+ map_failure_count = 0
395
+
396
+ for direction, polarization_angle, cosine_inclination in zip(
397
+ population.direction,
398
+ population.polarization,
399
+ population.cosine_inclination,
400
+ strict=True,
401
+ ):
402
+ lwa_response = et_response_matrix(np.array([0.0]), direction, polarization_angle)[0]
403
+ exact_response = et_response_matrix(np.array([frequency_hz]), direction, polarization_angle)[0]
404
+ source_polarization = np.array([(1.0 + cosine_inclination**2) / 2.0, -1j * cosine_inclination], dtype=complex)
405
+ raw_lwa_signal = lwa_response @ source_polarization
406
+ signal_scale = network_snr / np.linalg.norm(raw_lwa_signal)
407
+ whitened_signal = signal_scale * (exact_response @ source_polarization)
408
+ leakage_snr = float(np.abs(np.sum(whitened_signal) / np.sqrt(3.0)))
409
+ leakage_snrs.append(leakage_snr)
410
+ sky_factors.append(leakage_snr / (network_snr * angular_frequency_arm))
411
+ physical_signal_norms.append(np.linalg.norm(whitened_signal) * np.sqrt(psd / 2.0))
412
+
413
+ for _ in range(noise_realizations):
414
+ pair = simulate_identical_noise_pair(
415
+ lwa_response,
416
+ exact_response,
417
+ source_polarization,
418
+ network_snr=network_snr,
419
+ psd=psd,
420
+ delta_f=1.0,
421
+ rng=rng,
422
+ )
423
+ absent_null = np.sum(pair.whitened_absent) / np.sqrt(3.0)
424
+ present_null = np.sum(pair.whitened_present) / np.sqrt(3.0)
425
+ absent_statistic = float(np.abs(absent_null) ** 2)
426
+ present_statistic = float(np.abs(present_null) ** 2)
427
+ absent_statistics.append(absent_statistic)
428
+ present_statistics.append(present_statistic)
429
+ paired_statistic_increases.append(present_statistic - absent_statistic)
430
+
431
+ lwa_control_map = recover_lwa_map(
432
+ lwa_response,
433
+ pair.whitened_absent + pair.whitened_lwa_signal,
434
+ prior_sigma=prior_sigma,
435
+ )
436
+ exact_response_map = recover_lwa_map(lwa_response, pair.whitened_present, prior_sigma=prior_sigma)
437
+ map_failure_count += int(not lwa_control_map.success) + int(not exact_response_map.success)
438
+ amplitude_biases_percent.append(
439
+ 100.0 * np.max(np.abs(exact_response_map.amplitude - lwa_control_map.amplitude))
440
+ )
441
+ phase_biases_degrees.append(
442
+ np.rad2deg(np.max(np.abs(exact_response_map.physical_phase - lwa_control_map.physical_phase)))
443
+ )
444
+
445
+ sky_factors = np.asarray(sky_factors)
446
+ leakage_snrs = np.asarray(leakage_snrs)
447
+ physical_signal_norms = np.asarray(physical_signal_norms)
448
+ present_statistics = np.asarray(present_statistics)
449
+ absent_statistics = np.asarray(absent_statistics)
450
+ paired_statistic_increases = np.asarray(paired_statistic_increases)
451
+ amplitude_biases_percent = np.asarray(amplitude_biases_percent)
452
+ phase_biases_degrees = np.asarray(phase_biases_degrees)
453
+ amplitude_biases_by_source = amplitude_biases_percent.reshape(population_size, noise_realizations)
454
+ phase_biases_by_source = phase_biases_degrees.reshape(population_size, noise_realizations)
455
+ return {
456
+ "frequency_hz": float(frequency_hz),
457
+ "two_pi_f_l_over_c": angular_frequency_arm,
458
+ "et_d_psd_per_hz": psd,
459
+ "population_size": population_size,
460
+ "noise_realizations": noise_realizations,
461
+ "bias_sample_count": int(amplitude_biases_percent.size),
462
+ "sky_factor_p05": _quantile(sky_factors, 0.05),
463
+ "sky_factor_median": _quantile(sky_factors, 0.50),
464
+ "sky_factor_p95": _quantile(sky_factors, 0.95),
465
+ "sky_factor_p99": _quantile(sky_factors, 0.99),
466
+ "sky_factor_maximum": float(np.max(sky_factors)),
467
+ "leakage_snr_p05": _quantile(leakage_snrs, 0.05),
468
+ "leakage_snr_median": _quantile(leakage_snrs, 0.50),
469
+ "leakage_snr_p95": _quantile(leakage_snrs, 0.95),
470
+ "physical_signal_norm_median": _quantile(physical_signal_norms, 0.50),
471
+ "absent_false_alarm_fraction": float(np.mean(null_p_value(np.sqrt(absent_statistics)) < significance_alpha)),
472
+ "present_detection_fraction": float(np.mean(null_p_value(np.sqrt(present_statistics)) < significance_alpha)),
473
+ "paired_statistic_increase_median": _quantile(paired_statistic_increases, 0.50),
474
+ "paired_statistic_increase_p05": _quantile(paired_statistic_increases, 0.05),
475
+ "paired_statistic_increase_p95": _quantile(paired_statistic_increases, 0.95),
476
+ "amplitude_bias_percent_median": _quantile(amplitude_biases_percent, 0.50),
477
+ "amplitude_bias_percent_p95": _quantile(amplitude_biases_percent, 0.95),
478
+ "amplitude_bias_percent_p95_upper95": _source_block_bootstrap_p95_upper(
479
+ amplitude_biases_by_source,
480
+ resamples=bootstrap_resamples,
481
+ seed=bootstrap_seed,
482
+ ),
483
+ "amplitude_bias_percent_p99": _quantile(amplitude_biases_percent, 0.99),
484
+ "amplitude_bias_percent_maximum": float(np.max(amplitude_biases_percent)),
485
+ "phase_bias_degrees_median": _quantile(phase_biases_degrees, 0.50),
486
+ "phase_bias_degrees_p95": _quantile(phase_biases_degrees, 0.95),
487
+ "phase_bias_degrees_p95_upper95": _source_block_bootstrap_p95_upper(
488
+ phase_biases_by_source,
489
+ resamples=bootstrap_resamples,
490
+ seed=bootstrap_seed + 1,
491
+ ),
492
+ "phase_bias_degrees_p99": _quantile(phase_biases_degrees, 0.99),
493
+ "phase_bias_degrees_maximum": float(np.max(phase_biases_degrees)),
494
+ "map_failure_count": map_failure_count,
495
+ }
496
+
497
+
498
+ __all__ = [
499
+ "ARM_LENGTH_METRES",
500
+ "SPEED_OF_LIGHT_METRES_PER_SECOND",
501
+ "MapRecovery",
502
+ "NoisePair",
503
+ "Population",
504
+ "arm_transfer",
505
+ "et_d_psd",
506
+ "et_response_matrix",
507
+ "evaluate_frequency",
508
+ "generate_population",
509
+ "lwa_null_residual_energy",
510
+ "null_p_value",
511
+ "recover_lwa_map",
512
+ "simulate_identical_noise_pair",
513
+ ]
@@ -0,0 +1,148 @@
1
+ """Measure the best uniform-error approximation available to a spline basis."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ from scipy.interpolate import CubicSpline
7
+ from scipy.optimize import linprog
8
+
9
+ MINIMUM_CUBIC_SPLINE_KNOTS = 4
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class MinimaxSplineFit:
14
+ """Result of fitting a cubic spline by its maximum absolute residual."""
15
+
16
+ node_values: np.ndarray
17
+ values: np.ndarray
18
+ residuals: np.ndarray
19
+ max_abs_residual: float
20
+
21
+
22
+ def augmented_log_knots(
23
+ *,
24
+ minimum_frequency: float,
25
+ maximum_frequency: float,
26
+ broadband_count: int,
27
+ peak_frequency: float,
28
+ local_half_width: float,
29
+ local_count: int,
30
+ ) -> np.ndarray:
31
+ """Combine broadband log-spaced knots with linear knots around a feature."""
32
+ broadband = np.geomspace(minimum_frequency, maximum_frequency, broadband_count)
33
+ local = np.linspace(peak_frequency - local_half_width, peak_frequency + local_half_width, local_count)
34
+ if local[0] <= minimum_frequency or local[-1] >= maximum_frequency:
35
+ raise ValueError("local knots must lie strictly inside the broadband knot band")
36
+ knots = np.sort(np.concatenate([broadband, local]))
37
+ if np.any(np.diff(knots) <= 0.0):
38
+ raise ValueError("local and broadband knots must not coincide")
39
+ return knots
40
+
41
+
42
+ def gaussian_bump(
43
+ frequencies: np.ndarray,
44
+ *,
45
+ peak_value: float,
46
+ peak_frequency: float,
47
+ frequency_width: float,
48
+ ) -> np.ndarray:
49
+ """Evaluate a Gaussian bump whose standard deviation is one quarter-width."""
50
+ frequencies = np.asarray(frequencies, dtype=float)
51
+ sigma = frequency_width / 4.0
52
+ return peak_value * np.exp(-0.5 * ((frequencies - peak_frequency) / sigma) ** 2)
53
+
54
+
55
+ def spline_design_matrix(frequencies: np.ndarray, knots: np.ndarray) -> np.ndarray:
56
+ """Return the not-a-knot cubic-spline basis evaluated in log-frequency."""
57
+ frequencies = np.asarray(frequencies, dtype=float)
58
+ knots = np.asarray(knots, dtype=float)
59
+ if knots.ndim != 1 or knots.size < MINIMUM_CUBIC_SPLINE_KNOTS:
60
+ raise ValueError("a cubic spline requires at least four knots")
61
+ if np.any(~np.isfinite(knots)) or np.any(knots <= 0.0) or np.any(np.diff(knots) <= 0.0):
62
+ raise ValueError("knots must be finite, positive, and strictly increasing")
63
+ if frequencies.ndim != 1 or frequencies.size == 0 or np.any(~np.isfinite(frequencies)):
64
+ raise ValueError("frequencies must be a non-empty, finite one-dimensional array")
65
+ if np.any(frequencies < knots[0]) or np.any(frequencies > knots[-1]):
66
+ raise ValueError("frequencies must lie within the knot band")
67
+
68
+ identity = np.eye(knots.size)
69
+ interpolator = CubicSpline(np.log10(knots), identity, axis=0, extrapolate=False)
70
+ return interpolator(np.log10(frequencies))
71
+
72
+
73
+ def minimax_spline_fit(frequencies: np.ndarray, target: np.ndarray, knots: np.ndarray) -> MinimaxSplineFit:
74
+ """Find spline node values minimizing max absolute residual on a grid."""
75
+ target = np.asarray(target, dtype=float)
76
+ design = spline_design_matrix(frequencies, knots)
77
+ if target.shape != (design.shape[0],) or np.any(~np.isfinite(target)):
78
+ raise ValueError("target must be finite and have one value per frequency")
79
+
80
+ constraint_matrix = np.block([[design, -np.ones((design.shape[0], 1))], [-design, -np.ones((design.shape[0], 1))]])
81
+ constraint_values = np.concatenate([target, -target])
82
+ objective = np.zeros(design.shape[1] + 1)
83
+ objective[-1] = 1.0
84
+ solution = linprog(
85
+ objective,
86
+ A_ub=constraint_matrix,
87
+ b_ub=constraint_values,
88
+ bounds=[(None, None)] * design.shape[1] + [(0.0, None)],
89
+ method="highs",
90
+ )
91
+ if not solution.success:
92
+ raise RuntimeError(f"minimax spline fit failed: {solution.message}")
93
+
94
+ node_values = solution.x[:-1]
95
+ values = np.einsum("ij,j->i", design, node_values)
96
+ residuals = values - target
97
+ return MinimaxSplineFit(
98
+ node_values=node_values,
99
+ values=values,
100
+ residuals=residuals,
101
+ max_abs_residual=float(np.max(np.abs(residuals))),
102
+ )
103
+
104
+
105
+ def minimax_phase_spline_fit(
106
+ frequencies: np.ndarray,
107
+ target_phase: np.ndarray,
108
+ knots: np.ndarray,
109
+ *,
110
+ bisection_steps: int = 48,
111
+ ) -> MinimaxSplineFit:
112
+ """Minimize physical phase residual for the rational phase mapping."""
113
+ target_phase = np.asarray(target_phase, dtype=float)
114
+ design = spline_design_matrix(frequencies, knots)
115
+ if target_phase.shape != (design.shape[0],) or np.any(~np.isfinite(target_phase)):
116
+ raise ValueError("target_phase must be finite and have one value per frequency")
117
+
118
+ lower_error = 0.0
119
+ upper_error = float(np.max(np.abs(target_phase)))
120
+ if np.any(np.abs(target_phase) + upper_error >= np.pi):
121
+ raise ValueError("target_phase +/- bisection bounds must lie strictly between -pi and pi")
122
+ node_values = np.zeros(design.shape[1])
123
+ for _ in range(bisection_steps):
124
+ trial_error = (lower_error + upper_error) / 2.0
125
+ upper_latent = 2.0 * np.tan((target_phase + trial_error) / 2.0)
126
+ lower_latent = 2.0 * np.tan((target_phase - trial_error) / 2.0)
127
+ solution = linprog(
128
+ np.zeros(design.shape[1]),
129
+ A_ub=np.concatenate([design, -design]),
130
+ b_ub=np.concatenate([upper_latent, -lower_latent]),
131
+ bounds=[(None, None)] * design.shape[1],
132
+ method="highs",
133
+ )
134
+ if solution.success:
135
+ upper_error = trial_error
136
+ node_values = solution.x
137
+ else:
138
+ lower_error = trial_error
139
+
140
+ latent_values = np.einsum("ij,j->i", design, node_values)
141
+ values = 2.0 * np.arctan(latent_values / 2.0)
142
+ residuals = values - target_phase
143
+ return MinimaxSplineFit(
144
+ node_values=node_values,
145
+ values=values,
146
+ residuals=residuals,
147
+ max_abs_residual=float(np.max(np.abs(residuals))),
148
+ )
@@ -0,0 +1,7 @@
1
+ ## Third-Party Code and Attributions
2
+
3
+ This project includes code from the following third-party sources:
4
+
5
+ - **[WDMWaveletTransforms]**: Licensed under the GNU General Public License v2
6
+ (or later), originally developed by [XGI-MSU](https://github.com/XGI-MSU).
7
+ Source available at <https://github.com/XGI-MSU/WDMWaveletTransforms>.
@@ -0,0 +1,23 @@
1
+ """
2
+ Time-frequency transform.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from .utils import get_shape_of_wavelet_transform
8
+ from .wavelet_transforms import (
9
+ transform_wavelet_freq,
10
+ transform_wavelet_freq_quadrature,
11
+ transform_wavelet_freq_time,
12
+ transform_wavelet_freq_time_quadrature,
13
+ transform_wavelet_time,
14
+ )
15
+
16
+ __all__ = [
17
+ "get_shape_of_wavelet_transform",
18
+ "transform_wavelet_freq",
19
+ "transform_wavelet_freq_quadrature",
20
+ "transform_wavelet_freq_time",
21
+ "transform_wavelet_freq_time_quadrature",
22
+ "transform_wavelet_time",
23
+ ]