diff --git a/bilby/gw/detector/interferometer.py b/bilby/gw/detector/interferometer.py index 662866e8e7dde3d13a3ff838ecb91643ad177f62..82c6f1c4a38abcbd6a03cc8d3205b88c09c575da 100644 --- a/bilby/gw/detector/interferometer.py +++ b/bilby/gw/detector/interferometer.py @@ -616,19 +616,95 @@ class Interferometer(object): power_spectral_density=self.power_spectral_density_array[self.strain_data.frequency_mask], duration=self.strain_data.duration) + def whiten_frequency_series(self, frequency_series : np.array) -> np.array: + """Whitens a frequency series with the noise properties of the detector + + .. math:: + \\tilde{a}_w(f) = \\tilde{a}(f) \\sqrt{\\frac{4}{T S_n(f)}} + + Such that + + .. math:: + Var(n) = \\frac{1}{N} \\sum_k=0^N n_W(f_k)n_W^*(f_k) = 2 + + Where the factor of two is due to the independent real and imaginary + components. + + Parameters + ========== + frequency_series : np.array + The frequency series, whitened by the ASD + """ + return frequency_series / (self.amplitude_spectral_density_array * np.sqrt(self.duration / 4)) + + def get_whitened_time_series_from_whitened_frequency_series( + self, + whitened_frequency_series : np.array + ) -> np.array: + """Gets the whitened time series from a whitened frequency series. + + This ifft's and also applies a windowing factor, + since when f_min and f_max are set bilby applies a mask to the series. + + Per 6.2a-b in https://arxiv.org/pdf/gr-qc/0509116 since our window + is just a band pass, + this coefficient is :math:`w/W` where + + .. math:: + + W = \\frac{1}{N} \\sum_{k=0}^N w^2[j] + + Since our window :math:`w` is simply 1 or 0, depending on the mask, we get + + .. math:: + + W = \\frac{1}{N} \\sum_{k=0}^N \\Theta(f_{max} - f_k)\\Theta(f_k - f_{min})} + + and accordingly the termwise window factor is + + .. math:: + w = \\sqrt{N W} = \\sqrt{\\sum_{k=0}^N \\Theta(f_{max} - f_k)\\Theta(f_k - f_{min})}} + + """ + frequency_window_factor = ( + np.sum(self.frequency_mask) + / len(self.frequency_mask) + ) + + whitened_time_series = ( + np.fft.irfft(whitened_frequency_series) + * np.sqrt(np.sum(self.frequency_mask)) / frequency_window_factor + ) + + return whitened_time_series + @property def whitened_frequency_domain_strain(self): - """ Calculates the whitened data by dividing the frequency domain data by - ((amplitude spectral density) * (duration / 4) ** 0.5). The resulting - data will have unit variance. + r"""Whitens the frequency domain data by dividing through by ASD, + with appropriate normalization. + + See `whiten_frequency_series()` for details. Returns ======= array_like: The whitened data """ - return self.strain_data.frequency_domain_strain / ( - self.amplitude_spectral_density_array * np.sqrt(self.duration / 4) - ) + return self.whiten_frequency_series(self.strain_data.frequency_domain_strain) + + @property + def whitened_time_domain_strain(self) -> np.array: + """Calculates the whitened time domain strain + by iffting the whitened frequency domain strain, + with the appropriate normalization. + + See `get_whitened_time_series_from_whitened_frequency_series()` for details + + Returns + ======= + array_like + The whitened data in the time domain + """ + return self.get_whitened_time_series_from_whitened_frequency_series(self.whitened_frequency_domain_strain) def save_data(self, outdir, label=None): """ Creates save files for interferometer data in plain text format. diff --git a/bilby/gw/result.py b/bilby/gw/result.py index 5d1b6e7ed5a64baec1e702c101ca7fcccaa9436a..cc9f97ed7d0d4e337aae0e0a79255f8fd1261c87 100644 --- a/bilby/gw/result.py +++ b/bilby/gw/result.py @@ -377,10 +377,6 @@ class CompactBinaryCoalescenceResult(CoreResult): logger.debug("Downsampling frequency mask to {} values".format( len(frequency_idxs)) ) - frequency_window_factor = ( - np.sum(interferometer.frequency_mask) - / len(interferometer.frequency_mask) - ) plot_times = interferometer.time_array[time_idxs] plot_times -= interferometer.strain_data.start_time start_time -= interferometer.strain_data.start_time @@ -451,11 +447,7 @@ class CompactBinaryCoalescenceResult(CoreResult): fig.add_trace( go.Scatter( x=plot_times, - y=np.fft.irfft( - interferometer.whitened_frequency_domain_strain - * np.sqrt(np.sum(interferometer.frequency_mask)) - / frequency_window_factor - )[time_idxs], + y=interferometer.whitened_time_domain_strain[time_idxs], fill=None, mode='lines', line_color=DATA_COLOR, opacity=0.5, @@ -478,11 +470,7 @@ class CompactBinaryCoalescenceResult(CoreResult): interferometer.amplitude_spectral_density_array[frequency_idxs], color=DATA_COLOR, label='ASD') axs[1].plot( - plot_times, np.fft.irfft( - interferometer.whitened_frequency_domain_strain - * np.sqrt(np.sum(interferometer.frequency_mask)) - / frequency_window_factor - )[time_idxs], + plot_times, interferometer.whitened_time_domain_strain[time_idxs], color=DATA_COLOR, alpha=0.3) logger.debug('Plotted interferometer data.') @@ -493,10 +481,10 @@ class CompactBinaryCoalescenceResult(CoreResult): wf_pols = waveform_generator.frequency_domain_strain(params) fd_waveform = interferometer.get_detector_response(wf_pols, params) fd_waveforms.append(fd_waveform[frequency_idxs]) - td_waveform = infft( - fd_waveform * np.sqrt(2. / interferometer.sampling_frequency) / - interferometer.amplitude_spectral_density_array, - self.sampling_frequency)[time_idxs] + whitened_fd_waveform = interferometer.whiten_frequency_series(fd_waveform) + td_waveform = interferometer.get_whitened_time_series_from_whitened_frequency_series( + whitened_fd_waveform + )[time_idxs] td_waveforms.append(td_waveform) fd_waveforms = asd_from_freq_series( fd_waveforms, diff --git a/test/gw/detector/interferometer_test.py b/test/gw/detector/interferometer_test.py index 35804541833181f273da1bb330e85bde544cbeff..66e8476035d52d41b4791a1a2126f3927fadd605 100644 --- a/test/gw/detector/interferometer_test.py +++ b/test/gw/detector/interferometer_test.py @@ -560,21 +560,92 @@ class TestInterferometerAntennaPatternAgainstLAL(unittest.TestCase): class TestInterferometerWhitenedStrain(unittest.TestCase): def setUp(self): + self.duration = 64 + self.sampling_frequency = 4096 self.ifo = bilby.gw.detector.get_empty_interferometer('H1') self.ifo.set_strain_data_from_power_spectral_density( - sampling_frequency=4096, duration=64) + sampling_frequency=self.sampling_frequency, duration=self.duration) + self.waveform_generator = bilby.gw.waveform_generator.WaveformGenerator( + duration=self.duration, + sampling_frequency=self.sampling_frequency, + frequency_domain_source_model=bilby.gw.source.lal_binary_black_hole, + waveform_arguments={ + "waveform_approximant": "IMRPhenomXP" + }) + + self.parameters = { + 'mass_1': 10, + 'mass_2': 10, + 'a_1': 0, + 'a_2': 0, + 'tilt_1': 0, + 'tilt_2': 0, + 'phi_12': 0, + 'phi_jl': 0, + 'theta_jn': 0, + 'luminosity_distance': 40, + 'phase': 0, + 'ra': 0, + 'dec': 0, + 'geocent_time': 62, + 'psi': 0 + } def tearDown(self): del self.ifo + del self.waveform_generator + del self.parameters + del self.duration + del self.sampling_frequency - def test_whitened_strain(self): - mask = self.ifo.frequency_mask - white = self.ifo.whitened_frequency_domain_strain[mask] - std_real = np.std(white.real) - std_imag = np.std(white.imag) + def _check_frequency_series_whiteness(self, frequency_series): + std_real = np.std(frequency_series.real) + std_imag = np.std(frequency_series.imag) self.assertAlmostEqual(std_real, 1, places=2) self.assertAlmostEqual(std_imag, 1, places=2) + def _check_time_series_whiteness(self, time_series): + std = np.std(time_series) + self.assertAlmostEqual(std, 1, places=2) -if __name__ == "__main__": - unittest.main() + def test_frequency_domain_whitened_strain(self): + mask = self.ifo.frequency_mask + white = self.ifo.whitened_frequency_domain_strain[mask] + self._check_frequency_series_whiteness(white) + + def test_time_domain_whitened_strain(self): + whitened_td = self.ifo.whitened_time_domain_strain + self._check_time_series_whiteness(whitened_td) + + def test_frequency_domain_noise_and_signal_whitening(self): + # Inject some (loud) signal + self.ifo.inject_signal(waveform_generator=self.waveform_generator, parameters=self.parameters) + # Make the template separately + waveform_polarizations = self.waveform_generator.frequency_domain_strain(parameters=self.parameters) + signal_ifo = self.ifo.get_detector_response( + waveform_polarizations=waveform_polarizations, + parameters=self.parameters + ) + # Whiten the template + whitened_signal_ifo = self.ifo.whiten_frequency_series(signal_ifo) + mask = self.ifo.frequency_mask + white = self.ifo.whitened_frequency_domain_strain[mask] - whitened_signal_ifo[mask] + self._check_frequency_series_whiteness(white) + + def test_time_domain_noise_and_signal_whitening(self): + # Inject some (loud) signal + self.ifo.inject_signal(waveform_generator=self.waveform_generator, parameters=self.parameters) + # Make the template separately + waveform_polarizations = self.waveform_generator.frequency_domain_strain(parameters=self.parameters) + signal_ifo = self.ifo.get_detector_response( + waveform_polarizations=waveform_polarizations, + parameters=self.parameters + ) + # Whiten the template in FD + whitened_signal_ifo_fd = self.ifo.whiten_frequency_series(signal_ifo) + # Get whitened template in TD + whitened_signal_ifo_td = self.ifo.get_whitened_time_series_from_whitened_frequency_series( + whitened_signal_ifo_fd + ) + whitened_td = self.ifo.whitened_time_domain_strain - whitened_signal_ifo_td + self._check_time_series_whiteness(whitened_td)