#!/usr/bin/env python # Copyright (C) 2015 Christopher M. Biwer # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the # Free Software Foundation; either version 3 of the License, or (at your # option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import argparse from glue.ligolw import ilwd from glue.ligolw import ligolw from glue.ligolw import lsctables from glue.ligolw import utils import logging import numpy import pycbc import pycbc.version from pycbc import DYN_RANGE_FAC from pycbc import fft from pycbc import pnutils from pycbc import psd as _psd from pycbc import strain as _strain from pycbc.detector import Detector from pycbc.inject import InjectionSet, legacy_approximant_name from pycbc.filter import make_frequency_series from pycbc.filter import sigmasq from pycbc.types import Array, FrequencySeries, TimeSeries, zeros from pycbc.types.optparse import MultiDetOptionAppendAction, convert_to_process_params_dict from pycbc.waveform import FilterBank, get_td_waveform, td_approximants, taper_timeseries import os import sys def _empty_row(obj): """Create an empty sim_inspiral or sngl_inspiral row where the columns have default values of 0.0 for a float, 0 for an int, '' for a string. The ilwd columns have a default where the index is 0. """ # check if sim_inspiral or sngl_inspiral if obj == lsctables.SimInspiral: row = lsctables.SimInspiral() cols = lsctables.SimInspiralTable.validcolumns else: row = lsctables.SnglInspiral() cols = lsctables.SnglInspiralTable.validcolumns # populate columns with default values for entry in cols.keys(): if cols[entry] in ['real_4','real_8']: setattr(row,entry,0.) elif cols[entry] == 'int_4s': setattr(row,entry,0) elif cols[entry] == 'lstring': setattr(row,entry,'') elif entry == 'process_id': row.process_id = ilwd.ilwdchar("sim_inspiral:process_id:0") elif entry == 'simulation_id': row.simulation_id = ilwd.ilwdchar("sim_inspiral:simulation_id:0") elif entry == 'event_id': row.event_id = ilwd.ilwdchar("sngl_inspiral:event_id:0") else: raise ValueError("Column %s not recognized." %(entry) ) return row def pad_timeseries_to_integer_length(timeseries, sample_rate): ''' This function zero pads a time series so that its length is an integer multiple of the sampling rate. Padding is adding symmetically to the start and end of the time series. If the number of samples to pad is odd then the end zero padding will have one more sample than the start zero padding. ''' # calculate how many sample points needed to pad to get # integer second time series remainder = sample_rate - len(timeseries) % sample_rate start_pad = int(remainder / 2) end_pad = int(remainder - start_pad) # make arrays of zeroes start_array = numpy.zeros(start_pad) end_array = numpy.zeros(end_pad) # pad waveform with arrays of zeroes initial_array = numpy.concatenate([start_array,timeseries,end_array]) return TimeSeries(initial_array, delta_t=timeseries.delta_t, epoch=timeseries.start_time, dtype=timeseries.dtype) # map order integer to a string that can be parsed by lalsimulation pn_orders = { 'default' : -1, 'zeroPN' : 0, 'onePN' : 2, 'onePointFivePN' : 3, 'twoPN' : 4, 'twoPointFivePN' : 5, 'threePN' : 6, 'threePointFivePN' : 7, 'pseudoFourPN' : 8, } # command line usage parser = argparse.ArgumentParser( usage=__name__ + ' [--options]', description="Generates a hardware injection waveform using " "a time-domain waveform.") # IFO network options parser.add_argument('--network-snr', type=float, required=True, help='The network SNR of the injection.') # sky location options parser.add_argument('--ra', type=float, required=True, help='The right ascension of the injection in radians.') parser.add_argument('--dec', type=float, required=True, help='The declination of the injection in radians.') parser.add_argument('--polarization', type=float, required=True, help='The polarization of the injection in radians.') # waveform parameter options parser.add_argument('--approximant', type=str, required=True, choices=td_approximants(), help='Approximant to use for generating waveform.') parser.add_argument("--order", type=str, default='default', choices = pn_orders.keys(), help='The integer half-PN order at which to generate ' 'the approximant.') parser.add_argument('--mass1', type=float, required=True, help='First mass of the binary in solar masses.') parser.add_argument('--mass2', type=float, required=True, help='Second mass of the binary in solar masses.') parser.add_argument('--inclination', type=float, required=True, help='Inclination of the binary in radians.') parser.add_argument('--coa-phase', type=float, default=0.0, help='Reference orbital phase parameter in radians. ' 'Note, this is not the same as the constant ' 'frequency-domain phase shift that is maximized ' 'over in the standard matched-filter search and ' 'which is also commonly called coa-phase.' 'Called phiRef in LALSimulation. ' 'Called coa_phase in sim_inspiral tables.') parser.add_argument('--taper', required=True, choices=['TAPER_NONE', 'TAPER_START', 'TAPER_END', 'TAPER_STARTEND'], help='Taper the wavform before FFT.') parser.add_argument('--waveform-low-frequency-cutoff', type=float, required=True, help='Frequency to begin generating the waveform in Hz.') # waveform spin parameter options parser.add_argument('--spin1z', type=float, default=0.0, help='(optional) Spin in z direction for mass1.') parser.add_argument('--spin1y', type=float, default=0.0, help='(optional) Spin in y direction for mass1.') parser.add_argument('--spin1x', type=float, default=0.0, help='(optional) Spin in x direction for mass1.') parser.add_argument('--spin2z', type=float, default=0.0, help='(optional) Spin in z direction for mass2.') parser.add_argument('--spin2y', type=float, default=0.0, help='(optional) Spin in y direction for mass2.') parser.add_argument('--spin2x', type=float, default=0.0, help='(optional) Spin in x direction for mass2.') # tidal options parser.add_argument('--lambda1', type=float, default=None, help='(optional) Tidal lambda term for mass1.' 'WARNING: Giving this option will produce an XML ' 'file containing a lambda1 column. This file will ' 'not be readable by default methods.') parser.add_argument('--lambda2', type=float, default=None, help='(optional) Tidal lambda term for mass2. ' 'WARNING: Giving this option will produce an XML ' 'file containing a lambda2 column. This file will ' 'not be readable by default methods.') parser.add_argument('--dquad-mon1', type=float, default=None, help='(optional) Tidal self-spin term for mass1, ' 'for BHs this is 0 (its the deformation relative to ' 'Kerr. ' 'WARNING: Giving this option will produce an XML ' 'file containing a dquad_mon1 column. This file will ' 'not be readable by default methods.') parser.add_argument('--dquad-mon2', type=float, default=None, help='(optional) Tidal self-spin term for mass2, ' 'for BHs this is 0 (its the deformation relative to ' 'Kerr. ' 'WARNING: Giving this option will produce an XML ' 'file containing a dquad_mon2 column. This file will ' 'not be readable by default methods.') # Use this for NR injections parser.add_argument('--numrel-data', type=str, default='', help="Location of NR data file if using NR injections.") # end time options parser.add_argument('--geocentric-end-time', type=float, required=True, help='The geocentric GPS end time of the injection.') # data conditioning options parser.add_argument('--low-frequency-cutoff', type=float, required=True, help='Frequency to begin generating the PSD in Hz. This ' 'is the start frequency of the SNR calculation.') parser.add_argument('--high-frequency-cutoff', type=float, help='(optional) Upper frequency to terminate the SNR ' 'calculation. Default will be Nyquist frequency, ' 'ie. int(sample_rate/2).') # output options parser.add_argument("--tag", type=str, default='hwinjcbc', help="Prefix added to output filenames.") parser.add_argument("--instruments", nargs="+", type=str, required=True, help="List of instruments to analyze.") # add option groups fft.insert_fft_option_group(parser) _strain.insert_strain_option_group_multi_ifo(parser) _psd.insert_psd_option_group_multi_ifo(parser) # parse command line opts = parser.parse_args() # verify options are sane if using strain options if opts.psd_estimation: _strain.verify_strain_options_multi_ifo(opts, parser, opts.instruments) if not opts.psd_estimation and (opts.frame_files or opts.frame_type or opts.frame_cache or opts.fake_strain): raise KeyError("Must use --psd-estimation with frame options" "(--frame-files, --frame-type, --frame-cache, " "and --fake-strain).") # setup log logging_level = logging.DEBUG logging.basicConfig(format='%(asctime)s : %(message)s', level=logging_level) # check that sample rates are the same for ifo in opts.instruments: if opts.sample_rate[ifo] != opts.sample_rate[opts.instruments[0]]: logging.warn('Sample rates must be equal for all IFOs.') sys.exit() sample_rate = opts.sample_rate[opts.instruments[0]] # set upper frequency cutoff if not given if opts.high_frequency_cutoff: f_high = opts.high_frequency_cutoff else: f_high = int(sample_rate / 2) # check that frame types are not lists if opts.frame_type: for key,value in opts.frame_type.items(): if type(opts.frame_type[key]) == list: opts.frame_type[key] = value[0] # set an initial distance to generate waveform distance = 40.0 # set network SNR to 0.0 network_snr = 0.0 # create output XML file logging.info('Creating XML file') outdoc = ligolw.Document() outdoc.appendChild(ligolw.LIGO_LW()) # put opts into a dict for the process table opts_dict = convert_to_process_params_dict(opts) # create process table proc_id = utils.process.register_to_xmldoc( outdoc, sys.argv[0], opts_dict, comment="", ifos=[''.join(opts.instruments)], version=pycbc.version.git_hash, cvs_repository='pycbc/'+pycbc.version.git_branch, cvs_entry_time=pycbc.version.date).process_id # create sim_inspiral row for injection # and populate non-IFO-specific columns in XML output file # If using tidal terms some hacking is currently needed. Hopefully this can # be resolved in the future by using HDF injection files! if opts.lambda1 is not None: lsctables.SimInspiralTable.validcolumns['lambda1'] = 'real_4' if opts.lambda2 is not None: lsctables.SimInspiralTable.validcolumns['lambda2'] = 'real_4' if opts.dquad_mon1 is not None: lsctables.SimInspiralTable.validcolumns['dquad_mon1'] = 'real_4' if opts.dquad_mon2 is not None: lsctables.SimInspiralTable.validcolumns['dquad_mon2'] = 'real_4' # You'd think that would it to redefine the table columns, but wait, it gets # worse! class SimInspiralNew(lsctables.SimInspiral): __slots__ = lsctables.SimInspiralTable.validcolumns.keys() lsctables.SimInspiral = SimInspiralNew lsctables.SimInspiralTable.RowType = SimInspiralNew # create sim_inspiral table sim_table = lsctables.New(lsctables.SimInspiralTable, columns=lsctables.SimInspiralTable.validcolumns) outdoc.childNodes[0].appendChild(sim_table) sim = _empty_row(lsctables.SimInspiral) sim.f_lower = opts.waveform_low_frequency_cutoff sim.geocent_end_time = int(opts.geocentric_end_time) sim.geocent_end_time_ns = int(opts.geocentric_end_time % 1 * 1e9) sim.inclination = opts.inclination sim.coa_phase = opts.coa_phase sim.latitude = opts.dec sim.longitude = opts.ra sim.mass1 = opts.mass1 sim.mass2 = opts.mass2 sim.mchirp, sim.eta = pnutils.mass1_mass2_to_mchirp_eta(sim.mass1, sim.mass2) sim.spin1z = opts.spin1z sim.spin1y = opts.spin1y sim.spin1x = opts.spin1x sim.spin2z = opts.spin2z sim.spin2y = opts.spin2y sim.spin2x = opts.spin2x if opts.lambda1 is not None: sim.lambda1 = opts.lambda1 if opts.lambda2 is not None: sim.lambda2 = opts.lambda2 if opts.dquad_mon1 is not None: sim.dquad_mon1 = opts.dquad_mon1 if opts.dquad_mon2 is not None: sim.dquad_mon2 = opts.dquad_mon2 sim.polarization = opts.polarization sim.taper = opts.taper sim.distance = distance sim.numrel_data = opts.numrel_data # construct waveform string that can be parsed by lalsimulation waveform_string = opts.approximant if not pn_orders[opts.order] == -1: waveform_string += opts.order sim.waveform = waveform_string name, phase_order = legacy_approximant_name(sim.waveform) # create sngl_inspiral table sngl_table = lsctables.New(lsctables.SnglInspiralTable, columns=lsctables.SnglInspiralTable.validcolumns) outdoc.childNodes[0].appendChild(sngl_table) # create sngl_inspiral row for injection # and populate non-IFO-specific columns in XML output file sngl = _empty_row(lsctables.SnglInspiral) sngl.mass1 = opts.mass1 sngl.mass2 = opts.mass2 sngl.mchirp, sngl.eta = pnutils.mass1_mass2_to_mchirp_eta(sngl.mass1, sngl.mass2) sngl.mtotal = sngl.mass1 + sngl.mass2 sngl.spin1z = opts.spin1z sngl.spin1y = opts.spin1y sngl.spin1x = opts.spin1x sngl.spin2z = opts.spin2z sngl.spin2y = opts.spin2y sngl.spin2x = opts.spin2x # generate waveform logging.info('Generating waveform at %.3fMpc beginning at %.3fHz for ' 'SNR calculation', sim.distance, opts.waveform_low_frequency_cutoff) h_plus, h_cross = get_td_waveform(sim, approximant=name, phase_order=phase_order, f_lower=opts.waveform_low_frequency_cutoff, delta_t=1.0 / sample_rate) # zero pad polarizations to get integer second time series h_plus = pad_timeseries_to_integer_length(h_plus, sample_rate) h_cross = pad_timeseries_to_integer_length(h_cross, sample_rate) # get strain time series # strain is used to estimate a PSD so if user supplies PSD # then genreate a zeroNoise TimeSeries to get length, delta_f, etc. if not opts.psd_estimation: opts.fake_strain = "zeroNoise" strain_dict = _strain.from_cli_multi_ifos(opts, opts.instruments, dyn_range_fac=DYN_RANGE_FAC) # organize options for multi-IFO PSD # if not generating strain then set those related options to None stilde_dict = {} length_dict = {} delta_f_dict = {} low_frequency_cutoff_dict = {} for ifo in opts.instruments: stilde_dict[ifo] = strain_dict[ifo].to_frequencyseries() length_dict[ifo] = len(stilde_dict[ifo]) delta_f_dict[ifo] = stilde_dict[ifo].delta_f low_frequency_cutoff_dict[ifo] = opts.low_frequency_cutoff # get PSD logging.info('Generating PSDs') psd_dict = _psd.from_cli_multi_ifos( opts, length_dict, delta_f_dict, low_frequency_cutoff_dict, opts.instruments, strain_dict=strain_dict, dyn_range_factor=DYN_RANGE_FAC, precision="double") # loop over IFOs to calculate sigma for ifo in opts.instruments: # get Detector instance for IFO det = Detector(ifo) # get time delay to detector from center of the Earth time_delay = det.time_delay_from_earth_center(sim.longitude, sim.latitude, sim.geocent_end_time) end_time = sim.geocent_end_time + time_delay # get antenna pattern f_plus, f_cross = det.antenna_pattern(sim.longitude, sim.latitude, sim.polarization, sim.geocent_end_time) # calculate strain logging.info('Calculating strain for %s', ifo) strain = f_plus * h_plus + f_cross * h_cross # taper waveform logging.info('Tapering strain for %s', ifo) strain = taper_timeseries(strain, tapermethod=sim.taper) # FFT strain logging.info('FFT strain for %s', ifo) strain_tilde = make_frequency_series(strain) # interpolate PSD to waveform delta_f if psd_dict[ifo].delta_f != strain_tilde.delta_f: logging.info('Interpolating PSD for %s from %fHz to %fHz', ifo, psd_dict[ifo].delta_f, strain_tilde.delta_f) psd_dict[ifo] = _psd.interpolate( psd_dict[ifo], strain_tilde.delta_f) # calculate sigma-squared SNR logging.info('Calculating sigma for %s', ifo) sigma_squared = sigmasq( DYN_RANGE_FAC * strain_tilde, psd=psd_dict[ifo], low_frequency_cutoff=opts.low_frequency_cutoff, high_frequency_cutoff=f_high) logging.info('Sigma integrated from %.3f to %.3fHz for %s is %.3f', opts.low_frequency_cutoff, f_high, ifo, numpy.sqrt(sigma_squared)) # populate IFO end time columns setattr(sim, ifo[0].lower()+'_end_time', int(end_time)) setattr(sim, ifo[0].lower()+'_end_time_ns', int(end_time % 1 * 1e9)) # populate IFO distance columns eff_distance = det.effective_distance(sim.distance, sim.longitude, sim.latitude, sim.polarization, sim.geocent_end_time, sim.inclination) setattr(sim, 'eff_dist_'+ifo[0].lower(), eff_distance) # populate IFO end time columns sngl.end_time = int(end_time) sngl.end_time_ns = int(end_time % 1 * 1e9) # include sigma in network SNR calculation network_snr += sigma_squared # distance scaling factor to get target snr network_snr = numpy.sqrt(network_snr) scale = network_snr / opts.network_snr sim.distance = scale*sim.distance for ifo in opts.instruments: attrname='eff_dist_'+ifo[0].lower() effdist=getattr(sim,attrname) setattr(sim,attrname,effdist*scale) # generate waveform logging.info('Generating waveform at %.3fMpc beginning at %.3fHz for ' 'SNR calculation', sim.distance, opts.waveform_low_frequency_cutoff) h_plus, h_cross = get_td_waveform(sim, approximant=name, phase_order=phase_order, f_lower=opts.waveform_low_frequency_cutoff, delta_t=1.0 / sample_rate) # zero pad polarizations to get integer second time series h_plus = pad_timeseries_to_integer_length(h_plus, sample_rate) h_cross = pad_timeseries_to_integer_length(h_cross, sample_rate) # figure out length of time series to inject waveform into logging.info('Calculating number of sample points in output file') h_plus, _ = get_td_waveform(sim, approximant=name, phase_order=phase_order, delta_t=1.0/sample_rate) pad_seconds = 5 template_duration_seconds = int( len(h_plus) / sample_rate ) + 1 start_time = int(sim.geocent_end_time) - template_duration_seconds - pad_seconds end_time = int(sim.geocent_end_time) + 1 + pad_seconds num_samples = (end_time - start_time) * sample_rate # append rows to XML tables sim_table.append(sim) sngl_table.append(sngl) # construct filenames prefix prefix = opts.tag + '_' + str(start_time) # save XML output file if it does not exist logging.info('Writing XML file') xml_filename = prefix + '.xml.gz' if os.path.exists(xml_filename): logging.warn('Filename %s already exists and will not be overwritten', xml_filename) sys.exit() else: utils.write_filename(outdoc, xml_filename, gz=xml_filename.endswith('gz')) # loop over IFOs for writing waveforms to file for ifo in opts.instruments: # create a time series of zeroes to inject waveform into initial_array = numpy.zeros(num_samples, dtype=strain.dtype) output = TimeSeries(initial_array, delta_t=1.0 / sample_rate, epoch=start_time, dtype=strain.dtype) # inject waveform logging.info('Injecting %s waveform into timeseries of zeroes', ifo) injections = InjectionSet(xml_filename) injections.apply(output, ifo) # set output filename txt_filename = prefix + '_' + ifo + '.txt' # check if filename exists if os.path.exists(txt_filename): logging.warn('Filename %s already exists and will not be overwritten', txt_filename) sys.exit() # save waveform as single-column ASCII for awgstream to use logging.info('Writing strain for %s', ifo) numpy.savetxt(txt_filename, output) # finish logging.info('Done')