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.
- nullcal/__init__.py +16 -0
- nullcal/calibration.py +189 -0
- nullcal/clustering/__init__.py +0 -0
- nullcal/clustering/base.py +39 -0
- nullcal/clustering/injection.py +63 -0
- nullcal/clustering/precompute.py +30 -0
- nullcal/clustering/single.py +208 -0
- nullcal/clustering/time_frequency_map.py +42 -0
- nullcal/data.py +93 -0
- nullcal/likelihood/__init__.py +9 -0
- nullcal/likelihood/recalibration_likelihood.py +227 -0
- nullcal/metadata/__init__.py +0 -0
- nullcal/metadata/yaml.py +30 -0
- nullcal/null_stream/__init__.py +0 -0
- nullcal/null_stream/calibration.py +40 -0
- nullcal/null_stream/null_stream.py +252 -0
- nullcal/null_stream/projector.py +48 -0
- nullcal/null_stream/whiten.py +106 -0
- nullcal/result/__init__.py +5 -0
- nullcal/result/result.py +50 -0
- nullcal/result/utils.py +13 -0
- nullcal/sampler.py +113 -0
- nullcal/studies/__init__.py +1 -0
- nullcal/studies/lwa_leakage.py +513 -0
- nullcal/studies/spline_resolution.py +148 -0
- nullcal/time_frequency_transform/README.md +7 -0
- nullcal/time_frequency_transform/__init__.py +23 -0
- nullcal/time_frequency_transform/inverse_wavelet_freq_funcs.py +42 -0
- nullcal/time_frequency_transform/inverse_wavelet_time_funcs.py +49 -0
- nullcal/time_frequency_transform/stft.py +45 -0
- nullcal/time_frequency_transform/transform_freq_funcs.py +180 -0
- nullcal/time_frequency_transform/transform_time_funcs.py +60 -0
- nullcal/time_frequency_transform/utils.py +21 -0
- nullcal/time_frequency_transform/wavelet_transforms.py +249 -0
- nullcal/utils/__init__.py +0 -0
- nullcal/utils/log.py +72 -0
- nullcal/utils/snr.py +29 -0
- nullcal/version.py +9 -0
- nullcal-0.2.0.dist-info/METADATA +222 -0
- nullcal-0.2.0.dist-info/RECORD +42 -0
- nullcal-0.2.0.dist-info/WHEEL +4 -0
- nullcal-0.2.0.dist-info/licenses/LICENSE +21 -0
nullcal/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A package to constrain calibration errors of a closed-geometry network
|
|
3
|
+
of gravitational-wave detectors.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .utils.log import setup_logger
|
|
9
|
+
from .version import __version__
|
|
10
|
+
|
|
11
|
+
setup_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
nullcal/calibration.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Pure JAX calibration model and Gaussian knot-prior functions.
|
|
2
|
+
|
|
3
|
+
The spline interpolates amplitude and latent phase in log10 frequency using
|
|
4
|
+
not-a-knot boundary conditions. It matches the archived reference model on a
|
|
5
|
+
uniform log-frequency grid and also permits nonuniform knot placement.
|
|
6
|
+
|
|
7
|
+
Importing this module enables JAX's process-wide ``jax_enable_x64`` setting.
|
|
8
|
+
Calibration inference requires float64 precision, so this side effect is an
|
|
9
|
+
intentional part of the module's public behavior.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import jax
|
|
15
|
+
|
|
16
|
+
# Calibration inference needs substantially more precision than JAX's default
|
|
17
|
+
# float32 mode. Enable x64 before importing jax.numpy so every public function
|
|
18
|
+
# can explicitly construct float64 inputs and complex128 outputs.
|
|
19
|
+
jax.config.update("jax_enable_x64", True)
|
|
20
|
+
|
|
21
|
+
import jax.numpy as jnp # noqa: E402
|
|
22
|
+
|
|
23
|
+
MINIMUM_CUBIC_SPLINE_KNOTS = 4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _float64(values):
|
|
27
|
+
"""Return ``values`` as a JAX float64 array."""
|
|
28
|
+
return jnp.asarray(values, dtype=jnp.float64)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate_prior_shapes(values, mean, sigma, parameter):
|
|
32
|
+
"""Require array hyperparameters to match their knot-value shape."""
|
|
33
|
+
if (mean.ndim != 0 and mean.shape != values.shape) or (sigma.ndim != 0 and sigma.shape != values.shape):
|
|
34
|
+
raise ValueError(f"{parameter} mean and sigma must be scalar or match the value shape")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _not_a_knot_second_derivatives(log_knots, node_values):
|
|
38
|
+
"""Solve the nonuniform not-a-knot cubic-spline continuity equations."""
|
|
39
|
+
knot_count = log_knots.shape[0]
|
|
40
|
+
widths = jnp.diff(log_knots)
|
|
41
|
+
system = jnp.zeros((knot_count, knot_count), dtype=jnp.float64)
|
|
42
|
+
right_hand_side = jnp.zeros(knot_count, dtype=jnp.float64)
|
|
43
|
+
|
|
44
|
+
system = system.at[0, :3].set(jnp.array((-widths[1], widths[0] + widths[1], -widths[0])))
|
|
45
|
+
system = system.at[-1, -3:].set(jnp.array((widths[-1], -(widths[-2] + widths[-1]), widths[-2])))
|
|
46
|
+
|
|
47
|
+
interior = jnp.arange(1, knot_count - 1)
|
|
48
|
+
left_widths = widths[:-1]
|
|
49
|
+
right_widths = widths[1:]
|
|
50
|
+
system = system.at[interior, interior - 1].set(left_widths)
|
|
51
|
+
system = system.at[interior, interior].set(2.0 * (left_widths + right_widths))
|
|
52
|
+
system = system.at[interior, interior + 1].set(right_widths)
|
|
53
|
+
right_hand_side = right_hand_side.at[interior].set(
|
|
54
|
+
6.0
|
|
55
|
+
* ((node_values[2:] - node_values[1:-1]) / right_widths - (node_values[1:-1] - node_values[:-2]) / left_widths)
|
|
56
|
+
)
|
|
57
|
+
return jnp.linalg.solve(system, right_hand_side)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _evaluate_spline(frequencies, knot_frequencies, node_values):
|
|
61
|
+
"""Evaluate a not-a-knot cubic spline in log10 frequency."""
|
|
62
|
+
log_knots = jnp.log10(knot_frequencies)
|
|
63
|
+
log_frequencies = jnp.log10(frequencies)
|
|
64
|
+
second_derivatives = _not_a_knot_second_derivatives(log_knots, node_values)
|
|
65
|
+
intervals = jnp.searchsorted(log_knots, log_frequencies, side="right") - 1
|
|
66
|
+
intervals = jnp.clip(intervals, 0, log_knots.size - 2)
|
|
67
|
+
|
|
68
|
+
left = log_knots[intervals]
|
|
69
|
+
right = log_knots[intervals + 1]
|
|
70
|
+
widths = right - left
|
|
71
|
+
left_distance = right - log_frequencies
|
|
72
|
+
right_distance = log_frequencies - left
|
|
73
|
+
return (
|
|
74
|
+
second_derivatives[intervals] * left_distance**3 / (6.0 * widths)
|
|
75
|
+
+ second_derivatives[intervals + 1] * right_distance**3 / (6.0 * widths)
|
|
76
|
+
+ (node_values[intervals] - second_derivatives[intervals] * widths**2 / 6.0) * left_distance / widths
|
|
77
|
+
+ (node_values[intervals + 1] - second_derivatives[intervals + 1] * widths**2 / 6.0) * right_distance / widths
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def calibration_factor(frequencies, knot_frequencies, amplitude, phase):
|
|
82
|
+
"""Evaluate the reference cubic-spline calibration factor in explicit float64.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
frequencies: Positive frequencies at which to evaluate the factor.
|
|
86
|
+
knot_frequencies: At least four positive, strictly increasing knots.
|
|
87
|
+
Knot placement and count are function parameters; nonuniform grids
|
|
88
|
+
such as the 19-knot spectroscopy grid are supported.
|
|
89
|
+
amplitude: Fractional amplitude error at each knot.
|
|
90
|
+
phase: Latent phase parameter at each knot, in radians.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A complex128 array equal to ``(1 + amplitude_spline) *
|
|
94
|
+
(2 + 1j * phase_spline) / (2 - 1j * phase_spline)``.
|
|
95
|
+
"""
|
|
96
|
+
frequencies = _float64(frequencies)
|
|
97
|
+
knot_frequencies = _float64(knot_frequencies)
|
|
98
|
+
amplitude = _float64(amplitude)
|
|
99
|
+
phase = _float64(phase)
|
|
100
|
+
if frequencies.ndim != 1 or knot_frequencies.ndim != 1 or amplitude.ndim != 1 or phase.ndim != 1:
|
|
101
|
+
raise ValueError("frequencies, knots, amplitude, and phase must be one-dimensional")
|
|
102
|
+
if knot_frequencies.size < MINIMUM_CUBIC_SPLINE_KNOTS:
|
|
103
|
+
raise ValueError("a cubic spline requires at least four knots")
|
|
104
|
+
if amplitude.shape != knot_frequencies.shape or phase.shape != knot_frequencies.shape:
|
|
105
|
+
raise ValueError("amplitude and phase must contain one value per knot")
|
|
106
|
+
delta_amplitude = _evaluate_spline(frequencies, knot_frequencies, amplitude)
|
|
107
|
+
delta_phase = _evaluate_spline(frequencies, knot_frequencies, phase)
|
|
108
|
+
imaginary_phase = jnp.asarray(1j, dtype=jnp.complex128) * delta_phase
|
|
109
|
+
return (1.0 + delta_amplitude) * (2.0 + imaginary_phase) / (2.0 - imaginary_phase)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def calibration_log_prior(
|
|
113
|
+
amplitude,
|
|
114
|
+
phase,
|
|
115
|
+
amplitude_mean,
|
|
116
|
+
amplitude_sigma,
|
|
117
|
+
phase_mean,
|
|
118
|
+
phase_sigma,
|
|
119
|
+
):
|
|
120
|
+
"""Return the normalized independent-Gaussian log-prior for knot values."""
|
|
121
|
+
amplitude = _float64(amplitude)
|
|
122
|
+
phase = _float64(phase)
|
|
123
|
+
amplitude_mean = _float64(amplitude_mean)
|
|
124
|
+
amplitude_sigma = _float64(amplitude_sigma)
|
|
125
|
+
phase_mean = _float64(phase_mean)
|
|
126
|
+
phase_sigma = _float64(phase_sigma)
|
|
127
|
+
_validate_prior_shapes(amplitude, amplitude_mean, amplitude_sigma, "amplitude")
|
|
128
|
+
_validate_prior_shapes(phase, phase_mean, phase_sigma, "phase")
|
|
129
|
+
|
|
130
|
+
def gaussian_log_prob(values, means, sigmas):
|
|
131
|
+
return -0.5 * jnp.sum(((values - means) / sigmas) ** 2 + jnp.log(2.0 * jnp.pi * sigmas**2))
|
|
132
|
+
|
|
133
|
+
return gaussian_log_prob(amplitude, amplitude_mean, amplitude_sigma) + gaussian_log_prob(
|
|
134
|
+
phase, phase_mean, phase_sigma
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def calibration_parameters_to_unconstrained(
|
|
139
|
+
amplitude,
|
|
140
|
+
phase,
|
|
141
|
+
amplitude_mean,
|
|
142
|
+
amplitude_sigma,
|
|
143
|
+
phase_mean,
|
|
144
|
+
phase_sigma,
|
|
145
|
+
):
|
|
146
|
+
"""Map physical Gaussian knot values to standard-normal coordinates."""
|
|
147
|
+
amplitude = _float64(amplitude)
|
|
148
|
+
phase = _float64(phase)
|
|
149
|
+
amplitude_mean = _float64(amplitude_mean)
|
|
150
|
+
amplitude_sigma = _float64(amplitude_sigma)
|
|
151
|
+
phase_mean = _float64(phase_mean)
|
|
152
|
+
phase_sigma = _float64(phase_sigma)
|
|
153
|
+
_validate_prior_shapes(amplitude, amplitude_mean, amplitude_sigma, "amplitude")
|
|
154
|
+
_validate_prior_shapes(phase, phase_mean, phase_sigma, "phase")
|
|
155
|
+
return (
|
|
156
|
+
(amplitude - amplitude_mean) / amplitude_sigma,
|
|
157
|
+
(phase - phase_mean) / phase_sigma,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def unconstrained_to_calibration_parameters(
|
|
162
|
+
unconstrained_amplitude,
|
|
163
|
+
unconstrained_phase,
|
|
164
|
+
amplitude_mean,
|
|
165
|
+
amplitude_sigma,
|
|
166
|
+
phase_mean,
|
|
167
|
+
phase_sigma,
|
|
168
|
+
):
|
|
169
|
+
"""Map standard-normal coordinates to physical Gaussian knot values."""
|
|
170
|
+
unconstrained_amplitude = _float64(unconstrained_amplitude)
|
|
171
|
+
unconstrained_phase = _float64(unconstrained_phase)
|
|
172
|
+
amplitude_mean = _float64(amplitude_mean)
|
|
173
|
+
amplitude_sigma = _float64(amplitude_sigma)
|
|
174
|
+
phase_mean = _float64(phase_mean)
|
|
175
|
+
phase_sigma = _float64(phase_sigma)
|
|
176
|
+
_validate_prior_shapes(unconstrained_amplitude, amplitude_mean, amplitude_sigma, "amplitude")
|
|
177
|
+
_validate_prior_shapes(unconstrained_phase, phase_mean, phase_sigma, "phase")
|
|
178
|
+
return (
|
|
179
|
+
amplitude_mean + amplitude_sigma * unconstrained_amplitude,
|
|
180
|
+
phase_mean + phase_sigma * unconstrained_phase,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
__all__ = [
|
|
185
|
+
"calibration_factor",
|
|
186
|
+
"calibration_log_prior",
|
|
187
|
+
"calibration_parameters_to_unconstrained",
|
|
188
|
+
"unconstrained_to_calibration_parameters",
|
|
189
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""A submodule for clustering."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ..time_frequency_transform.wavelet_transforms import WaveletTransform
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Clustering:
|
|
9
|
+
"""A base class to handle time-frequency clustering."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, time_frequency_transform: WaveletTransform):
|
|
12
|
+
"""A class to handle time-frequency clustering.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
time_frequency_transform (WaveletTransform): Wavelet transform instance.
|
|
16
|
+
"""
|
|
17
|
+
self.time_frequency_transform = time_frequency_transform
|
|
18
|
+
self._time_frequency_filter = None
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def shape(self) -> tuple:
|
|
22
|
+
"""Get the shape of the time-frequency transform.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
tuple: Shape of time-frequency transform.
|
|
26
|
+
(n_time, n_freq).
|
|
27
|
+
"""
|
|
28
|
+
return self.time_frequency_transform.shape
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def time_frequency_filter(self) -> np.ndarray:
|
|
32
|
+
"""Get the time-frequency filter.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
np.ndarray: Time-frequency filter.
|
|
36
|
+
"""
|
|
37
|
+
if self._time_frequency_filter is None:
|
|
38
|
+
raise ValueError("self._time_frequency_filter is None.")
|
|
39
|
+
return self._time_frequency_filter
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Clustering over injected strain prepared outside the likelihood."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..data import InterferometerData
|
|
9
|
+
from ..time_frequency_transform.wavelet_transforms import WaveletTransform
|
|
10
|
+
from .base import Clustering
|
|
11
|
+
from .single import single_clustering_by_threshold
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("nullcal")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InjectionClustering(Clustering):
|
|
17
|
+
"""Clustering method using waveform injections."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
time_frequency_transform: WaveletTransform,
|
|
22
|
+
injections: Iterable[InterferometerData],
|
|
23
|
+
threshold: float,
|
|
24
|
+
minimum_frequency: float | None = None,
|
|
25
|
+
maximum_frequency: float | None = None,
|
|
26
|
+
):
|
|
27
|
+
"""Clustering method using waveform injections.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
time_frequency_transform (WaveletTransform): A WaveletTransform instance.
|
|
31
|
+
injections (Iterable[InterferometerData]): Zero-noise injected
|
|
32
|
+
strain prepared by a loader or waveform package.
|
|
33
|
+
threshold (float): The threshold to select time-frequency pixels.
|
|
34
|
+
minimum_frequency (float, optional): Lowest clustering frequency.
|
|
35
|
+
maximum_frequency (float, optional): Highest clustering frequency.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(time_frequency_transform=time_frequency_transform)
|
|
38
|
+
injections = tuple(injections)
|
|
39
|
+
if not injections:
|
|
40
|
+
raise ValueError("injections must contain at least one prepared data set")
|
|
41
|
+
filters = [
|
|
42
|
+
single_clustering_by_threshold(
|
|
43
|
+
interferometers=injection,
|
|
44
|
+
time_frequency_transform=self.time_frequency_transform,
|
|
45
|
+
threshold=threshold,
|
|
46
|
+
padding_time=0.0,
|
|
47
|
+
padding_freq=0.0,
|
|
48
|
+
minimum_frequency=minimum_frequency,
|
|
49
|
+
maximum_frequency=maximum_frequency,
|
|
50
|
+
)
|
|
51
|
+
for injection in injections
|
|
52
|
+
]
|
|
53
|
+
self._time_frequency_filter = np.logical_or.reduce(filters)
|
|
54
|
+
logger.info("Injection clustering preprocessing done.")
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def time_frequency_filter(self) -> np.ndarray:
|
|
58
|
+
"""Get the time-frequency filter.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
np.ndarray: Time-frequency filter.
|
|
62
|
+
"""
|
|
63
|
+
return self._time_frequency_filter
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A class to handle pre-computed clustering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..time_frequency_transform.wavelet_transforms import WaveletTransform
|
|
8
|
+
from .base import Clustering
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PrecomputedClustering(Clustering):
|
|
12
|
+
"""Pre-computed clustering."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, time_frequency_transform: WaveletTransform, time_frequency_filter: np.ndarray):
|
|
15
|
+
"""Pre-computed clustering.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
time_frequency_transform (WaveletTransform): A WaveletTransform instance
|
|
19
|
+
for performing wavelet transforms.
|
|
20
|
+
time_frequency_filter (np.ndarray): A pre-computed time-frequency filter.
|
|
21
|
+
"""
|
|
22
|
+
super().__init__(time_frequency_transform=time_frequency_transform)
|
|
23
|
+
# Check the shape of the input time-frequency filter.
|
|
24
|
+
if time_frequency_filter.shape != self.shape:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"The shape of time_frequency_filter: {time_frequency_filter.shape}"
|
|
27
|
+
f"does not match the expected shape: {self.shape}."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
self._time_frequency_filter = time_frequency_filter
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""A submodule for single-signal clustering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..data import InterferometerData
|
|
10
|
+
from ..time_frequency_transform.wavelet_transforms import WaveletTransform
|
|
11
|
+
from .time_frequency_map import construct_time_frequency_map
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("nullcal")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_neighbors(i: int, j: int, mask: np.ndarray) -> list:
|
|
17
|
+
"""Get the neighbors of a pixel at (i,j ).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
i (int): Time index.
|
|
21
|
+
j (int): Frequency index.
|
|
22
|
+
mask (np.ndarray): A time-frequency mask.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
list: A list of time-frequency coordinates of the neighbors.
|
|
26
|
+
"""
|
|
27
|
+
neighbors = []
|
|
28
|
+
for x in range(-1, 2):
|
|
29
|
+
for y in range(-1, 2):
|
|
30
|
+
if x == 0 and y == 0:
|
|
31
|
+
continue
|
|
32
|
+
if 0 <= i + x < mask.shape[0] and 0 <= j + y < mask.shape[1]:
|
|
33
|
+
neighbors.append((i + x, j + y))
|
|
34
|
+
return neighbors
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Depth-first search
|
|
38
|
+
def _dfs(i: int, j: int, mask: np.ndarray, visited: np.ndarray) -> list:
|
|
39
|
+
"""Depth-first search.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
i (int): Time index.
|
|
43
|
+
j (int): Frequency index.
|
|
44
|
+
mask (np.ndarray): The time-frequency mask.
|
|
45
|
+
visited (np.ndarray): An array to indicate the pixels that have been visited.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
list: A list of time-frequency coordinates in the cluster.
|
|
49
|
+
"""
|
|
50
|
+
stack = [(i, j)]
|
|
51
|
+
cluster = []
|
|
52
|
+
while stack:
|
|
53
|
+
i, j = stack.pop()
|
|
54
|
+
if visited[i, j]:
|
|
55
|
+
continue
|
|
56
|
+
visited[i, j] = 1
|
|
57
|
+
cluster.append((i, j))
|
|
58
|
+
for neighbor in _get_neighbors(i, j, mask):
|
|
59
|
+
if mask[neighbor[0], neighbor[1]]:
|
|
60
|
+
stack.append(neighbor)
|
|
61
|
+
return cluster
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clustering(tf_filter: np.ndarray, dt: float, df: float, padding_time: float = 0.1, padding_freq: float = 10):
|
|
65
|
+
"""
|
|
66
|
+
Find the largest cluster in the filter.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
filter (np.ndarray): A binary mask in shape (n_time, n_freq).
|
|
70
|
+
dt (float): The time resolution in seconds.
|
|
71
|
+
df (float): The frequency resolution in Hz.
|
|
72
|
+
padding_time (float, optional): The padding in time direction in seconds. Default is 0.1.
|
|
73
|
+
padding_freq (float, optional): The padding in frequency direction in Hz. Default is 10.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
np.ndarray: A mask with the largest cluster in shape (n_time, n_freq).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# find clusters
|
|
80
|
+
visited = np.zeros(tf_filter.shape, dtype=np.uint8)
|
|
81
|
+
clusters = []
|
|
82
|
+
for i in range(tf_filter.shape[0]):
|
|
83
|
+
for j in range(tf_filter.shape[1]):
|
|
84
|
+
if tf_filter[i, j] and not visited[i, j]:
|
|
85
|
+
clusters.append(_dfs(i, j, tf_filter, visited))
|
|
86
|
+
|
|
87
|
+
# find the largest cluster
|
|
88
|
+
largest_cluster = max(clusters, key=len)
|
|
89
|
+
mask = np.zeros(tf_filter.shape, dtype=np.uint8)
|
|
90
|
+
for i, j in largest_cluster:
|
|
91
|
+
mask[i, j] = 1
|
|
92
|
+
|
|
93
|
+
# add padding
|
|
94
|
+
padding_time = int(np.ceil(padding_time / dt))
|
|
95
|
+
padding_freq = int(np.ceil(padding_freq / df))
|
|
96
|
+
for i, j in largest_cluster:
|
|
97
|
+
for x in range(-padding_time, padding_time + 1):
|
|
98
|
+
for y in range(-padding_freq, padding_freq + 1):
|
|
99
|
+
if 0 <= i + x < mask.shape[0] and 0 <= j + y < mask.shape[1]:
|
|
100
|
+
mask[i + x, j + y] = 1
|
|
101
|
+
|
|
102
|
+
return mask
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def single_clustering_by_quantile(
|
|
106
|
+
interferometers: InterferometerData,
|
|
107
|
+
time_frequency_transform: WaveletTransform,
|
|
108
|
+
quantile: float,
|
|
109
|
+
padding_time: float = 0.05,
|
|
110
|
+
padding_freq: float = 0.0,
|
|
111
|
+
minimum_frequency: float | None = None,
|
|
112
|
+
maximum_frequency: float | None = None,
|
|
113
|
+
) -> np.ndarray:
|
|
114
|
+
"""Perform clustering with the threshold set by the quantile of the power.
|
|
115
|
+
|
|
116
|
+
This function only selects the largest cluster.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
interferometers (InterferometerData): Frozen detector arrays and metadata.
|
|
120
|
+
frequency_resolution (float): The frequency resolution in Hz.
|
|
121
|
+
nx (float): The sharpness of wavelet.
|
|
122
|
+
quantile (float): The quantile to define the threshold.
|
|
123
|
+
padding_time (float, optional): The time window to pad at both ends. Defaults to 0.05.
|
|
124
|
+
padding_freq (float, optional): The frequency window to pad at both ends. Defaults to 0.0.
|
|
125
|
+
minimum_frequency (Optional[float], optional): Minimum frequency. Defaults to None.
|
|
126
|
+
maximum_frequency (Optional[float], optional): Maximum frequency. Defaults to None.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
np.ndarray: A boolean time-frequency mask.
|
|
130
|
+
"""
|
|
131
|
+
time_frequency_map = construct_time_frequency_map(
|
|
132
|
+
interferometers=interferometers, time_frequency_transform=time_frequency_transform
|
|
133
|
+
)
|
|
134
|
+
# Zero the components beyond the frequency range
|
|
135
|
+
if minimum_frequency is not None:
|
|
136
|
+
freq_low_idx = int(np.ceil(minimum_frequency / time_frequency_transform.frequency_resolution))
|
|
137
|
+
time_frequency_map[:, :freq_low_idx] = 0.0
|
|
138
|
+
if maximum_frequency is not None:
|
|
139
|
+
freq_high_idx = int(np.floor(maximum_frequency / time_frequency_transform.frequency_resolution))
|
|
140
|
+
if freq_high_idx == time_frequency_map.shape[1] - 1:
|
|
141
|
+
logger.warning("The freq_high_idx = %s contains the Nyquist frequency.", freq_high_idx)
|
|
142
|
+
freq_high_idx -= 1
|
|
143
|
+
logger.warning("freq_high_idx is set to %s.", freq_high_idx)
|
|
144
|
+
time_frequency_map[:, freq_high_idx + 1 :] = 0.0
|
|
145
|
+
threshold = np.quantile(time_frequency_map[time_frequency_map > 0.0], quantile)
|
|
146
|
+
tf_filter = time_frequency_map > threshold
|
|
147
|
+
dt = interferometers.duration / time_frequency_transform.shape[0]
|
|
148
|
+
output = clustering(
|
|
149
|
+
tf_filter,
|
|
150
|
+
dt,
|
|
151
|
+
time_frequency_transform.frequency_resolution,
|
|
152
|
+
padding_time=padding_time,
|
|
153
|
+
padding_freq=padding_freq,
|
|
154
|
+
)
|
|
155
|
+
return output.astype(bool)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def single_clustering_by_threshold(
|
|
159
|
+
interferometers: InterferometerData,
|
|
160
|
+
time_frequency_transform: WaveletTransform,
|
|
161
|
+
threshold: float,
|
|
162
|
+
padding_time: float = 0.05,
|
|
163
|
+
padding_freq: float = 0.0,
|
|
164
|
+
minimum_frequency: float | None = None,
|
|
165
|
+
maximum_frequency: float | None = None,
|
|
166
|
+
) -> np.ndarray:
|
|
167
|
+
"""Perform clustering with a given threshold.
|
|
168
|
+
|
|
169
|
+
This function only selects the largest cluster.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
interferometers (InterferometerData): Frozen detector arrays and metadata.
|
|
173
|
+
time_frequency_transform (WaveletTransform): A WaveletTransform instance.
|
|
174
|
+
threshold (float): The threshold to select time-frequency pixels.
|
|
175
|
+
padding_time (float, optional): The time window to pad at both ends. Defaults to 0.05.
|
|
176
|
+
padding_freq (float, optional): The frequency window to pad at both ends. Defaults to 0.0.
|
|
177
|
+
minimum_frequency (Optional[float], optional): Minimum frequency. Defaults to None.
|
|
178
|
+
maximum_frequency (Optional[float], optional): Maximum frequency. Defaults to None.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
np.ndarray: A boolean time-frequency mask.
|
|
182
|
+
"""
|
|
183
|
+
time_frequency_map = construct_time_frequency_map(
|
|
184
|
+
interferometers=interferometers, time_frequency_transform=time_frequency_transform
|
|
185
|
+
)
|
|
186
|
+
# Zero the components beyond the frequency range
|
|
187
|
+
if minimum_frequency is not None:
|
|
188
|
+
freq_low_idx = int(np.ceil(minimum_frequency / time_frequency_transform.frequency_resolution))
|
|
189
|
+
time_frequency_map[:, :freq_low_idx] = 0.0
|
|
190
|
+
if maximum_frequency is not None:
|
|
191
|
+
freq_high_idx = int(np.floor(maximum_frequency / time_frequency_transform.frequency_resolution))
|
|
192
|
+
if freq_high_idx == time_frequency_map.shape[1] - 1:
|
|
193
|
+
logger.warning("The freq_high_idx = %s contains the Nyquist frequency.", freq_high_idx)
|
|
194
|
+
freq_high_idx -= 1
|
|
195
|
+
logger.warning("freq_high_idx is set to %s.", freq_high_idx)
|
|
196
|
+
time_frequency_map[:, freq_high_idx:] = 0.0
|
|
197
|
+
tf_filter = time_frequency_map > threshold
|
|
198
|
+
n_f = int(interferometers.sampling_frequency / 2 / time_frequency_transform.frequency_resolution)
|
|
199
|
+
n_t = int(interferometers.duration * interferometers.sampling_frequency / n_f)
|
|
200
|
+
dt = interferometers.duration / n_t
|
|
201
|
+
output = clustering(
|
|
202
|
+
tf_filter,
|
|
203
|
+
dt,
|
|
204
|
+
time_frequency_transform.frequency_resolution,
|
|
205
|
+
padding_time=padding_time,
|
|
206
|
+
padding_freq=padding_freq,
|
|
207
|
+
)
|
|
208
|
+
return output.astype(bool)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""A submodule for constructing time-frequency maps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..data import InterferometerData
|
|
8
|
+
from ..null_stream.whiten import compute_whitened_frequency_domain_strain
|
|
9
|
+
from ..time_frequency_transform.wavelet_transforms import WaveletTransform
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def construct_time_frequency_map(interferometers: InterferometerData, time_frequency_transform: WaveletTransform):
|
|
13
|
+
"""Construct the time-frequency map from the interferometers.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
interferometers (InterferometerData): Frozen detector arrays and metadata.
|
|
17
|
+
time_frequency_transform (WaveletTransform): A WaveletTransform instance
|
|
18
|
+
for performing wavelet transforms.
|
|
19
|
+
Returns:
|
|
20
|
+
np.ndarray: The combined time-frequency map.
|
|
21
|
+
"""
|
|
22
|
+
n_det = interferometers.strain.shape[0]
|
|
23
|
+
# Compute the whitened time-frequency array
|
|
24
|
+
whitened_frequency_domain_strain = compute_whitened_frequency_domain_strain(
|
|
25
|
+
interferometers.strain,
|
|
26
|
+
interferometers.psd,
|
|
27
|
+
1 / interferometers.duration,
|
|
28
|
+
np.all(interferometers.mask, axis=0),
|
|
29
|
+
)
|
|
30
|
+
combined_power = np.zeros(time_frequency_transform.shape)
|
|
31
|
+
for i in range(n_det):
|
|
32
|
+
whitened_time_frequency_domain_strain_i = time_frequency_transform.frequency_to_wavelet(
|
|
33
|
+
frequency_domain_data=whitened_frequency_domain_strain[i]
|
|
34
|
+
)
|
|
35
|
+
whitened_time_frequency_domain_strain_quadrature_i = time_frequency_transform.frequency_to_wavelet_quadrature(
|
|
36
|
+
frequency_domain_data=whitened_frequency_domain_strain[i]
|
|
37
|
+
)
|
|
38
|
+
combined_power += (
|
|
39
|
+
np.abs(whitened_time_frequency_domain_strain_i) ** 2
|
|
40
|
+
+ np.abs(whitened_time_frequency_domain_strain_quadrature_i) ** 2
|
|
41
|
+
) / 2
|
|
42
|
+
return combined_power
|
nullcal/data.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Immutable detector data consumed by null-stream calculations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import jax
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
DETECTOR_ARRAY_NDIM = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@jax.tree_util.register_pytree_node_class
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class InterferometerData:
|
|
18
|
+
"""Dependency-free arrays and shared metadata for a detector network.
|
|
19
|
+
|
|
20
|
+
Detector names are static pytree metadata. All numerical values are leaves,
|
|
21
|
+
so JAX transformations can move or batch the data without importing the
|
|
22
|
+
package that originally loaded the strain.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
psd: Any
|
|
26
|
+
strain: Any
|
|
27
|
+
mask: Any
|
|
28
|
+
frequency_array: Any
|
|
29
|
+
duration: Any
|
|
30
|
+
sampling_frequency: Any
|
|
31
|
+
start_time: Any
|
|
32
|
+
name: tuple[str, ...]
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if any(array.ndim != DETECTOR_ARRAY_NDIM for array in (self.psd, self.strain, self.mask)):
|
|
36
|
+
raise ValueError("psd, strain, and mask must have (detector, frequency) dimensions")
|
|
37
|
+
if self.psd.shape != self.strain.shape or self.psd.shape != self.mask.shape:
|
|
38
|
+
raise ValueError("psd, strain, and mask must have identical shapes")
|
|
39
|
+
if self.frequency_array.ndim != 1 or self.frequency_array.shape[0] != self.psd.shape[1]:
|
|
40
|
+
raise ValueError("frequency_array must match the frequency dimension")
|
|
41
|
+
if len(self.name) != self.psd.shape[0]:
|
|
42
|
+
raise ValueError("name must contain one label per detector")
|
|
43
|
+
|
|
44
|
+
def __len__(self) -> int:
|
|
45
|
+
"""Return the number of detectors."""
|
|
46
|
+
return len(self.name)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_interferometers(cls, interferometers: Sequence[Any]) -> InterferometerData:
|
|
50
|
+
"""Load arrays from detector objects without retaining their package type."""
|
|
51
|
+
interferometers = tuple(interferometers)
|
|
52
|
+
if not interferometers:
|
|
53
|
+
raise ValueError("interferometers must contain at least one detector")
|
|
54
|
+
|
|
55
|
+
def shared_scalar(attribute: str, label: str) -> float:
|
|
56
|
+
values = np.asarray([getattr(interferometer, attribute) for interferometer in interferometers])
|
|
57
|
+
if not np.allclose(values, values[0]):
|
|
58
|
+
raise ValueError(f"The interferometers do not have the same {label}: {values.tolist()}.")
|
|
59
|
+
return float(values[0])
|
|
60
|
+
|
|
61
|
+
frequency_arrays = np.asarray([interferometer.frequency_array for interferometer in interferometers])
|
|
62
|
+
if not np.allclose(frequency_arrays, frequency_arrays[0], rtol=0.0, atol=0.0):
|
|
63
|
+
raise ValueError("The interferometers do not have the same frequency array.")
|
|
64
|
+
|
|
65
|
+
return cls(
|
|
66
|
+
psd=np.asarray([interferometer.power_spectral_density_array for interferometer in interferometers]),
|
|
67
|
+
strain=np.asarray([interferometer.frequency_domain_strain for interferometer in interferometers]),
|
|
68
|
+
mask=np.asarray([interferometer.frequency_mask for interferometer in interferometers], dtype=bool),
|
|
69
|
+
frequency_array=np.asarray(frequency_arrays[0]),
|
|
70
|
+
duration=shared_scalar("duration", "duration"),
|
|
71
|
+
sampling_frequency=shared_scalar("sampling_frequency", "sampling frequency"),
|
|
72
|
+
start_time=shared_scalar("start_time", "start time"),
|
|
73
|
+
name=tuple(str(interferometer.name) for interferometer in interferometers),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def tree_flatten(self):
|
|
77
|
+
children = (
|
|
78
|
+
self.psd,
|
|
79
|
+
self.strain,
|
|
80
|
+
self.mask,
|
|
81
|
+
self.frequency_array,
|
|
82
|
+
self.duration,
|
|
83
|
+
self.sampling_frequency,
|
|
84
|
+
self.start_time,
|
|
85
|
+
)
|
|
86
|
+
return children, self.name
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def tree_unflatten(cls, name, children):
|
|
90
|
+
return cls(*children, name=name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = ["InterferometerData"]
|