gstlal 1.13.0
Loading...
Searching...
No Matches
psd.py
1# Copyright (C) 2010--2013 Kipp Cannon, Chad Hanna, Leo Singer
2#
3# This program is free software; you can redistribute it and/or modify it
4# under the terms of the GNU General Public License as published by the
5# Free Software Foundation; either version 2 of the License, or (at your
6# option) any later version.
7#
8# This program is distributed in the hope that it will be useful, but
9# WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
11# Public License for more details.
12#
13# You should have received a copy of the GNU General Public License along
14# with this program; if not, write to the Free Software Foundation, Inc.,
15# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16
17
18#
19# =============================================================================
20#
21# Preamble
22#
23# =============================================================================
24#
25
26
27import math
28import sys
29import signal
30from typing import Dict, Iterable, Optional, Tuple, Union
31
32import numpy
33import pandas
34from scipy import interpolate
35
36import gi
37gi.require_version('Gst', '1.0')
38from gi.repository import GObject
39from gi.repository import Gst
40GObject.threads_init()
41Gst.init(None)
42
43
44from ligo.lw import utils as ligolw_utils
45import lal
46import lal.series
47from lal import LIGOTimeGPS
48import lalsimulation
49
50
51from gstlal import datasource
52from gstlal import pipeparts
53from gstlal import pipeio
54from gstlal import simplehandler
55
56
57__doc__ = """
58**Review Status**
59
60+-------------------------------------------------+------------------------------------------+------------+
61| Names | Hash | Date |
62+=================================================+==========================================+============+
63| Florent, Sathya, Duncan Me., Jolien, Kipp, Chad | b3ef077fe87b597578000f140e4aa780f3a227aa | 2014-05-01 |
64+-------------------------------------------------+------------------------------------------+------------+
65
66"""
67
68
69#
70# =============================================================================
71#
72# PSD Measurement
73#
74# =============================================================================
75#
76
77
78#
79# pipeline handler for PSD measurement
80#
81
82
84 def __init__(self, *args, **kwargs):
85 self.psd = None
86 simplehandler.Handler.__init__(self, *args, **kwargs)
87
88 def do_on_message(self, bus, message):
89 if message.type == Gst.MessageType.ELEMENT and message.get_structure().get_name() == "spectrum":
90 self.psd = pipeio.parse_spectrum_message(message)
91 return True
92 return False
93
94
96 def __init__(self):
97 self.psd = None
98
99 def on_spectrum_message(self, message):
100 self.psd = pipeio.parse_spectrum_message(message)
101 return True
102
103
104#
105# measure_psd()
106#
107
108
109def measure_psd(gw_data_source_info, instrument, rate, psd_fft_length = 8, verbose = False):
110 """
111**Gstreamer graph**
112
113.. graphviz::
114
115 digraph G {
116 // graph properties
117
118 rankdir=LR;
119 compound=true;
120 node [shape=record fontsize=10 fontname="Verdana"];
121 edge [fontsize=8 fontname="Verdana"];
122
123 // nodes
124
125 "mkbasicsrc()" ;
126 capsfilter1 ;
127 resample ;
128 capsfilter2 ;
129 queue ;
130 whiten ;
131 fakesink ;
132
133 "mkbasicsrc()" -> capsfilter1 -> resample -> capsfilter2 -> queue -> whiten -> fakesink;
134 }
135
136 Stream Implementation: (future refactor)
137 stream = Stream.from_datasource(gw_data_source_info, instrument, verbose=verbose)
138 stream.add_callback(MessageType.ELEMENT, "spectrum", tracker.on_spectrum_message)
139
140 stream = stream.capsfilter(f"audio/x-raw, rate=[{rate:d},MAX]") # disallow upsampling
141 stream.resample(quality=9) \
142 .capsfilter(f"audio/x-raw, rate={rate:d}") \
143 .queue(max_size_buffers=8) \
144 .whiten(
145 psd_mode=0,
146 zero_pad=0,
147 fft_length=psd_fft_length,
148 average_samples=average_samples,
149 median_samples=7
150 ) \
151 .fakesink()
152 """
153
154
155 #
156 # 8 FFT-lengths is just a ball-parky estimate of how much data is
157 # needed for a good PSD, this isn't a requirement of the code (the
158 # code requires a minimum of 1)
159 #
160
161 if gw_data_source_info.seg is not None and float(abs(gw_data_source_info.seg)) < 8 * psd_fft_length:
162 raise ValueError("segment %s too short" % str(gw_data_source_info.seg))
163
164 #
165 # build pipeline
166 #
167
168 if verbose:
169 print("measuring PSD in segment %s" % str(gw_data_source_info.seg), file=sys.stderr)
170 print("building pipeline ...", file=sys.stderr)
171 mainloop = GObject.MainLoop()
172 pipeline = Gst.Pipeline(name="psd")
173 handler = PSDHandler(mainloop, pipeline)
174
175 head, _, _, _ = datasource.mkbasicsrc(pipeline, gw_data_source_info, instrument, verbose = verbose)
176 head = pipeparts.mkcapsfilter(pipeline, head, "audio/x-raw, rate=[%d,MAX]" % rate) # disallow upsampling
177 head = pipeparts.mkresample(pipeline, head, quality = 9)
178 head = pipeparts.mkcapsfilter(pipeline, head, "audio/x-raw, rate=%d" % rate)
179 head = pipeparts.mkqueue(pipeline, head, max_size_buffers = 8)
180 if gw_data_source_info.seg is not None:
181 average_samples = int(round(float(abs(gw_data_source_info.seg)) / (psd_fft_length / 2.) - 1.))
182 else:
183 #FIXME maybe let the user specify this
184 average_samples = 64
185 head = pipeparts.mkwhiten(pipeline, head, psd_mode = 0, zero_pad = 0, fft_length = psd_fft_length, average_samples = average_samples, median_samples = 7)
186 pipeparts.mkfakesink(pipeline, head)
187
188 #
189 # setup signal handler to shutdown pipeline for live data
190 #
191
192 if gw_data_source_info.data_source in ("lvshm", "framexmit"):# FIXME what about nds online?
194
195 #
196 # process segment
197 #
198
199 if verbose:
200 print("putting pipeline into READY state ...", file=sys.stderr)
201 if pipeline.set_state(Gst.State.READY) == Gst.StateChangeReturn.FAILURE:
202 raise RuntimeError("pipeline failed to enter READY state")
203 if gw_data_source_info.data_source not in ("lvshm", "framexmit"):# FIXME what about nds online?
204 datasource.pipeline_seek_for_gps(pipeline, *gw_data_source_info.seg)
205 if verbose:
206 print("putting pipeline into PLAYING state ...", file=sys.stderr)
207 if pipeline.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
208 raise RuntimeError("pipeline failed to enter PLAYING state")
209 if verbose:
210 print("running pipeline ...", file=sys.stderr)
211 mainloop.run()
212
213 #
214 # done
215 #
216
217 if verbose:
218 print("PSD measurement complete", file=sys.stderr)
219 return handler.psd
220
221
222def read_psd(filename: str, verbose: Optional[bool] = False) -> Dict[str, lal.REAL8FrequencySeries]:
223 """Reads in an XML-formatted PSD.
224
225 Args:
226 filename:
227 str, the file to read PSD(s) from
228 verbose:
229 bool, default False, whether to display logging messages
230
231 Returns:
232 a dictionary of lal FrequencySeries keyed by instrument
233
234 """
235 return lal.series.read_psd_xmldoc(
236 ligolw_utils.load_filename(
237 filename,
238 verbose=verbose,
239 contenthandler=lal.series.PSDContentHandler
240 )
241 )
242
243
244def write_psd(
245 filename: str,
246 psddict: Dict[str, lal.REAL8FrequencySeries],
247 trap_signals: Optional[Iterable[signal.Signals]] = None,
248 verbose: Optional[bool] = False,
249) -> None:
250 """Writes an XML-formatted PSD to disk.
251
252 Wrapper around make_psd_xmldoc() to write the XML document directly
253 to a named file.
254
255 Args:
256 filename:
257 str, the file to write PSD(s) to
258 psds:
259 Dict[str, lal.REAL8FrequencySeries], the PSD(s)
260 trap_signals:
261 Iterable[signal.Signal], optional, whether to attach extra signal
262 handlers on write
263 verbose:
264 bool, default False, whether to display logging messages
265
266 """
267 ligolw_utils.write_filename(lal.series.make_psd_xmldoc(psddict), filename, verbose = verbose, trap_signals = trap_signals)
268
269
270def read_asd_txt(
271 filename: str,
272 df: float = 0.25,
273 zero_pad: bool = False,
274 read_as_psd: bool = False,
275) -> lal.REAL8FrequencySeries:
276 """Reads in a text-formatted ASD as a PSD.
277
278 Args:
279 filename:
280 str, the file to read ASD(s) from
281 df (Hz):
282 float, default 0.25, the frequency resolution to interpolate to
283 zero_pad:
284 bool, default False, whether to zero-pad PSD to 0 Hz if needed.
285 read_as_psd:
286 bool, default False, whether to treat input as PSD rather than ASD
287
288 Returns:
289 lal.REAL8FrequencySeries, the PSD
290
291 """
292 data = numpy.loadtxt(filename, comments="#")
293 psd_data = data[:, 1]
294 if not read_as_psd:
295 psd_data = numpy.power(psd_data, 2)
296
297 f = data[:, 0]
298 if zero_pad:
299 f_pad = numpy.arange(0, f[0], df)
300 psd_data = numpy.concatenate((numpy.ones(len(f_pad)) * psd_data[0], psd_data))
301 f = numpy.concatenate((f_pad, f))
302
303 uniformf = numpy.arange(f[0], f.max(), df)
304 psdinterp = interpolate.interp1d(f, psd_data)
305 psd_data = psdinterp(uniformf)
306
307 psd = lal.CreateREAL8FrequencySeries(
308 name = "PSD",
309 epoch = 0,
310 f0 = f[0],
311 deltaF = df,
312 sampleUnits = lal.Unit("s strain^2"),
313 length = len(psd_data),
314 )
315 psd.data.data = psd_data
316
317 return psd
318
319
320def write_asd_txt(
321 filename: str,
322 psd: lal.REAL8FrequencySeries,
323 verbose: Optional[bool] = False,
324) -> None:
325 """Writes an text-formatted ASD to disk.
326
327 Args:
328 filename:
329 str, the file to write ASD to
330 psd:
331 lal.REAL8FrequencySeries, the PSD
332 verbose:
333 bool, default False, whether to display logging messages
334
335 """
336 if verbose:
337 print("writing '%s' ..." % filename, file=sys.stderr)
338 with open(filename, "w") as f:
339 for i, x in enumerate(psd.data.data):
340 print("%.16g %.16g" % (psd.f0 + i * psd.deltaF, x**.5), file=f)
341
342
343def interpolate_psd(psd: lal.REAL8FrequencySeries, deltaF: int) -> lal.REAL8FrequencySeries:
344 """Interpolates a PSD to a target frequency resolution.
345
346 Args:
347 psd:
348 lal.REAL8FrequencySeries, the PSD to interpolate
349 deltaF:
350 int, the target frequency resolution to interpolate to
351
352 Returns:
353 lal.REAL8FrequencySeries, the interpolated PSD
354
355 """
356 #
357 # no-op?
358 #
359
360 if deltaF == psd.deltaF:
361 return psd
362
363 #
364 # interpolate PSD by clipping/zero-padding time-domain impulse
365 # response of equivalent whitening filter
366 #
367
368 #from scipy import fftpack
369 #psd_data = psd.data.data
370 #x = numpy.zeros((len(psd_data) * 2 - 2,), dtype = "double")
371 #psd_data = numpy.where(psd_data, psd_data, float("inf"))
372 #x[0] = 1 / psd_data[0]**.5
373 #x[1::2] = 1 / psd_data[1:]**.5
374 #x = fftpack.irfft(x)
375 #if deltaF < psd.deltaF:
376 # x *= numpy.cos(numpy.arange(len(x)) * math.pi / (len(x) + 1))**2
377 # x = numpy.concatenate((x[:(len(x) / 2)], numpy.zeros((int(round(len(x) * psd.deltaF / deltaF)) - len(x),), dtype = "double"), x[(len(x) / 2):]))
378 #else:
379 # x = numpy.concatenate((x[:(int(round(len(x) * psd.deltaF / deltaF)) / 2)], x[-(int(round(len(x) * psd.deltaF / deltaF)) / 2):]))
380 # x *= numpy.cos(numpy.arange(len(x)) * math.pi / (len(x) + 1))**2
381 #x = 1 / fftpack.rfft(x)**2
382 #psd_data = numpy.concatenate(([x[0]], x[1::2]))
383
384 #
385 # interpolate log(PSD) with cubic spline. note that the PSD is
386 # clipped at 1e-300 to prevent nan's in the interpolator (which
387 # doesn't seem to like the occasional sample being -inf)
388 #
389
390 psd_data = psd.data.data
391 psd_data = numpy.where(psd_data, psd_data, 1e-300)
392 f = psd.f0 + numpy.arange(len(psd_data)) * psd.deltaF
393 interp = interpolate.splrep(f, numpy.log(psd_data), s = 0)
394 f = psd.f0 + numpy.arange(round((len(psd_data) - 1) * psd.deltaF / deltaF) + 1) * deltaF
395 psd_data = numpy.exp(interpolate.splev(f, interp, der = 0))
396
397 #
398 # return result
399 #
400
401 psd = lal.CreateREAL8FrequencySeries(
402 name = psd.name,
403 epoch = psd.epoch,
404 f0 = psd.f0,
405 deltaF = deltaF,
406 sampleUnits = psd.sampleUnits,
407 length = len(psd_data)
408 )
409 psd.data.data = psd_data
410
411 return psd
412
413
414def movingmedian(
415 psd: Union[numpy.ndarray, lal.REAL8FrequencySeries],
416 window_size: int
417) -> Union[numpy.ndarray, lal.REAL8FrequencySeries]:
418 """Smoothen a PSD with a moving median.
419
420 Assumes that the underlying PSD doesn't have variance, i.e., that there
421 is no median / mean correction factor required.
422
423 Args:
424 psd:
425 Union[numpy.ndarray, lal.REAL8FrequencySeries], the PSD to smoothen
426 window_size:
427 int, the size of the window used for the moving median
428
429 Returns:
430 a smoothened PSD of same type as input PSD
431
432 """
433 if isinstance(psd, numpy.ndarray):
434 tmp = numpy.copy(psd)
435 else: # lal Series
436 tmp = numpy.copy(psd.data.data)
437
438 # compute rolling median
439 tmp[window_size:(len(tmp) - window_size)] = numpy.array(
440 pandas.Series(tmp).rolling(2 * window_size).median()[(2 * window_size - 1):-1]
441 )
442
443 if isinstance(psd, numpy.ndarray):
444 return tmp
445 else: # lal Series
446 new_psd = lal.CreateREAL8FrequencySeries(
447 name = psd.name,
448 epoch = psd.epoch,
449 f0 = psd.f0,
450 deltaF = psd.deltaF,
451 sampleUnits = psd.sampleUnits,
452 length = len(tmp)
453 )
454 psd.data.data = tmp
455 return new_psd
456
457
458def movingaverage(psd: numpy.ndarray, window_size: int) -> numpy.ndarray:
459 """Smoothen a PSD with a moving average.
460
461 Args:
462 psd:
463 numpy.ndarray, the PSD to smoothen
464 window_size:
465 int, the size of the window used for the moving median
466
467 Returns:
468 the smoothened PSD
469
470 """
471 window = lal.CreateTukeyREAL8Window(window_size, 0.5).data.data
472 return numpy.convolve(psd, window, 'same')
473
474
475def taperzero_fseries(
476 fseries: lal.REAL8FrequencySeries,
477 minfs: Optional[Tuple[float, float]] = (35.0, 40.0),
478 maxfs: Optional[Tuple[float, float]] = (1800., 2048.)
479) -> lal.REAL8FrequencySeries:
480 """Taper the PSD to infinity for given min/max frequencies.
481
482 Args:
483 psd:
484 lal.REAL8FrequencySeries, the PSD to taper
485 minfs (Hz):
486 Tuple[float, float], optional, the frequency boundaries over which to taper the
487 spectrum to infinity. i.e., frequencies below the first item in the tuple will
488 have an infinite spectrum, the second item in the tuple will not be changed.
489 A taper from 0 to infinity is applied in between.
490 maxfs (Hz):
491 Tuple[float, float], optional, the frequency boundaries over which to taper the
492 spectrum to infinity. i.e., frequencies above the second item in the tuple will
493 have an infinite spectrum, the first item in the tuple will not be changed.
494 A taper from 0 to infinity is applied in between.
495
496 Returns:
497 lal.REAL8FrequencySeries, the tapered PSD
498
499 """
500
501 #
502 # store the psd horizon before tapering
503 #
504
505 data = fseries.data.data
506 norm_before = numpy.dot(data.conj(), data).real
507
508 #
509 # taper to infinity to turn this psd into an effective band pass filter
510 #
511
512 deltaF = fseries.deltaF
513 kmin = int(minfs[0] / deltaF)
514 kmax = int(minfs[1] / deltaF)
515 data[(len(data)//2 + 1) - kmin + 1:(len(data)//2 + 1) + kmin] = 0.
516 data[(len(data)//2 + 1) + kmin:(len(data)//2 + 1) + kmax] *= numpy.sin(numpy.arange(kmax-kmin) / (kmax-kmin-1.) * numpy.pi / 2.0)**4
517 data[(len(data)//2 + 1) - kmax:(len(data)//2 + 1) - kmin] *= numpy.cos(numpy.arange(kmax-kmin) / (kmax-kmin-1.) * numpy.pi / 2.0)**4
518
519 kmin = int(maxfs[0] / deltaF) - 1
520 kmax = int(maxfs[1] / deltaF) - 1
521 data[(len(data)//2 + 1) + kmax:] = data[:-(len(data)//2 + 1) - kmax] = 0.
522 data[(len(data)//2 + 1) + kmin:(len(data)//2 + 1) + kmax] *= numpy.cos(numpy.arange(kmax-kmin) / (kmax-kmin-1.) * numpy.pi / 2.0)**4
523 data[(len(data)//2 + 1) - kmax:(len(data)//2 + 1) - kmin] *= numpy.sin(numpy.arange(kmax-kmin) / (kmax-kmin-1.) * numpy.pi / 2.0)**4
524
525 #
526 # renormalize after tapering
527 #
528
529 fseries.data.data = data * math.sqrt(norm_before / numpy.dot(data.conj(), data).real)
530
531 #
532 # done
533 #
534
535 return fseries
536
537
538def condition_psd(
539 psd: lal.REAL8FrequencySeries,
540 newdeltaF: int,
541 minfs: Optional[Tuple[int, int]] = (35.0, 40.0),
542 maxfs: Optional[Tuple[int, int]] = (1800., 2048.),
543 smoothing_frequency: Optional[float] = 4.,
544 fir_whiten: Optional[bool] = False
545) -> lal.REAL8FrequencySeries:
546 """Condition a PSD suitable for whitening waveforms.
547
548 Args:
549 psd:
550 lal.REAL8FrequencySeries, the PSD to taper
551 newdeltaF (Hz):
552 int, the target delta F to interpolate to
553 minfs (Hz):
554 Tuple[float, float], optional, the frequency boundaries over which to taper the
555 spectrum to infinity. i.e., frequencies below the first item in the tuple will
556 have an infinite spectrum, the second item in the tuple will not be changed.
557 A taper from 0 to infinity is applied in between.
558 maxfs (Hz):
559 Tuple[float, float], optional, the frequency boundaries over which to taper the
560 spectrum to infinity. i.e., frequencies above the second item in the tuple will
561 have an infinite spectrum, the first item in the tuple will not be changed.
562 A taper from 0 to infinity is applied in between.
563 smoothing_frequency (Hz):
564 float, default = 4 Hz, the target frequency resolution after smoothing. Lines with
565 bandwidths << smoothing_frequency are removed via a median calculation.
566 Remaining features will be blurred out to this resolution.
567 fir_whiten:
568 bool, default False, whether to enable causal whitening with a time-domain
569 whitening kernel vs. traditional acausal whitening
570
571 Returns:
572 lal.REAL8FrequencySeries, the conditioned PSD
573
574 """
575
576 #
577 # store the psd horizon before conditioning
578 #
579
580 horizon_distance = HorizonDistance(minfs[1], maxfs[0], psd.deltaF, 1.4, 1.4)
581 horizon_before = horizon_distance(psd, 8.0)[0]
582
583 #
584 # interpolate to new \Delta f
585 #
586
587 psd = interpolate_psd(psd, newdeltaF)
588
589 #
590 # Smooth the psd
591 #
592
593 psddata = psd.data.data
594 avgwindow = int(smoothing_frequency / newdeltaF)
595 psddata = movingmedian(psddata, avgwindow)
596 psddata = movingaverage(psddata, avgwindow)
597 psd.data.data = psddata
598
599 #
600 # Tapering psd in either side up to infinity if a frequency-domain whitener is used, returns a psd without tapering otherwise.
601 # For a time-domain whitener, the tapering is effectively done as a part of deriving a frequency series of the FIR-whitner kernel
602 #
603 if not fir_whiten:
604 #
605 # Taper to infinity to turn this psd into an effective band pass filter
606 #
607
608 psddata = psd.data.data
609 kmin = int(minfs[0] / newdeltaF)
610 kmax = int(minfs[1] / newdeltaF)
611 psddata[:kmin+1] = numpy.inf
612 psddata[kmin:kmax] /= numpy.sin(numpy.arange(kmax-kmin) / (kmax-kmin-1.) * numpy.pi / 2.0)**4
613
614 kmin = int(maxfs[0] / newdeltaF)
615 kmax = int(maxfs[1] / newdeltaF)
616 psddata[kmax:] = numpy.inf
617 psddata[kmin:kmax] /= numpy.cos(numpy.arange(kmax-kmin) / (kmax-kmin-1.) * numpy.pi / 2.0)**4
618
619 psd.data.data = psddata
620
621 #
622 # compute the psd horizon after conditioning and renormalize
623 #
624
625 horizon_after = horizon_distance(psd, 8.0)[0]
626
627 psddata = psd.data.data
628 psd.data.data = psddata * (horizon_after / horizon_before)**2
629
630 #
631 # done
632 #
633
634 return psd
635
636
637def polyfit(
638 psd: lal.REAL8FrequencySeries,
639 f_low: float,
640 f_high: float,
641 order: int,
642 verbose: Optional[bool] = False
643) -> lal.REAL8FrequencySeries:
644 """Fit a PSD to a polynomial.
645
646 Args:
647 psd:
648 lal.REAL8FrequencySeries, the PSD to fit
649 f_low (Hz):
650 float, the low frequency to begin fitting with
651 f_high (Hz):
652 float, the high frequency to stop fitting with
653 order:
654 int, the order of the fitting polynomial
655 verbose:
656 bool, default false, whether to display the fit
657
658 Returns:
659 lal.REAL8FrequencySeries, the PSD fitted to a polynomial
660
661 """
662 minsample = int(f_low // psd.deltaF)
663 maxsample = int(f_high // psd.deltaF)
664
665 # f / f_min between f_min and f_max, i.e. f[0] here is 1
666 f = numpy.arange(maxsample - minsample) * psd.deltaF + 1
667 data = psd.data.data[minsample:maxsample]
668
669 logf = numpy.linspace(numpy.log(f[0]), numpy.log(f[-1]), 100000)
670 interp = interpolate.interp1d(numpy.log(f), numpy.log(data))
671 data = interp(logf)
672 p = numpy.poly1d(numpy.polyfit(logf, data, order))
673 if verbose:
674 print("\nFit polynomial is: \n\nlog(PSD) = \n", p, "\n\nwhere x = f / f_min\n", file=sys.stderr)
675 data = numpy.exp(p(numpy.log(f)))
676 olddata = psd.data.data
677 olddata[minsample:maxsample] = data
678 psd = lal.CreateREAL8FrequencySeries(
679 name = psd.name,
680 epoch = psd.epoch,
681 f0 = psd.f0,
682 deltaF = psd.deltaF,
683 sampleUnits = psd.sampleUnits,
684 length = len(olddata)
685 )
686 psd.data.data = olddata
687 return psd
688
689
690def harmonic_mean(psddict: Dict[str, lal.REAL8FrequencySeries]) -> lal.REAL8FrequencySeries:
691 """Take the harmonic mean of a dictionary of PSDs.
692
693 """
694 refpsd = list(psddict.values())[0]
695 psd = lal.CreateREAL8FrequencySeries("psd", refpsd.epoch, 0., refpsd.deltaF, lal.Unit("strain^2 s"), refpsd.data.length)
696 psd.data.data[:] = 0.
697 for ifo in psddict:
698 psd.data.data[:] += 1. / psddict[ifo].data.data
699 psd.data.data[:] = len(psddict) / psd.data.data[:]
700 return psd
701
702
703class HorizonDistance(object):
704 def __init__(self, f_min, f_max, delta_f, m1, m2, spin1 = (0., 0., 0.), spin2 = (0., 0., 0.), eccentricity = 0., inclination = 0., approximant = "IMRPhenomD"):
705 """
706 Configures the horizon distance calculation for a specific
707 waveform model. The waveform is pre-computed and stored,
708 so this initialization step can be time-consuming but
709 computing horizon distances from measured PSDs will be
710 fast.
711
712 The waveform model's spectrum parameters, for example its
713 Nyquist and frequency resolution, need not match the
714 parameters for the PSDs that will ultimately be supplied
715 but there are some advantages to be had in getting them to
716 match. For example, computing the waveform with a smaller
717 delta_f than will be needed will require additional storage
718 and consume additional CPU time for the initialization,
719 while computing it with too low an f_max or too large a
720 delta_f might lead to inaccurate horizon distance
721 estimates.
722
723 f_min (Hertz) sets the frequency at which the waveform
724 model is to begin.
725
726 f_max (Hertz) sets the frequency upto which the waveform's
727 model is desired.
728
729 delta_f (Hertz) sets the frequency resolution of the
730 desired waveform model.
731
732 m1, m2 (solar masses) set the component masses of the
733 system to model.
734
735 spin1, spin2 (3-component vectors, geometric units) set the
736 spins of the component masses.
737
738 eccentricity [0, 1) sets the eccentricity of the system.
739
740 inclination (radians) sets the orbital inclination of the
741 system.
742
743 Example:
744
745 >>> # configure for non-spinning, circular, 1.4+1.4 BNS
746 >>> horizon_distance = HorizonDistance(10., 1024., 1./32., 1.4, 1.4)
747 >>> # populate a PSD for testing
748 >>> import lal, lalsimulation
749 >>> psd = lal.CreateREAL8FrequencySeries("psd", lal.LIGOTimeGPS(0), 0., 1./32., lal.Unit("strain^2 s"), horizon_distance.model.data.length)
750 >>> lalsimulation.SimNoisePSDaLIGODesignSensitivityP1200087(psd, 0.)
751 0
752 >>> # compute horizon distance
753 >>> D, (f, model) = horizon_distance(psd)
754 >>> print("%.4g Mpc" % D)
755 434.7 Mpc
756 >>> # compute distance and spectrum for SNR = 25
757 >>> D, (f, model) = horizon_distance(psd, 25.)
758 >>> "%.4g Mpc" % D
759 '139.1 Mpc'
760 >>> f
761 array([ 10. , 10.03125, 10.0625 , ..., 1023.9375 ,
762 1023.96875, 1024. ])
763 >>> model
764 array([ 8.05622865e-45, 7.99763234e-45, 7.93964216e-45, ...,
765 1.11824864e-49, 1.11815656e-49, 1.11806450e-49])
766
767 NOTE:
768
769 - Currently the SEOBNRv4_ROM waveform model is used, so its
770 limitations with respect to masses, spins, etc., apply.
771 The choice of waveform model is subject to change.
772 """
773 self.f_min = f_min
774 self.f_max = f_max
775 self.m1 = m1
776 self.m2 = m2
777 self.spin1 = numpy.array(spin1)
778 self.spin2 = numpy.array(spin2)
779 self.inclination = inclination
780 self.eccentricity = eccentricity
781 self.approximant = approximant
782 # NOTE: the waveform models are computed up-to but not
783 # including the supplied f_max parameter so we need to pass
784 # (f_max + delta_f) if we want the waveform model defined
785 # in the f_max bin
786 hp, hc = lalsimulation.SimInspiralFD(
787 m1 * lal.MSUN_SI, m2 * lal.MSUN_SI,
788 spin1[0], spin1[1], spin1[2],
789 spin2[0], spin2[1], spin2[2],
790 1.0, # distance (m)
791 inclination,
792 0.0, # reference orbital phase (rad)
793 0.0, # longitude of ascending nodes (rad)
794 eccentricity,
795 0.0, # mean anomaly of periastron
796 delta_f,
797 f_min,
798 f_max + delta_f,
799 100., # reference frequency (Hz)
800 None, # LAL dictionary containing accessory parameters
801 lalsimulation.GetApproximantFromString(self.approximant)
802 )
803 assert hp.data.length > 0, "huh!? h+ has zero length!"
804
805 #
806 # store |h(f)|^2 for source at D = 1 m. see (5) in
807 # arXiv:1003.2481
808 #
809
810 self.model = lal.CreateREAL8FrequencySeries(
811 name = "signal spectrum",
812 epoch = LIGOTimeGPS(0),
813 f0 = hp.f0,
814 deltaF = hp.deltaF,
815 sampleUnits = hp.sampleUnits * hp.sampleUnits,
816 length = hp.data.length
817 )
818 self.model.data.data[:] = numpy.abs(hp.data.data)**2.
819
820
821 def __call__(self, psd, snr = 8.):
822 """
823 Compute the horizon distance for the configured waveform
824 model given the PSD and the SNR at which the horizon is
825 defined (default = 8). Equivalently, from a PSD and an
826 observed SNR compute and return the amplitude of the
827 configured waveform's spectrum required to achieve that
828 SNR.
829
830 The return value is a two-element tuple. The first element
831 is the horizon distance in Mpc. The second element is,
832 itself, a two-element tuple containing two vectors giving
833 the frequencies and amplitudes of the waveform model's
834 spectrum scaled so as to have the given SNR. The vectors
835 are clipped to the range of frequencies that were used for
836 the SNR integral.
837
838 The parameters of the PSD, for example its Nyquist and
839 frequency resolution, need not match the parameters of the
840 configured waveform model. In the event of a mismatch, the
841 waveform model is resampled to the frequencies at which the
842 PSD has been measured.
843
844 The inspiral spectrum returned has the same units as the
845 PSD and is normalized so that the SNR is
846
847 SNR^2 = \int (inspiral_spectrum / psd) df
848
849 That is, the ratio of the inspiral spectrum to the PSD
850 gives the spectral density of SNR^2.
851 """
852 #
853 # frequencies at which PSD has been measured
854 #
855
856 f = psd.f0 + numpy.arange(psd.data.length) * psd.deltaF
857
858 #
859 # nearest-neighbour interpolation of waveform model
860 # evaluated at PSD's frequency bins
861 #
862
863 indexes = ((f - self.model.f0) / self.model.deltaF).round().astype("int").clip(0, self.model.data.length - 1)
864 model = self.model.data.data[indexes]
865
866 #
867 # range of indexes for integration
868 #
869
870 kmin = (max(psd.f0, self.model.f0, self.f_min) - psd.f0) / psd.deltaF
871 kmin = int(round(kmin))
872 kmax = (min(psd.f0 + psd.data.length * psd.deltaF, self.model.f0 + self.model.data.length * self.model.deltaF, self.f_max) - psd.f0) / psd.deltaF
873 kmax = int(round(kmax)) + 1
874 assert kmin < kmax, "PSD and waveform model do not intersect"
875
876 #
877 # SNR for source at D = 1 m <--> D in m for source w/ SNR =
878 # 1. see (3) in arXiv:1003.2481
879 #
880
881 f = f[kmin:kmax]
882 model = model[kmin:kmax]
883 D = math.sqrt(4. * (model / psd.data.data[kmin:kmax]).sum() * psd.deltaF)
884
885 #
886 # distance at desired SNR
887 #
888
889 D /= snr
890
891 #
892 # scale inspiral spectrum by distance to achieve desired SNR
893 #
894
895 model *= 4. / D**2.
896
897 #
898 # D in Mpc for source with specified SNR, and waveform
899 # model
900 #
901
902 return D / (1e6 * lal.PC_SI), (f, model)
903
904
905def effective_distance_factor(inclination, fp, fc):
906 """
907 Returns the ratio of effective distance to physical distance for
908 compact binary mergers. Inclination is the orbital inclination of
909 the system in radians, fp and fc are the F+ and Fx antenna factors.
910 See lal.ComputeDetAMResponse() for a function to compute antenna
911 factors. The effective distance is given by
912
913 Deff = effective_distance_factor * D
914
915 See Equation (4.3) of arXiv:0705.1514.
916 """
917 cos2i = math.cos(inclination)**2
918 return 1.0 / math.sqrt(fp**2 * (1+cos2i)**2 / 4 + fc**2 * cos2i)
__init__(self, f_min, f_max, delta_f, m1, m2, spin1=(0., 0., 0.), spin2=(0., 0., 0.), eccentricity=0., inclination=0., approximant="IMRPhenomD")
Definition psd.py:704
__call__(self, psd, snr=8.)
Definition psd.py:821
do_on_message(self, bus, message)
Definition psd.py:88