def zero_pad(st, pad_length_in_seconds, before=True, after=True): """ Zero pad the data of a stream, change the starttime to reflect the change. Useful for if e.g. observed data starttime comes in later than synthetic. :type st: obspy.stream.Stream :param st: stream to be zero padded :type pad_length_in_seconds: int :param pad_length_in_seconds: length of padding front and back :type before: bool :param before: pad the stream before the origin time :type after: bool :param after: pad the stream after the last sample :rtype st: obspy.stream.Stream :return st: stream with zero padded data object """ pad_before, pad_after = 0, 0 st_pad = st.copy() for tr in st_pad: array = tr.data pad_width = int(pad_length_in_seconds * tr.stats.sampling_rate) # Determine if we should pad before or after if before: pad_before = pad_width if after: pad_after = pad_width logger.debug(f"zero pad {tr.id} ({pad_before}, {pad_after}) samples") # Constant value is default 0 tr.data = np.pad(array, (pad_before, pad_after), mode='constant') tr.stats.starttime -= pad_length_in_seconds logger.debug(f"new starttime {tr.id}: {tr.stats.starttime}") return st_pad
def load_windows(ds, net, sta, iteration, step_count, return_previous=False): """ Returns misfit windows from an ASDFDataSet for a given iteration, step, network and station, as well as a count of windows returned. If given iteration and step are not present in dataset (e.g. during line search, new step), will try to search the previous step, which may or may not be contained in the previous iteration. Returns windows as Pyflex Window objects which can be used in Pyadjoint or in the Pyatoa workflow. .. note:: Expects that windows are saved into the dataset at each iteration and step such that there is a coherent structure within the dataset :type ds: pyasdf.ASDFDataSet :param ds: ASDF dataset containing MisfitWindows subgroup :type net: str :param net: network code used to find the name of the misfit window :type sta: str :param sta: station code used to find the name of the misfit window :type iteration: int or str :param iteration: current iteration, will be formatted by the function :type step_count: int or str :param step_count: step count, will be formatted by the function :type return_previous: bool :param return_previous: search the dataset for available windows from the previous iteration/step given the current iteration/step :rtype window_dict: dict :return window_dict: dictionary containing misfit windows, in a format expected by Pyatoa Manager class """ # Ensure the tags are properly formatted iteration = format_iter(iteration) step_count = format_step(step_count) windows = ds.auxiliary_data.MisfitWindows window_dict = {} if return_previous: # Retrieve windows from previous iter/step prev_windows = previous_windows(windows=windows, iteration=iteration, step_count=step_count) window_dict = dataset_windows_to_pyflex_windows(windows=prev_windows, network=net, station=sta) else: if hasattr(windows, iteration) and \ hasattr(windows[iteration], step_count): # Attempt to retrieve windows from the given iter/step logger.debug(f"searching for windows in {iteration}{step_count}") window_dict = dataset_windows_to_pyflex_windows( windows=windows[iteration][step_count], network=net, station=sta) return window_dict
def stf_convolve(st, half_duration, source_decay=4., time_shift=None, time_offset=None): """ Convolve function with a Gaussian window source time function. Design follows Specfem3D Cartesian "comp_source_time_function.f90" `hdur` given is `hdur`_Gaussian = hdur/SOURCE_DECAY_MIMIC_TRIANGLE with SOURCE_DECAY_MIMIC_TRIANGLE ~ 1.68 This gaussian uses a strong decay rate to avoid non-zero onset times, while still miicking a triangle source time function :type st: obspy.stream.Stream :param st: stream object to convolve with source time function :type half_duration: float :param half_duration: the half duration of the source time function, usually provided in moment tensor catalogs :type source_decay: float :param source_decay: the decay strength of the source time function, the default value of 4 gives a Gaussian. A value of 1.68 mimics a triangle. :type time_shift: float :param time_shift: Time shift of the source time function in seconds :type time_offset: If simulations have a value t0 that is negative, i.e. a starttime before the event origin time. This value will make sure the source time function doesn't start convolving before origin time to avoid non-zero onset times :rtype: obspy.stream.Stream :return: stream object which has been convolved with a source time function """ logger.debug(f"convolving data w/ Gaussian (t/2={half_duration:.2f}s)") sampling_rate = st[0].stats.sampling_rate half_duration_in_samples = round(half_duration * sampling_rate) # generate gaussian function decay_rate = half_duration_in_samples / source_decay a = 1 / (decay_rate**2) t = np.arange(-half_duration_in_samples, half_duration_in_samples, 1) gaussian_stf = np.exp(-a * t**2) / (np.sqrt(np.pi) * decay_rate) # prepare time offset machinery if time_offset: time_offset_in_samp = int(time_offset * sampling_rate) # convolve each trace with the soure time function and time shift if needed st_out = st.copy() for tr in st_out: if time_shift: tr.stats.starttime += time_shift data_out = np.convolve(tr.data, gaussian_stf, mode="same") tr.data = data_out return st_out
def previous_windows(windows, iteration, step_count): """ Given an iteration and step count, find windows from the previous step count. If none are found for the given iteration, return the most recently available windows. .. note:: Assumes that windows are saved at each iteration. :type windows: pyasdf.utils.AuxiliaryDataAccessor :param windows: ds.auxiliary_data.MisfitWindows[iter][step] :type iteration: int or str :param iteration: the current iteration :type step_count: int or str :param step_count: the current step count :rtype: pyasdf.utils.AuxiliaryDataAccessor :return: ds.auxiliary_data.MisfitWindows """ # Ensure we're working with integer values for indexing, e.g. 's00' -> 0 if isinstance(iteration, str): iteration = int(iteration[1:]) if isinstance(step_count, str): step_count = int(step_count[1:]) # Get a flattened list of iters and steps as unique tuples of integers iters = [] steps = {i: windows[i].list() for i in windows.list()} for i, s in steps.items(): for s_ in s: iters.append((int(i[1:]), int(s_[1:]))) current = (iteration, step_count) if current in iters: # If windows have already been added to the auxiliary data prev_iter, prev_step = iters[iters.index(current) - 1] else: # Wind back the step to see if there are any windows for this iteration while step_count >= 0: if (iteration, step_count) in iters: prev_iter, prev_step = iteration, step_count break step_count -= 1 else: # If nothing is found return the most recent windows available prev_iter, prev_step = iters[-1] # Format back into strings for accessing auxiliary data prev_iter = format_iter(prev_iter) prev_step = format_step(prev_step) logger.debug(f"most recent windows: {prev_iter}{prev_step}") return windows[prev_iter][prev_step]
def dataset_windows_to_pyflex_windows(windows, network, station): """ Convert the parameter dictionary of an ASDFDataSet MisfitWindow into a dictionary of Pyflex Window objects, in the same format as Manager.windows Returns empty dict and 0 if no windows are found :type windows: pyasdf.utils.AuxiliaryDataAccessor :param windows: ds.auxiliary_data.MisfitWindows[iter][step] :type network: str :param network: network of the station related to the windows :type station: str :param station: station related to the windows :rtype: dict :return: dictionary of window attributes in the same format that Pyflex outputs """ window_dict, _num_windows = {}, 0 for window_name in windows.list(): net, sta, comp, n = window_name.split("_") # Check the title of the misfit window to see if applicable if (net == network) and (sta == station): par = windows[window_name].parameters # Create a Pyflex Window object window = Window(left=par["left_index"], right=par["right_index"], center=par["center_index"], dt=par["dt"], time_of_first_sample=UTCDateTime( par["time_of_first_sample"]), min_period=par["min_period"], channel_id=par["channel_id"]) # We cant initiate these parameters so set them after the fact # If data changed, should recalculate with Window._calc_criteria() setattr(window, "dlnA", par["dlnA"]) setattr(window, "cc_shift", par["cc_shift_in_samples"]) setattr(window, "max_cc_value", par["max_cc_value"]) # Save windows into the dictionary labelled by component if comp in window_dict.keys(): # Either append to existing entry window_dict[comp] += [window] else: # Or create the first entry window_dict[comp] = [window] _num_windows += 1 logger.debug(f"{_num_windows} window(s) found in dataset for " f"{network}.{station}") return window_dict
def write_stations_adjoint(ds, iteration, specfem_station_file, step_count=None, pathout=None): """ Generate the STATIONS_ADJOINT file for Specfem input by reading in the STATIONS file and cross-checking which adjoint sources are available in the Pyasdf dataset. :type ds: pyasdf.ASDFDataSet :param ds: dataset containing AdjointSources auxiliary data :type iteration: str or int :param iteration: iteration number, e.g. "i01". Will be formatted so int ok. :type step_count: str or int :param step_count: step count e.g. "s00". Will be formatted so int ok. If NoneType, final step of the iteration will be chosen automatically. :type specfem_station_file: str :param specfem_station_file: path/to/specfem/DATA/STATIONS :type pathout: str :param pathout: path to save file 'STATIONS_ADJOINT' """ # Check which stations have adjoint sources stas_with_adjsrcs = [] adj_srcs = ds.auxiliary_data.AdjointSources[format_iter(iteration)] # Dynamically determine final step count in the iteration if step_count is None: step_count = adj_srcs.list()[-1] logger.debug(f"writing stations adjoint for " f"{format_iter(iteration)}{format_step(step_count)}") adj_srcs = adj_srcs[format_step(step_count)] for code in adj_srcs.list(): stas_with_adjsrcs.append(code.split('_')[1]) stas_with_adjsrcs = set(stas_with_adjsrcs) # Figure out which stations were simulated with open(specfem_station_file, "r") as f: lines = f.readlines() # If no output path is specified, save into current working directory with # an event_id tag to avoid confusion with other files, else normal naming if pathout is None: write_out = f"./STATIONS_ADJOINT_{format_event_name(ds)}" else: write_out = os.path.join(pathout, "STATIONS_ADJOINT") # Rewrite the Station file but only with stations that contain adjoint srcs with open(write_out, "w") as f: for line in lines: if line.split()[0] in stas_with_adjsrcs: f.write(line)
def match_npts(st_a, st_b, force=None): """ Resampling can cause sample number differences which will lead to failure of some preprocessing or processing steps. This function ensures that `npts` matches between traces by extending one of the traces with zeros. A small taper is applied to ensure the new values do not cause discontinuities. Note: its assumed that all traces within a single stream have the same `npts` :type st_a: obspy.stream.Stream :param st_a: one stream to match samples with :type st_b: obspy.stream.Stream :param st_b: one stream to match samples with :type force: str :param force: choose which stream to use as the default npts, defaults to 'a', options: 'a', 'b' :rtype: tuple (obspy.stream.Stream, obspy.stream.Stream) :return: streams that may or may not have adjusted npts, returned in the same order as provided """ # Assign the number of points, copy to avoid editing in place if not force or force == "a": npts = st_a[0].stats.npts st_const = st_a.copy() st_change = st_b.copy() else: npts = st_b[0].stats.npts st_const = st_b.copy() st_change = st_a.copy() for tr in st_change: diff = abs(tr.stats.npts - npts) if diff: logger.debug(f"appending {diff} zeros to {tr.get_id()}") tr.data = np.append(tr.data, np.zeros(diff)) # Ensure streams are returned in the correct order if not force or force == "a": return st_const, st_change else: return st_change, st_const
def save_windows(self): """ Convenience function to save collected misfit windows into an ASDFDataSet with some preliminary checks Auxiliary data tag is hardcoded as 'MisfitWindows' """ if self.ds is None: logger.warning("Manager has no ASDFDataSet, cannot save windows") elif not self.windows: logger.warning("Manager has no windows to save") elif not self.config.save_to_ds: logger.warning("config parameter save_to_ds is set False, " "will not save windows") else: logger.debug("saving misfit windows to ASDFDataSet") add_misfit_windows(self.windows, self.ds, path=self.config.aux_path)
def gather_event(self, event_id=None, try_fm=True, **kwargs): """ Gather an ObsPy Event object by searching disk then querying webservices .. note:: Event info need only be retrieved once per Pyatoa workflow. :type try_fm: bool :param try_fm: try to find correspondig focal mechanism. :rtype: obspy.core.event.Event :return: event retrieved either via internal or external methods :raises GathererNoDataException: if no event information is found. """ logger.debug("gathering event") event = None # Attempt to gather event information internally event = self.event_fetch(event_id, **kwargs) # If no data internally, query FDSN if event is None and self.Client: event = self.event_get(event_id) # Append focal mechanism or moment tensor information, which is # likely stored in a separate catalog if try_fm: event = append_focal_mechanism(event, client=self.config.client) # If no event after internal/external checks, throw error if event is None: raise GathererNoDataException(f"no Event information found for " f"{self.config.event_id}") # Otherwise state success and grab important origin information else: self.origintime = event.preferred_origin().time # Save event information to dataset if necessary if self.ds and self.config.save_to_ds: try: self.ds.add_quakeml(event) logger.debug(f"event QuakeML added to ASDFDataSet") # Trying to re-add an event to the ASDFDataSet throws ValueError except ValueError: pass return event
def asdf_event_fetch(self): """ Return Event information from ASDFDataSet. .. note:: Assumes that the ASDF Dataset will only contain one event, which is dictated by the structure of Pyatoa. .. note:: TO DO: * Remove the logger statement, and write to corresponding functions * event_fetch: calls this function and (1) in succession :rtype event: obspy.core.event.Event :return event: event object :raises AttributeError: if no event attribute found in ASDFDataSet :raises IndexError: if event attribute found but no events """ event = self.ds.events[0] logger.debug(f"matching event found: {format_event_name(event)}") return event
def save_adjsrcs(self): """ Convenience function to save collected adjoint sources into an ASDFDataSet with some preliminary checks Auxiliary data tag is hardcoded as 'AdjointSources' """ if self.ds is None: logger.warning("Manager has no ASDFDataSet, cannot save " "adjoint sources") elif not self.adjsrcs: logger.warning("Manager has no adjoint sources to save") elif not self.config.save_to_ds: logger.warning("config parameter save_to_ds is set False, " "will not save adjoint sources") else: logger.debug("saving adjoint sources to ASDFDataSet") add_adjoint_sources(adjsrcs=self.adjsrcs, ds=self.ds, path=self.config.aux_path, time_offset=self.stats.time_offset_sec)
def event_get(self, event_id=None): """ Return event information parameters pertaining to a given event id if an event id is given, else by origin time. Catches FDSN exceptions. :rtype event: obspy.core.event.Event or None :return event: event object if found, else None. """ if not self.Client: return None if event_id is None: event_id = self.config.event_id event, origintime = None, None if event_id is not None: try: # Get events via event id, only available from certain clients logger.debug(f"event ID: {event_id}, querying " f"client {self.config.client}") event = self.Client.get_events(eventid=event_id)[0] except FDSNException: pass if self.origintime and event is None: try: # If getting by event id doesn't work, try based on origintime logger.debug(f"origintime: {self.origintime}, querying" f"client {self.config.client}") event = self.Client.get_events(starttime=self.origintime, endtime=self.origintime) if len(event) > 1: # Getting by origin time may result in multiple events # found in the catalog, this is hard to control and will # probably need to be addressed manually. logger.warning(f"{len(event)} events found, expected 1." f"Returning first entry, manual revision " f"may be required.") event = event[0] except FDSNException: pass return event
def station_get(self, code, **kwargs): """ Call for ObsPy FDSN client to download station dataless information. Defaults to retrieving response information. :type code: str :param code: Station code following SEED naming convention. This must be in the form NN.SSSS.LL.CCC (N=network, S=station, L=location, C=channel). Allows for wildcard naming. By default the pyatoa workflow wants three orthogonal components in the N/E/Z coordinate system. Example station code: NZ.OPRZ.10.HH? :rtype: obspy.core.inventory.Inventory :return: inventory containing relevant network and stations Keyword Arguments :: str station_level: The level of the station metadata if retrieved using the ObsPy Client. Defaults to 'response' """ level = kwargs.get("station_level", "response") if not self.Client: return None logger.debug(f"querying client {self.config.client}") net, sta, loc, cha = code.split('.') try: inv = self.Client.get_stations( network=net, station=sta, location=loc, channel=cha, starttime=self.origintime - self.config.start_pad, endtime=self.origintime + self.config.end_pad, level=level) return inv except FDSNException: return None
def obs_waveform_get(self, code): """ Call for ObsPy FDSN webservice client to download waveform data. .. Note: ObsPy sometimes returns traces with varying sample lengths, so we use a 10 second cushion on start and end time and trim after retrieval to make sure traces are the same length. :type code: str :param code: Station code following SEED naming convention. This must be in the form NN.SSSS.LL.CCC (N=network, S=station, L=location, C=channel). Allows for wildcard naming. By default the pyatoa workflow wants three orthogonal components in the N/E/Z coordinate system. Example station code: NZ.OPRZ.10.HH? :rtype stream: obspy.core.stream.Stream :return stream: waveform contained in a stream :raises FDSNException: if no data found using Client """ if not self.Client or self.config.synthetics_only: return None logger.debug(f"querying client {self.config.client}") net, sta, loc, cha = code.split('.') try: st = self.Client.get_waveforms( network=net, station=sta, location=loc, channel=cha, starttime=self.origintime - (self.config.start_pad + 10), endtime=self.origintime + (self.config.end_pad + 10)) # Sometimes FDSN queries return improperly cut start and end times, # so we retrieve +/-10 seconds and then cut down st.trim(starttime=self.origintime - self.config.start_pad, endtime=self.origintime + self.config.end_pad) return st except FDSNException: return None
def _format_windows(self): """ .. note:: In `pyadjoint.calculate_adjoint_source`, the window needs to be a list of lists, with each list containing the [left_window, right_window]; each window argument should be given in units of time (seconds). This is not in the PyAdjoint docs. :rtype: dict of list of lists :return: dictionary with key related to individual components, and corresponding to a list of lists containing window start and end """ adjoint_windows = {} if self.windows is not None: for comp, window in self.windows.items(): adjoint_windows[comp] = [] dt = self.st_obs.select(component=comp)[0].stats.delta # Prepare Pyflex window indices to give to Pyadjoint for win in window: # Window units given in seconds adj_win = [win.left * dt, win.right * dt] adjoint_windows[comp].append(adj_win) # If no windows given, calculate adjoint source on whole trace else: logger.debug("no windows given, adjoint sources will be " "calculated on full trace") for comp in self.config.component_list: dt = self.st_obs.select(component=comp)[0].stats.delta npts = self.st_obs.select(component=comp)[0].stats.npts # We offset the bounds of the entire trace by 1s to play nice # with PyAdjoints quirky method of generating the adjsrc. # The assumption being the end points will be zero anyway adjoint_windows[comp] = [[1, npts * dt - 1]] return adjoint_windows
def _check(self): """ A series of sanity checks to make sure that the configuration parameters are set properly to avoid any problems throughout the workflow. Should normally be run after any parameters are changed to make sure that they are acceptable. """ if self.iteration is not None: assert(self.iteration >= 1), "Iterations must start at 1" if self.step_count is not None: assert(self.step_count >= 0), "Step count must start from 0" # Check period range is acceptable assert(self.min_period < self.max_period), \ "min_period must be less than max_period" # Check if unit output properly set, dictated by ObsPy units acceptable_units = ['DISP', 'VEL', 'ACC'] assert(self.unit_output in acceptable_units), \ f"unit_output should be in {acceptable_units}" # Check that paths are in the proper format, dictated by Pyatoa required_keys = ['synthetics', 'waveforms', 'responses', 'events'] assert(isinstance(self.paths, dict)), "paths should be a dict" for key in self.paths.keys(): assert(key in required_keys), \ f"path keys can only be in {required_keys}" # Make sure that all the required keys are given in the dictionary for key in required_keys: if key not in self.paths.keys(): self.paths[key] = [] # Set the component list. Rotate component list if necessary if self.rotate_to_rtz: logger.debug("Components changed ZNE -> ZRT") if not self.component_list: logger.debug("Component list set to R/T/Z") self.component_list = ["R", "T", "Z"] else: for comp in ["N", "E"]: assert(comp not in self.component_list), \ f"rotated component list cannot include '{comp}'" else: if not self.component_list: logger.debug("Component list set to E/N/Z") self.component_list = ["E", "N", "Z"] # Check that the amplitude ratio is a reasonable number if self.win_amp_ratio > 0: assert(self.win_amp_ratio < 1), \ "window amplitude ratio should be < 1" # Make sure adjoint source type is formatted properly self.adj_src_type = format_adj_src_type(self.adj_src_type)
def retrieve_windows(self, iteration, step_count, return_previous): """ Mid-level window selection function that retrieves windows from a PyASDF Dataset, recalculates window criteria, and attaches window information to Manager. No access to rejected window information. :type iteration: int or str :param iteration: retrieve windows from the given iteration :type step_count: int or str :param step_count: retrieve windows from the given step count in the given dataset :type return_previous: bool :param return_previous: if True: return windows from the previous step count in relation to the given iteration/step_count. if False: return windows from the given iteration/step_count """ logger.info(f"retrieving windows from dataset") net, sta, _, _ = self.st_obs[0].get_id().split(".") # Function will return empty dictionary if no acceptable windows found windows = load_windows(ds=self.ds, net=net, sta=sta, iteration=iteration, step_count=step_count, return_previous=return_previous) # Recalculate window criteria for new values for cc, tshift, dlnA etc... logger.debug("recalculating window criteria") for comp, windows_ in windows.items(): try: d = self.st_obs.select(component=comp)[0].data s = self.st_syn.select(component=comp)[0].data for w, win in enumerate(windows_): # Post the old and new values to the logger for sanity check logger.debug(f"{comp}{w}_old - " f"cc:{win.max_cc_value:.2f} / " f"dt:{win.cc_shift:.1f} / " f"dlnA:{win.dlnA:.2f}") win._calc_criteria(d, s) logger.debug(f"{comp}{w}_new - " f"cc:{win.max_cc_value:.2f} / " f"dt:{win.cc_shift:.1f} / " f"dlnA:{win.dlnA:.2f}") # IndexError thrown when trying to access an empty Stream except IndexError: continue self.windows = windows self.stats.nwin = sum(len(_) for _ in self.windows.values())
def write_adj_src_to_ascii(ds, iteration, step_count=None, pathout=None, comp_list="ZNE"): """ Take AdjointSource auxiliary data from a Pyasdf dataset and write out the adjoint sources into ascii files with proper formatting, for input into PyASDF. .. note:: Specfem dictates that if a station is given as an adjoint source, all components must be present, even if some components don't have any misfit windows. This function writes blank adjoint sources (an array of 0's) to satisfy this requirement. :type ds: pyasdf.ASDFDataSet :param ds: dataset containing adjoint sources :type iteration: str or int :param iteration: iteration number, e.g. "i00". Will be formatted so int ok. :type step_count: str or int :param step_count: step count e.g. "s00". Will be formatted so int ok. If NoneType, final step of the iteration will be chosen automatically. :type pathout: str :param pathout: path to write the adjoint sources to :type comp_list: str :param comp_list: component list to check when writing blank adjoint sources defaults to N, E, Z, but can also be e.g. R, T, Z """ def write_to_ascii(f_, array): """ Function used to write the ascii in the correct format. Columns are formatted like the ASCII outputs of Specfem, two columns times written as float, amplitudes written in E notation, 6 spaces between. :type f_: _io.TextIO :param f_: the open file to write to :type array: numpy.ndarray :param array: array of data from obspy stream """ for dt, amp in array: if dt == 0. and amp != 0.: dt = 0 adj_formatter = "{dt:>13d} {amp:13.6E}\n" elif dt != 0. and amp == 0.: amp = 0 adj_formatter = "{dt:13.6f} {amp:>13d}\n" else: adj_formatter = "{dt:13.6f} {amp:13.6E}\n" f_.write(adj_formatter.format(dt=dt, amp=amp)) # Shortcuts adjsrcs = ds.auxiliary_data.AdjointSources[format_iter(iteration)] if step_count is None: step_count = adjsrcs.list()[-1] adjsrcs = adjsrcs[format_step(step_count)] logger.debug(f"writing adjoint sources to ascii for " f"{format_iter(iteration)}{format_step(step_count)}") # Set the path to write the data to. # If no path is given, default to current working directory if pathout is None: pathout = os.path.join("./", format_event_name(ds)) if not os.path.exists(pathout): os.makedirs(pathout) # Loop through adjoint sources and write out ascii files # ASDF datasets use '_' as separators but Specfem wants '.' as separators already_written = [] for adj_src in adjsrcs.list(): station = adj_src.replace('_', '.') fid = os.path.join(pathout, f"{station}.adj") with open(fid, "w") as f: write_to_ascii(f, adjsrcs[adj_src].data[()]) # Write blank adjoint sources for components with no misfit windows for comp in list(comp_list): station_blank = (adj_src[:-1] + comp).replace('_', '.') if station_blank.replace('.', '_') not in adjsrcs.list() and \ station_blank not in already_written: # Use the same adjoint source, but set the data to zeros blank_adj_src = adjsrcs[adj_src].data[()] blank_adj_src[:, 1] = np.zeros(len(blank_adj_src[:, 1])) # Write out the blank adjoint source fid_blank = os.path.join(pathout, f"{station_blank}.adj") with open(fid_blank, "w") as b: write_to_ascii(b, blank_adj_src) # Append to a list to make sure we don't write doubles already_written.append(station_blank)
def measure(self, force=False, save=True): """ Measure misfit and calculate adjoint sources using PyAdjoint. Method for caluculating misfit set in Config, Pyadjoint expects standardized traces with the same spectral content, so this function will not run unless these flags are passed. Returns a dictionary of adjoint sources based on component. Saves resultant dictionary to a pyasdf dataset if given. .. note:: Pyadjoint returns an unscaled misfit value for an entire set of windows. To return a "total misfit" value as defined by Tape (2010) Eq. 6, the total summed misfit will need to be scaled by the number of misfit windows chosen in Manager.window(). :type force: bool :param force: ignore flag checks and run function, useful if e.g. external preprocessing is used that doesn't meet flag criteria :type save: bool :param save: save adjoint sources to ASDFDataSet """ self.check() if self.config.adj_src_type is None: logger.info("adjoint source type is 'None', will not measure") return # Check that data has been filtered and standardized if not self.stats.standardized and not force: raise ManagerError("cannot measure misfit, not standardized") elif not (self.stats.obs_processed and self.stats.syn_processed) \ and not force: raise ManagerError("cannot measure misfit, not filtered") elif self.stats.nwin == 0 and not force: raise ManagerError("cannot measure misfit, no windows recovered") logger.debug(f"running Pyadjoint w/ type: {self.config.adj_src_type}") # Create list of windows needed for Pyadjoint adjoint_windows = self._format_windows() # Run Pyadjoint to retrieve adjoint source objects total_misfit, adjoint_sources = 0, {} for comp, adj_win in adjoint_windows.items(): try: adj_src = pyadjoint.calculate_adjoint_source( adj_src_type=self.config.adj_src_type, config=self.config.pyadjoint_config, observed=self.st_obs.select(component=comp)[0], synthetic=self.st_syn.select(component=comp)[0], window=adj_win, plot=False) # Re-format component name to reflect SPECFEM convention adj_src.component = f"{channel_code(adj_src.dt)}X{comp}" # Save adjoint sources in dictionary object. Sum total misfit adjoint_sources[comp] = adj_src logger.info(f"{adj_src.misfit:.3f} misfit for comp {comp}") total_misfit += adj_src.misfit except IndexError: continue # Save adjoint source internally and to dataset self.adjsrcs = adjoint_sources if save: self.save_adjsrcs() # Run check to get total misfit self.check() logger.info(f"total misfit {self.stats.misfit:.3f}") return self
def standardize(self, force=False, standardize_to="syn"): """ Standardize the observed and synthetic traces in place. Ensures Streams have the same starttime, endtime, sampling rate, npts. :type force: bool :param force: allow the User to force the function to run even if checks say that the two Streams are already standardized :type standardize_to: str :param standardize_to: allows User to set which Stream conforms to which by default the Observed traces should conform to the Synthetic ones because exports to Specfem should be controlled by the Synthetic sampling rate, npts, etc. """ self.check() if not self.stats.len_obs or not self.stats.len_syn: raise ManagerError("cannot standardize, not enough waveform data") elif self.stats.standardized and not force: logger.info("data already standardized") return self logger.info("standardizing streams") # If observations starttime after synthetic, zero pad the front of obs dt_st = self.st_obs[0].stats.starttime - self.st_syn[0].stats.starttime if dt_st > 0: self.st_obs = zero_pad(self.st_obs, dt_st, before=True, after=False) # Match sampling rates if standardize_to == "syn": self.st_obs.resample(self.st_syn[0].stats.sampling_rate) else: self.st_syn.resample(self.st_obs[0].stats.sampling_rate) # Match start and endtimes self.st_obs, self.st_syn = trim_streams(st_a=self.st_obs, st_b=self.st_syn, force={ "obs": "a", "syn": "b" }[standardize_to]) # Match the number of samples self.st_obs, self.st_syn = match_npts(st_a=self.st_obs, st_b=self.st_syn, force={ "obs": "a", "syn": "b" }[standardize_to]) # Determine if synthetics start before the origintime if self.event is not None: self.stats.time_offset_sec = (self.st_syn[0].stats.starttime - self.event.preferred_origin().time) logger.debug(f"time offset is {self.stats.time_offset_sec}s") else: self.stats.time_offset_sec = 0 self.stats.standardized = True return self
def fetch_event_by_dir(self, event_id, prefix="", suffix="", format_=None, **kwargs): """ Fetch event information via directory structure on disk. Developed to parse CMTSOLUTION and QUAKEML files, but theoretically accepts any format that the ObsPy read_events() function will accept. Will search through all paths given until a matching source file found. .. note:: This function will search for the following path /path/to/event_dir/{prefix}{event_id}{suffix} so, if e.g., searching for a CMTSOLUTION file in the current dir: ./CMTSOLUTION_{event_id} Wildcards are okay but the function will return the first match :type event_id: str :param event_id: Unique event identifier to search source file by. e.g., a New Zealand earthquake ID '2018p130600'. A prefix or suffix will be tacked onto this :rtype event: obspy.core.event.Event or None :return event: event object if found, else None. :type prefix: str :param prefix Prefix to prepend to event id for file name searching. Wildcards are okay. :type suffix: str :param suffix: Suffix to append to event id for file name searching. Wildcards are okay. :type format_: str or NoneType :param format_: Expected format of the file to read, e.g., 'QUAKEML', passed to ObsPy read_events. NoneType means read_events() will guess """ # Ensure that the paths are a list so that iterating doesnt accidentally # try to iterate through a string. paths = self.config.paths["events"] if not isinstance(paths, list): paths = [paths] event = None for path_ in paths: if not os.path.exists(path_): continue # Search for available event files fid = os.path.join(path_, f"{prefix}{event_id}{suffix}") for filepath in glob.glob(fid): logger.debug(f"searching for event data: {filepath}") if os.path.exists(filepath): try: # Allow input of various types of source files if "SOURCE" in prefix: logger.info( f"reading SPECFEM2D SOURCE: {filepath}") cat = [read_specfem2d_source(filepath)] elif "FORCESOLUTION" in prefix: logger.info(f"reading FORCESOLUTION: {filepath}") cat = [read_forcesolution(filepath)] else: logger.info( f"reading source using ObsPy: {filepath}") cat = read_events(filepath, format=format_) if len(cat) != 1: logger.warning( f"{filepath} event file contains more than one " "event, returning 1st entry") event = cat[0] break except Exception as e: logger.warning(f"{filepath} event file read error {e}") if event is not None: logger.info(f"retrieved local file:\n{filepath}") else: logger.info(f"no local event file found") return event
def fetch_resp_by_dir(self, code, **kwargs): """ Fetch station dataless via directory structure on disk. Will search through all paths given until StationXML found. .. note:: Default path naming follows SEED convention, that is: path/to/dataless/{NET}.{STA}/RESP.{NET}.{STA}.{LOC}.{CHA} e.g. path/to/dataless/NZ.BFZ/RESP.NZ.BFZ.10.HHZ :type code: str :param code: Station code following SEED naming convention. This must be in the form NN.SSSS.LL.CCC (N=network, S=station, L=location, C=channel). Allows for wildcard naming. By default the pyatoa workflow wants three orthogonal components in the N/E/Z coordinate system. Example station code: NZ.OPRZ.10.HH? :rtype inv: obspy.core.inventory.Inventory or None :return inv: inventory containing relevant network and stations Keyword Arguments :: str resp_dir_template: Directory structure template to search for response files. By default follows the SEED convention, 'path/to/RESPONSE/{sta}.{net}/' str resp_fid_template: Response file naming template to search for station dataless. By default, follows the SEED convention 'RESP.{net}.{sta}.{loc}.{cha}' """ resp_dir_template = kwargs.get("resp_dir_template", "{sta}.{net}") resp_fid_template = kwargs.get("resp_fid_template", "RESP.{net}.{sta}.{loc}.{cha}") inv = None net, sta, loc, cha = code.split('.') # Ensure that the paths are a list so that iterating doesnt accidentally # try to iterate through a string. paths = self.config.paths["responses"] if not isinstance(paths, list): paths = [paths] for path_ in paths: if not os.path.exists(path_): continue # Attempting to instantiate an empty Inventory requires some # positional arguements we dont have, so don't do that fid = os.path.join(path_, resp_dir_template, resp_fid_template) fid = fid.format(net=net, sta=sta, cha=cha, loc=loc) logger.debug(f"searching for responses: {fid}") for filepath in glob.glob(fid): if inv is None: # The first inventory becomes the main inv to return inv = read_inventory(filepath) else: # All other inventories are appended to the original inv_append = read_inventory(filepath) # Merge inventories to remove repeated networks inv = merge_inventories(inv, inv_append) logger.info(f"retrieved response locally:\n{filepath}") return inv
def fetch_obs_by_dir(self, code, **kwargs): """ Fetch observation waveforms via directory structure on disk. .. note:: Default waveform directory structure assumed to follow SEED convention. That is: path/to/data/{YEAR}/{NETWORK}/{STATION}/{CHANNEL}*/{FID} e.g. path/to/data/2017/NZ/OPRZ/HHZ.D/NZ.OPRZ.10.HHZ.D :type code: str :param code: Station code following SEED naming convention. This must be in the form NN.SSSS.LL.CCC (N=network, S=station, L=location, C=channel). Allows for wildcard naming. By default the pyatoa workflow wants three orthogonal components in the N/E/Z coordinate system. Example station code: NZ.OPRZ.10.HH? :rtype stream: obspy.core.stream.Stream or None :return stream: stream object containing relevant waveforms, else None Keyword Arguments :: str obs_dir_template: directory structure to search for observation data. Follows the SEED convention: 'path/to/obs_data/{year}/{net}/{sta}/{cha}' str obs_fid_template: File naming template to search for observation data. Follows the SEED convention: '{net}.{sta}.{loc}.{cha}*{year}.{jday:0>3}' """ obs_dir_template = kwargs.get("obs_dir_template", "{year}/{net}/{sta}/{cha}*") obs_fid_template = kwargs.get( "obs_fid_template", "{net}.{sta}.{loc}.{cha}*{year}.{jday:0>3}") if self.origintime is None: raise AttributeError("'origintime' must be specified") net, sta, loc, cha = code.split('.') # If waveforms contain midnight, multiple files need to be read jdays = overlapping_days(origin_time=self.origintime, start_pad=self.config.start_pad, end_pad=self.config.end_pad) # Ensure that the paths are a list so that iterating doesnt accidentally # try to iterate through a string. paths = self.config.paths["waveforms"] if not isinstance(paths, list): paths = [paths] for path_ in paths: if not os.path.exists(path_): continue full_path = os.path.join(path_, obs_dir_template, obs_fid_template) pathlist = [] for jday in jdays: pathlist.append( full_path.format(net=net, sta=sta, cha=cha, loc=loc, jday=jday, year=self.origintime.year)) st = Stream() for fid in pathlist: logger.debug(f"searching for observations: {fid}") for filepath in glob.glob(fid): st += read(filepath) logger.info(f"retrieved observations locally:\n{filepath}") if len(st) > 0: # Take care of gaps in data by converting to masked data st.merge() st.trim(starttime=self.origintime - self.config.start_pad, endtime=self.origintime + self.config.end_pad) # Check if trimming retains data if len(st) > 0: return st else: logger.warning( "data does not fit origin time +/- pad time") return None else: return None
def trim_streams(st_a, st_b, precision=1E-3, force=None): """ Trim two streams to common start and end times, Do some basic preprocessing before trimming. Allows user to force one stream to conform to another. Assumes all traces in a stream have the same time. Prechecks make sure that the streams are actually different :type st_a: obspy.stream.Stream :param st_a: streams to be trimmed :type st_b: obspy.stream.Stream :param st_b: streams to be trimmed :type precision: float :param precision: precision to check UTCDateTime differences :type force: str :param force: "a" or "b"; force trim to the length of "st_a" or to "st_b", if not given, trims to the common time :rtype: tuple of obspy.stream.Stream :return: trimmed stream objects in the same order as input """ # Check if the times are already the same if st_a[0].stats.starttime - st_b[0].stats.starttime < precision and \ st_a[0].stats.endtime - st_b[0].stats.endtime < precision: logger.debug(f"start and endtimes already match to {precision}") return st_a, st_b # Force the trim to the start and end times of one of the streams if force: if force.lower() == "a": start_set = st_a[0].stats.starttime end_set = st_a[0].stats.endtime elif force.lower() == "b": start_set = st_b[0].stats.starttime end_set = st_b[0].stats.endtime # Get starttime and endtime base on min values else: st_trimmed = st_a + st_b start_set, end_set = 0, 1E10 for st in st_trimmed: start_hold = st.stats.starttime end_hold = st.stats.endtime if start_hold > start_set: start_set = start_hold if end_hold < end_set: end_set = end_hold # Trim to common start and end times st_a_out = st_a.copy() st_b_out = st_b.copy() for st in [st_a_out, st_b_out]: st.trim(start_set, end_set) # Trimming doesn't always make the starttimes exactly equal if the precision # of the UTCDateTime object is set too high. # Artificially shift the starttime of the streams iff the amount shifted # is less than the sampling rate for st in [st_a_out, st_b_out]: for tr in st: dt = start_set - tr.stats.starttime if 0 < dt < tr.stats.sampling_rate: logger.debug(f"shifting {tr.id} starttime by {dt}s") tr.stats.starttime = start_set elif dt >= tr.stats.delta: logger.warning( f"{tr.id} starttime is {dt}s greater than delta") return st_a_out, st_b_out
def fetch_syn_by_dir(self, code, **kwargs): """ Fetch synthetic waveforms from Specfem3D via directory structure on disk, if necessary convert native ASCII format to Stream object. :type code: str :param code: Station code following SEED naming convention. This must be in the form NN.SSSS.LL.CCC (N=network, S=station, L=location, C=channel). Allows for wildcard naming. By default the pyatoa workflow wants three orthogonal components in the N/E/Z coordinate system. Example station code: NZ.OPRZ.10.HH? :rtype stream: obspy.core.stream.Stream or None :return stream: stream object containing relevant waveforms Keyword Arguments :: str syn_pathname: Config.paths key to search for synthetic data. Defaults to 'synthetics', but for the may need to be set to 'waveforms' in certain use-cases. str syn_unit: Optional argument to specify the letter used to identify the units of the synthetic data: For Specfem3D: ["d", "v", "a", "?"] 'd' for displacement, 'v' for velocity, 'a' for acceleration. Wildcards okay. Defaults to '?' str syn_dir_template: Directory structure template to search for synthetic waveforms. Defaults to empty string str syn_fid_template: The naming template of synthetic waveforms defaults to "{net}.{sta}.*{cmp}.sem{syn_unit}" """ syn_cfgpath = kwargs.get("syn_cfgpath", "synthetics") syn_unit = kwargs.get("syn_unit", "?") syn_dir_template = kwargs.get("syn_dir_template", "") syn_fid_template = kwargs.get("syn_fid_template", "{net}.{sta}.*{cmp}.sem{dva}") if self.origintime is None: raise AttributeError("'origintime' must be specified") # Generate information necessary to search for data net, sta, loc, cha = code.split('.') # Ensure that the paths are a list so that iterating doesnt accidentally # try to iterate through a string. paths = self.config.paths[syn_cfgpath] if not isinstance(paths, list): paths = [paths] for path_ in paths: if not os.path.exists(path_): continue # Here the path is determined for search. If event_id is given, # the function will search for an event_id directory. full_path = os.path.join(path_, syn_dir_template, syn_fid_template) logger.debug(f"searching for synthetics: {full_path}") st = Stream() for filepath in glob.glob( full_path.format(net=net, sta=sta, cmp=cha[2:], dva=syn_unit.lower())): try: # Convert the ASCII file to a miniseed st += read_sem(filepath, self.origintime) except UnicodeDecodeError: # If the data file is for some reason already in miniseed st += read(filepath) logger.info(f"retrieved synthetics locally:\n{filepath}") if len(st) > 0: st.merge() st.trim(starttime=self.origintime - self.config.start_pad, endtime=self.origintime + self.config.end_pad) return st else: return None
def filters(st, min_period=None, max_period=None, min_freq=None, max_freq=None, corners=2, zerophase=True, **kwargs): """ Choose the appropriate filter depending on the ranges given. Either periods or frequencies can be given. Periods will be prioritized. Uses Butterworth filters by default. Filters the stream in place. Kwargs passed to filter functions. :type st: obspy.core.stream.Stream :param st: stream object to be filtered :type min_period: float :param min_period: minimum filter bound in units of seconds :type max_period: float :param max_period: maximum filter bound in units of seconds :type min_freq: float :param min_freq: optional minimum filter bound in units of Hz, will be overwritten by `max_period` if given :type max_freq: float :param max_freq: optional maximum filter bound in units of Hz, will be overwritten by `min_period` if given :type corners: int :param corners: number of filter corners to be passed to ObsPy filter functions :type zerophase: bool :param zerophase: if True, run filter backwards and forwards to avoid any phase shifting :rtype: obspy.core.stream.Stream :return: Filtered stream object """ # Ensure that the frequency and period bounds are the same if not min_period and max_freq: min_period = 1 / max_freq if not max_period and min_freq: max_period = 1 / min_freq if not max_freq: max_freq = 1 / min_period if not min_freq: min_freq = 1 / max_period # Bandpass if both bounds given if min_period and max_period: st.filter("bandpass", corners=corners, zerophase=zerophase, freqmin=min_freq, freqmax=max_freq, **kwargs) logger.debug(f"bandpass filter: {min_period} - {max_period}s w/ " f"{corners} corners") # Highpass if only minimum period given elif min_period: st.filter("highpass", freq=max_freq, corners=corners, zerophase=zerophase, **kwargs) logger.debug(f"highpass filter: {min_period}s w/ {corners} corners") # Lowpass if only minimum period given elif max_period: st.filter("lowpass", freq=min_freq, corners=corners, zerophase=True, **kwargs) logger.debug(f"lowpass filter: {max_period}s w/ {corners} corners") return st
def default_process(mgmt, choice, **kwargs): """ Default preprocessing function to process waveform data from a Manager Preprocessing is slightly different for obs and syn waveforms. Each processing function is split into a separate function so that they can be called by custom preprocessing functions. :type mgmt: pyatoa.core.manager.Manager :param mgmt: Manager class that should contain a Config object as well as waveform data and inventory :type choice: str :param choice: option to preprocess observed, synthetic or both available: 'obs', 'syn' :rtype: obspy.core.stream.Stream :return: preprocessed stream object pertaining to `choice` Keyword Arguments :: int water_level: water level for response removal float taper_percentage: amount to taper ends of waveform bool remove_response: remove instrument response using the Manager's inventory object. Defaults to True bool apply_filter: filter the waveforms using the Config's min_period and max_period parameters. Defaults to True bool convolve_with_stf: Convolve synthetic data with a Gaussian source time function if a half duration is provided. """ assert choice in ["obs", "syn"], "choice must be 'obs' or 'syn" water_level = kwargs.get("water_level", 60) taper_percentage = kwargs.get("taper_percentage", 0.05) zerophase = kwargs.get("zerophase", True) remove_response = kwargs.get("remove_response", True) apply_filter = kwargs.get("apply_filter", True) convolve_with_stf = kwargs.get("convolve_with_stf", True) # Copy the stream to avoid editing in place. Synthetic variable used to # denote if the waveforms are synthetic or not, these require special # processing steps. if choice == "syn": st = mgmt.st_syn.copy() is_synthetic_data = True elif choice == "obs": st = mgmt.st_obs.copy() is_synthetic_data = mgmt.config.synthetics_only if is_preprocessed(st): return st # Get rid of any long period trends that may affect that data st.detrend("simple").detrend("demean").taper(taper_percentage) st = taper_time_offset(st, taper_percentage, mgmt.stats.time_offset_sec) # Observed specific data preprocessing includes response and rotating to ZNE if remove_response and not is_synthetic_data: logger.debug(f"removing response, units to {mgmt.config.unit_output}") st.remove_response(inventory=mgmt.inv, output=mgmt.config.unit_output, water_level=water_level, plot=False) # Rotate streams if not in ZNE, e.g. Z12. Only necessary for observed logger.debug("rotating from generic coordinate system to ZNE") st.rotate(method="->ZNE", inventory=mgmt.inv) st.detrend("simple").detrend("demean").taper(taper_percentage) else: logger.debug("no response removal, synthetic data or requested not to") # Rotate the given stream from standard NEZ to RTZ if BAz given if mgmt.baz: logger.debug(f"rotating NE->RT by {mgmt.baz} degrees") st.rotate(method="NE->RT", back_azimuth=mgmt.baz) # Filter data based on the given period bounds if apply_filter: st = filters(st, min_period=mgmt.config.min_period, max_period=mgmt.config.max_period, corners=mgmt.config.filter_corners, zerophase=zerophase) st.detrend("simple").detrend("demean").taper(taper_percentage) else: logger.debug(f"no filter applied to data") # Convolve synthetic data with a Gaussian source time function if convolve_with_stf and is_synthetic_data and mgmt.stats.half_dur: st = stf_convolve(st=st, half_duration=mgmt.stats.half_dur) return st
def geonet_mt(event_id, units, event=None, csv_fid=None): """ Focal mechanisms created by John Ristau are written to a .csv file located on Github. This function will append information from the .csv file onto the Obspy event object so that all the information can be located in a single object :type event_id: str :param event_id: unique event identifier :type units: str :param units: output units of the focal mechanism, either: 'dynecm': for dyne*cm or 'nm': for Newton*meter :type event: obspy.core.event.Event :param event: event to append focal mechanism to :rtype focal_mechanism: obspy.core.event.FocalMechanism :return focal_mechanism: generated focal mechanism """ assert (units in ["dynecm", "nm"]), "units must be 'dynecm' or 'nm'" mtlist = get_geonet_mt(event_id, csv_fid=csv_fid) # Match the identifier with Goenet id_template = f"smi:local/geonetcsv/{mtlist['PublicID']}/{{}}" # Generate the Nodal Plane objects containing strike-dip-rake nodal_plane_1 = source.NodalPlane(strike=mtlist['strike1'], dip=mtlist['dip1'], rake=mtlist['rake1']) nodal_plane_2 = source.NodalPlane(strike=mtlist['strike2'], dip=mtlist['dip2'], rake=mtlist['rake2']) nodal_planes = source.NodalPlanes(nodal_plane_1, nodal_plane_2, preferred_plane=1) # Create the Principal Axes as Axis objects tension_axis = source.Axis(azimuth=mtlist['Taz'], plunge=mtlist['Tpl'], length=mtlist['Tva']) null_axis = source.Axis(azimuth=mtlist['Naz'], plunge=mtlist['Npl'], length=mtlist['Nva']) pressure_axis = source.Axis(azimuth=mtlist['Paz'], plunge=mtlist['Ppl'], length=mtlist['Pva']) principal_axes = source.PrincipalAxes(t_axis=tension_axis, p_axis=pressure_axis, n_axis=null_axis) # Create the Moment Tensor object with correct units and scaling if units == "nm": c = 1E-7 # conversion from dyne*cm to N*m logger.debug(f"GeoNet moment tensor is in units of Newton*meters") elif units == "dynecm": c = 1 logger.debug(f"GeoNet moment tensor is in units of dyne*cm") # CV is the conversion from non-units to the desired output units cv = 1E20 * c seismic_moment_in_nm = mtlist['Mo'] * c # Convert the XYZ coordinate system of GeoNet to an RTP coordinate system # expected in the CMTSOLUTION file of Specfem rtp = mt_transform(mt={ "m_xx": mtlist['Mxx'] * cv, "m_yy": mtlist['Myy'] * cv, "m_zz": mtlist['Mzz'] * cv, "m_xy": mtlist['Mxy'] * cv, "m_xz": mtlist['Mxz'] * cv, "m_yz": mtlist['Myz'] * cv }, method="xyz2rtp") tensor = source.Tensor(m_rr=rtp['m_rr'], m_tt=rtp['m_tt'], m_pp=rtp['m_pp'], m_rt=rtp['m_rt'], m_rp=rtp['m_rp'], m_tp=rtp['m_tp']) # Create the source time function source_time_function = source.SourceTimeFunction( duration=2 * half_duration_from_m0(seismic_moment_in_nm)) # Generate a comment for provenance comment = Comment( force_resource_id=True, text="Automatically generated by Pyatoa via GeoNet MT CSV") # Fill the moment tensor object moment_tensor = source.MomentTensor( force_resource_id=True, tensor=tensor, source_time_function=source_time_function, # !!! # This doesn't play nice with obspy.Catalog.write(format='CMTSOLUTION') # so ignore the origin id # derived_origin_id=id_template.format('origin#ristau'), scalar_moment=seismic_moment_in_nm, double_couple=mtlist['DC'] / 100, variance_reduction=mtlist['VR'], comment=comment) # Finally, assemble the Focal Mechanism. Force a resource id so that # the event can identify its preferred focal mechanism focal_mechanism = source.FocalMechanism(force_resource_id=True, nodal_planes=nodal_planes, moment_tensor=moment_tensor, principal_axes=principal_axes, comments=[comment]) # Append the focal mechanisms to the event object. Set the preferred # focal mechanism so that this attribute can be used in the future if event: event.focal_mechanisms = [focal_mechanism] event.preferred_focal_mechanism_id = focal_mechanism.resource_id return event, focal_mechanism # If no event is given, just return the focal mechanism else: return None, focal_mechanism