Source code for mspasspy.db.normalize

from abc import ABC, abstractmethod

from mspasspy.ccore.utility import MsPASSError, ErrorSeverity, Metadata
from mspasspy.util.error_logger import PyErrorLogger
from mspasspy.ccore.seismic import (
    TimeSeries,
    Seismogram,
    TimeSeriesEnsemble,
    SeismogramEnsemble,
)
from mspasspy.db.database import Database
from mspasspy.util.decorators import mspass_func_wrapper

from bson import ObjectId

from obspy import UTCDateTime
import pymongo
import copy
import pandas as pd
import dask
import numpy as np

type_pdd = pd.core.frame.DataFrame
type_ddd = dask.dataframe.core.DataFrame


[docs]class BasicMatcher(ABC): """ Base class defining the api for a generic matching capability for MongoDB normalization. The base class is a skeleton that defines on required abstract methods and initializes a set of universal attributes all matchers need. It cannot be instatiated directly. Matching is defined as one of two things: (1) a one-to-one match algorithm is guaranteed to have each search yield either exactly one match or none. That is defined through a find_one method following the same concept in MongoDB. (2) some matches are not unique and yield more than one document. For that case use the find method. Unlike the MongoDB find method, however, find in this context returns a list of Metadata containers holding the set of attributes requested in lists defined on constuction. Another way of viewing this interface, in fact, is an abstraction of the find and find_one methods of MongoDB to a wider class of algorithms that may or may not utilize MongoDB directly. In particular, intermediate level classes defined below that implement different cache data structures allow input either by loading data from a MongoDB collection of from a pandas DataFrame. That can potentially provide a wide variety of applications of matching data to tabular data contained in files loaded into pandas by any of long list of standard dataframe read methods. Examples are any SQL database or antelope raw tables or views loaded as text files. """ def __init__( self, attributes_to_load=None, load_if_defined=None, aliases=None, ): """ Base class constructor sets only attributes considered necessary for all subclasses. Most if not all subclasses will want to call this constructor driven by an arg list passed to the subclass constructor. :param attributes_to_load: is a list of keys (strings) that are to be loaded from the normalizing table/collection. Default for this base class is None, but subclasses should normally define a default list. It is important to realize that all subclasses should normally treat this list as a set of required attributes. Optional values should appear in the load_if_defined list. :param load_if_defined: is a secondary list of keys (strings) that should be loaded only if they are defined. Default is None here, subclass should set their own default values. :param aliases: should be a python dictionary used to define alternative keys to access a data object's Metadata from that defining the same attribute in the collection/table being matched. Note carefully the key of the dictionary is the collection/table attribute name and the value associated with that key is the alias to use to fetch Metadata. When matchers scan the attributes_to_load and load_if_defined list they should treat missing entries in alias as meaning the key in the collection/table and Metadata are identical. Default is a None which is used as a signal to this constructor to create an empty dictionary meaning there are no aliases. :type aliases: python dictionary """ if attributes_to_load is None: self.attributes_to_load = [] else: self.attributes_to_load = attributes_to_load if load_if_defined is None: self.load_if_defined = [] else: self.load_if_defined = load_if_defined if aliases is None: self.aliases = dict() elif isinstance(aliases, dict): self.aliases = aliases else: raise TypeError( "BasicMatcher constructor: aliases argument must be either None or define a python dictionary" ) def __call__(self, d, *args, **kwargs): """ Convenience method that allows the shorthand of the find_one method using the standard python meaning of this symbol. e.g. if we have an concrete instance of a subclass of this base class for matching against ObjectIds using the implementation below called IDMatcher we assign to the symbol "matcher" we can get a Metadata container of the matching content for a MsPASS data object with symbol d using md = matcher(d) versus md = matcher.find_one(d). All subclasses have this interface define because it is a (nonvirtual) base class method. """ return self.find_one(d, *args, **kwargs)
[docs] @abstractmethod def find(self, mspass_object, *args, **kwargs) -> tuple: """ Abstraction of the MongoDB database find method with the matching criteria defined when a concrete instance is instantiated. Like the MongoDB method implementations it should return a list of containers matching the keys found in the data passed through the mspass_object. A key difference from MongoDB, however, is that instead of a MongoDB cursor we return a python list of Metadata containers. In some instances that is a direct translation of a MongoDB cursor to a list of Metadata objects. The abstraction is useful to allow small collections to be accessed faster with a generic cache algorithm (see below) and loading of tables of data through a file-based subclass of this base. All can be treated through a common interface. WE STRESS STRONGLY that the abstraction assumes returns are always small enough to not cause a memory bloating problem. If you need the big-memory model of a cursor use it directly. All subclasses must implement this virtual method to be concrete or they cannot be instantiated. If the matching algorithm implemented is always expected to be a unique one-to-one match applications may want to have this method throw an exception as a use error. That case should use the find_one interface defined below. All implementations should return a pair (2 component tuple). 0 is expected to hold a list of Metadata containers and 1 is expected to contain either a None type or an PyErrorLogger object. The PyErrorLogger is a convenient way to pass error messages back to the caller in a manner that is easier to handle with the MsPASS error system than an exception mechanism. Callers should handle four cases that are possible for a return (Noting [] means an empty list and [...] a list with data) 1. [] None - notmatch found 2. [] ErrorLog - failure with an informational message in the ErrorLog that should be preserved. The presence of an error should imply something went wrong and it was simply a null result. 3. [...] None - all is good with no detected errors 4. [...] ErrorLog - valid data returned but there is a warning or informational message posted. In this case handlers may want to examine the ErrorSeverity components of the log and handle different levels differently (e.g. Fatal and Informational should always be treated differently) """
[docs] @abstractmethod def find_one(self, mspass_object, *args, **kwargs) -> tuple: """ Abstraction of the MongoDB database find_one method with the matching criteria defined when a concrete instance is instantiated. Like the MongoDB method implementations should return the unique document that the keys in mspass_object are expected to define with the matching criteria defined by the instance. A type example of an always unique match is ObjectIds. When a match is found the result should be returned in a Metadata container. The attributes returned are normally a subset of the document and are defined by the base class attributes "attributes_to_load" and "load_if_defined". For database instances this is little more than copying desired attributes from the matching document returned by MongoDB, but for abstraction more may be involved. e.g., implemented below is a generic cached algorithm that stores a collection to be matched in memory for efficiency. That implementation allows the "collection" to be loaded from MongoDB or a pandas DataFrame. All implementations should return a pair (2 component tuple). 0 is expected to hold a Metadata containers that was yielded by the match. It should be returned as None if there is no match. 1 is expected to contain either a None type or an PyErrorLogger object. The PyErrorLogger is a convenient way to pass error messages back to the caller in a manner that is easier to handle with the MsPASS error system than an exception mechanism. Callers should handle four cases that are possible for a return: 1, None None - no match found 2. None ErrorLog - failure with an informational message in the ErrorLog that the caller may want be preserved or convert to an exception. 3. Metadata None - all is good with no detected errors 4. Metadata ErrorLog - valid data was returned but there is a warning or informational message posted. In this case handlers may want to examine the ErrorSeverity components of the log and handle different levels differently (e.g. Fatal and Informational should always be treated differently) """ pass
[docs]class DatabaseMatcher(BasicMatcher): """ Matcher using direct database queries to MongoDB. Each call to the find method of this class constructs a query, calls the MongoDB database find method with that query, and extracts desired attributes from the return in the form of a Metadata container. The query construction is abstracted by a virtual method called query_generator. This is an intermediate class that cannot be instantiated directly because it contains a virtual method. User's should consult docstrings for constructors for subclasses of this intermediate class. """ def __init__( self, db, collection, attributes_to_load=None, load_if_defined=None, aliases=None, require_unique_match=False, prepend_collection_name=False, ): """ Constructor for this intermediate class. It should not be used except by subclasses as this intermediate class is not concrete. """ super().__init__( attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, ) if not isinstance(collection, str): raise TypeError( "{} constructor: required arg1 must be a collection name - received invalid type".format( self.__class__.__name__ ) ) self.collection = collection if isinstance(db, pymongo.database.Database): self.dbhandle = db[collection] else: message = "DatabaseMatcher constructor: db argument is not a valid Database handle\n" message += "Actual type of db arg={}".format(str(type(db))) raise TypeError(message) self.require_unique_match = require_unique_match self.prepend_collection_name = prepend_collection_name
[docs] @abstractmethod def query_generator(self, mspass_object) -> dict: """ Subclasses of this intermediate class MUST implement this method. It should extract content from mspass_object and use that content to generate a MongoDB query that is passed directly to the find method of the MongoDB database handle stored within this object (self) during the class construction. Since pymongo uses a python dict for that purpose it must return a valid query dict. Implementations should return None if no query could be generated. Common, for example, if a key required to generate the query is missing from mspass_object. """ pass
[docs] def find(self, mspass_object): """ Generic database implementation of the find method for this abstraction. It returns what the base class api specifies. That is, normally it returns a tuple with component 0 being a python list of Metadata containers. Each container holds the subset of attributes defined by attributes_to_load and (if present) load_if_defined. The list is the set of all documents matching the query, which at this level of the class structure is abstract. The method dogmatically requires data for all keys defined by attributes_to_load. It will throw a MsPASSError exception with a Fatal tag if any of the required attributes are not defined in any of the documents. The return matches the API specification for BasicMatcher. It also handles failures of the abstract query_generator through the mechanism the base class api specified: a None return means the method could not create a valid query. Failures in the query will always post a message to elog tagging the result as "Invalid". It also handles the common problem of dead data or accidentally receiving invalid data like a None. The later may cause other algorithms to abort, but we handle it here return [None,None]. We don't return an PyErrorLogger in that situation as the assumption is there is no place to put it and something else has gone really wrong. """ if not _input_is_valid(mspass_object): elog = PyErrorLogger() message = "received invalid data. Arg0 must be a valid MsPASS data object" elog.log_error(message, ErrorSeverity.Invalid) if mspass_object.dead(): return [None, None] query = self.query_generator(mspass_object) if query is None: elog = PyErrorLogger() message = "query_generator method failed to generate a valid query - required attributes are probably missing" elog.log_error(message, ErrorSeverity.Invalid) return [None, elog] number_hits = self.dbhandle.count_documents(query) if number_hits <= 0: elog = PyErrorLogger() message = "query = " + str(query) + " yielded no documents" elog.log_error(message, ErrorSeverity.Complaint) return [None, elog] cursor = self.dbhandle.find(query) elog = PyErrorLogger() metadata_list = [] for doc in cursor: try: md = _extractData2Metadata( doc, self.attributes_to_load, self.aliases, self.prepend_collection_name, self.collection, self.load_if_defined, ) metadata_list.append(md) except MsPASSError as e: raise MsPASSError("DatabaseMatcher.find: " + e.message, e.severity) if elog.size() <= 0: return [metadata_list, None] else: return [metadata_list, elog]
[docs] def find_one(self, mspass_object): """ Generic database implementation of the find_one method. The tacit assumption is that if you call find_one you are expecting a unique match to the algorithm implemented. The actual behavior for a nonunique match is controlled by the class attribute require_unique_match. Subclasses that want to dogmatically enforce uniqueness (appropriate for example with ObjectIds) should set require_unique_match True. In that case if a match is not unique the method will throw an exception. When False, which is the default, an informational message is posted and the method returns the first list element returned by find. This method is actually little more than a wrapper around find to handle that uniqueness issue. """ find_output = self.find(mspass_object) if find_output[0] is None: return [None, find_output[1]] mdlist_length = len(find_output[0]) if mdlist_length == 1: return [find_output[0][0], find_output[1]] else: # somewhat messy logic to handle differnt situations # we throw an exception if the constructor set require_unique_match # True. Otherwise we need to handle the distinction on whether or # not the return from find had an PyErrorLogger defined with data. if self.require_unique_match: message = "query of collection {col} did not yield a unique match. Found {n} matching documents. Aborting".format( col=self.collection, n=mdlist_length ) raise MsPASSError( "DatabaseMatcher.find_one: " + message, ErrorSeverity.Fatal ) else: if find_output[1] is None: elog = PyErrorLogger() else: elog = find_output[1] message = "query of collection {col} did not yield a unique match. Found {n} matching documents. Using first one in list".format( col=self.collection, n=mdlist_length ) elog.log_error( "DatabaseMatcher.find_one", message, ErrorSeverity.Complaint ) return [find_output[0][0], elog]
[docs]class DictionaryCacheMatcher(BasicMatcher): """ Matcher implementing a caching method based on a python dictionary. This is an intermediate class for instances where the database collection to be matched is small enough that the in-memory model is appropriate. It should also only be used if the matching algorithm can be reduced to a single string that can serve as a unique id for each tuple. The class defines a generic dictionary cache with a string key. The way that key is define is abstracted through two virtual methods: (1) The cache_id method creates a match key from a mspass data object. That is normally from the Metadata container but it is not restricted to that. e.g. start time for TimeSeries or Seismogram objects can be obtained from the t0 attribute directly. (2) The db_make_cache_id is called by the internal method of this intermediate class (method name is _load_normalization_cache) to build the cache index from MongoDB documents scanned to construct the cache. Two different methods to define the cache index are necessary as a generic way to implement aliases. A type example is the mspass use of names like "channel_id" to refer to the ObjectId of a specific document in the channel collection. When loading channel the name key is "_id" but data objects would normally have that same data defined with the key "channel_id". Similarly, if data have had aliases applied a key in the data may not match the name in a collection to be matched. The dark side of this is it is very easy when running subclasses of this to get null results with all members of a dataset. As always testing with a subset of data is strongly recommended before running versions of this on a large dataset. This class cannot be instantiated because it is not concrete (has abstract - virtual - methods that must be defined by subclasses) See implementations for constructor argument definitions. """ def __init__( self, db_or_df, collection, query=None, attributes_to_load=None, aliases=None, load_if_defined=None, require_unique_match=False, prepend_collection_name=False, use_dataframe_index_as_cache_id=False, ): """ Constructor for this intermediate class. It should not be used except by subclasses as this intermediate class is not concrete. It calls the base class constructor and then loads two internal attributes: query and collection. It then creates the normalization python dict that applies the abstract cache_id method. Note that only works for concrete subclasses of this intermediate class. """ super().__init__( attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, ) if not isinstance(collection, str): raise TypeError( "{} constructor: required arg1 must be a collection name - received invalid type".format( self.__class__.__name__ ) ) if query == None: self.query = dict() elif isinstance(query, dict): self.query = query else: raise TypeError( "{} constructor: query argument must be a python dict container".format( self.__class__.__name__ ) ) self.collection = collection self.require_unique_match = require_unique_match self.prepend_collection_name = prepend_collection_name self.use_dataframe_index_as_cache_id = use_dataframe_index_as_cache_id # This is a redundant initialization but a minor cost for stability self.normcache = dict() # Reference the base class to avoid a type error # This seems to be an oddity from using the same name Database # in mspass as pymongo if isinstance(db_or_df, pymongo.database.Database): self._db_load_normalization_cache(db_or_df, collection) elif isinstance(db_or_df, (type_ddd, type_pdd)): self._df_load_normalization_cache(db_or_df, collection) else: message = "{} constructor: required arg0 must be a Database handle or panda Dataframe\n".format( self.__class__.__name__ ) message += "Actual type = {}".format(str(type(db_or_df))) raise TypeError(message)
[docs] @abstractmethod def cache_id(self, mspass_object) -> str: """ Concrete implementations must implement this method to define how a mspass data object, mspass_object, is to be used to construct the key to the cache dict container. It is distinct from db_make_cache_id to allow differences in naming or even the algorithm used to construct the key from a datum relative to the database. This complicates the interface but makes it more generic. :param mspass_object: is expected to be a MsPASS object. Any type restrictions should be implemented in subclasses that implement the method. :return: should always return a valid string and never throw an exception. If the algorithm fails the implementation should return a None. """ pass
[docs] @abstractmethod def db_make_cache_id(self, doc) -> str: """ Concrete implementation must implement this method to define how the cache index is to be created from database documents passed through the arg doc, which pymongo always returns as a python dict. It is distinct from cache_id to allow differences in naming or the algorithm for loading the cache compared to accessing it using attributes of a data object. If the id string cannot be created from doc an implementation should return None. The generic loaders in this class, db_load_normalization_cache and df_load_normalization_class, handle that situation cleanly but if a subclass overrides the load methods they should handle such errors. "cleanly" in this case means they throw an exception which is appropriate since they are run during construction and any invalid key is not acceptable in that situation. """ pass
[docs] def find_one(self, mspass_object) -> tuple: """ Implementation of find for generic cached method. It uses the cache_id method to create the indexing string from mspass_object and then returns a match to the cache stored in self. Only subclasses of this intermediate class can work because the cache_id method is defined as a pure virtual method in this intermediate class. That construct is used to simplify writing additional matcher classes. All extensions need to do is define the cache_id and db_make_cache_id algorithms to build that index. :param mspass_object: Any valid MsPASS data object. That means TimeSeries, Seismogram, TimeSeriesEnsemble, or SeismogramEnsemble. This datum is passed to the (abstract) cache_id method to create an index string and the result is used to fetch the Metadata container matching that key. What is required of the input is dependent on the subclass implementation of cache_id. :return: 2-component tuple following API specification in BasicMatcher. Only two possible results are possible from this implementation: None ErrorLog - failure with an error message that can be passed on if desired or printed Metadata None - all is good with no detected errors. The Metadata container holds all attributes_to_load and any defined load_if_defined values. """ find_output = self.find(mspass_object) if find_output[0] is None: return find_output elif len(find_output[0]) == 1: return [find_output[0][0], find_output[1]] else: # as with the database version we use require_unique_match # to define if we should be dogmatic or not if self.require_unique_match: message = "query does not yield a unique match and require_unique_match is set true" raise MsPASSError( "DictionaryCacheMatcher.find: " + message, ErrorSeverity.Fatal ) else: message = "encountered a nonunique match calling find_one - returning contents of first matching document found" if find_output[1] is None: elog = PyErrorLogger() else: elog = find_output[1] elog.log_error( "DictionaryCacheMatcher.find: ", message, ErrorSeverity.Complaint ) return [find_output[0][0], elog]
[docs] def find(self, mspass_object) -> tuple: """ Generic implementation of find method for cached tables/collections. This method is a generalization of the MongoDB database find method. It differs in two ways. First, it creates the "query" directly from a MsPASS data object (pymongo find requires a dict as input). Second, the result is return as a python list of Metadata containers containing what is (usually) a subset of the data stored in the original collection (table). In contrast pymongo database find returns a database "Cursor" object which is their implementation of a large list that may exceed the size of memory. A key point is the model here makes sense only if the original table itself is small enough to not cause a memory problem. Further, find calls that yield long list may cause efficiency problems as subclasses that build on this usually will need to do a linear search through the list if they need to find a particular instance (e.g. call to find_one). :param mspass_object: data object to match against data in cache (i.e. query). :type mspass_object: must be a valid MsPASS data object. currently that means TimeSeries, Seismogram, TimeSeriesEnsemble, or SeismogramEnsemble. If it is anything else (e.g. None) this method will return a tuple [None, elog] with elog being a PyErrorLogger with a posted message. :return: tuple with two elements. 0 is either a list of valid Metadata container(s) or None and 1 is either None or an PyErrorLogger object. There are only two possible returns from this method: [None, elog] - find failed. See/save elog for why it failed. [ [md1, md2, ..., mdn], None] - success with 0 a list of Metadata containing attributes_to_load and load_if_defined (if appropriate) in each component. """ if not _input_is_valid(mspass_object): elog = PyErrorLogger() elog.log_error( "Received datum that was not a valid MsPASS data object", ErrorSeverity.Invalid, ) return [None, elog] if mspass_object.dead(): return [None, None] thisid = self.cache_id(mspass_object) # this should perhaps generate two different messages as the # they imply slightly different things - the current message # is accurate though if (thisid == None) or (thisid not in self.normcache): error_message = "cache_id method found no match for this datum" elog = PyErrorLogger() elog.log_error(error_message, ErrorSeverity.Invalid) return [None, elog] else: return [self.normcache[thisid], None]
def _db_load_normalization_cache(self, db, collection): """ This private method abstracts the process of loading a cached version of a normalizing collection. It creates a python dict stored internally with the name self.normcache. The container is keyed by a string created by the virtual method cache_id. The value associated with each dict key is a python list of Metadata containers. Each component is constructed from any document matching the algorithm defined by cache_id. The list is constructed by essentially appending a new Metadata object whenever a matching cache_id is returned. The Metadata containers normally contain only a subset of the original attributes in the collection. The list attributes_to_load is treated as required and this method will throw a MsPASSError exception if any of them are missing from any document parsed. Use load_if_define for attributes that are not required for your workflow. This method will throw a MsPASS fatal error in two situations: 1. If the collection following the (optional) query is empty 2. If any attribute in the self.attributes_to_load is not defined in any document loaded. In all BasicMatcher subclasses attributes_to_load are considered required. :param db: MsPASS Database class MongoDB database handle. Note it can be the subclass of the base class MongooDB handle as extensions for MsPASS to the handle are not used in this method. :param collection: string defining the MongoDB collection that is to be loaded and indexed - the normalizing collection target. """ dbhandle = db[collection] self.collection_size = dbhandle.count_documents(self.query) if self.collection_size <= 0: message = "Query={} of collection {} yielded no documents\n".format( str(self.query), collection ) message += "Cannot construct a zero length object" raise MsPASSError( "DictionaryCacheMatcher._load_normalization_cache: " + message, ErrorSeverity.Fatal, ) cursor = dbhandle.find(self.query) self.normcache = dict() count = 0 for doc in cursor: cache_key = self.db_make_cache_id(doc) # This error trap may not be necessary but the api requires us # to handle a None return if cache_key == None: raise MsPASSError( "DictionaryCacheMatcher._load_normalization_cache: " + "db_make_cache_id failed - coding problem or major problem with collection=" + collection, ErrorSeverity.Fatal, ) try: md = _extractData2Metadata( doc, self.attributes_to_load, self.aliases, self.prepend_collection_name, collection, self.load_if_defined, ) if cache_key in self.normcache: self.normcache[cache_key].append(md) else: self.normcache[cache_key] = [md] count += 1 except MsPASSError as e: raise MsPASSError( e.message + " in document number {n} of collection {col}".format( n=count, col=collection ), e.severity, ) def _df_load_normalization_cache(self, df, collection): """ This function does the same thing as _db_load_normalization_cache, the only difference is that this current function takes one argument, which is a dataframe. :param df: a pandas/dask dataframe where we load data from """ query_result = df if self.query is not None and len(self.query) > 0: # Create a query # There are multiple ways of querying in a dataframe, according to # the experiments in https://stackoverflow.com/a/46165056/11138718 # We pick the following approach: sub_conds = [df[key].values == val for key, val in self.query.items()] cond = np.logical_and.reduce(sub_conds) query_result = df[cond] if len(query_result.index) <= 0: message = ( "Query={query_str} of dataframe={dataframe_str}" " yielded 0 documents - cannot construct this object".format( query_str=str(self.query), dataframe_str=str(df) ) ) raise MsPASSError( "DictionaryCacheMatcher._load_normalization_cache: " + message, ErrorSeverity.Fatal, ) self.normcache = dict() count = 0 for index, doc in query_result.iterrows(): cache_key = index if not self.use_dataframe_index_as_cache_id: cache_key = self.db_make_cache_id(doc) # This error trap may not be necessary but the api requires us # to handle a None return if cache_key == None: raise MsPASSError( "DictionaryCacheMatcher._load_normalization_cache: " + "db_make_cache_id failed - coding problem or major problem with collection=" + collection, ErrorSeverity.Fatal, ) try: md = _extractData2Metadata( doc, self.attributes_to_load, self.aliases, self.prepend_collection_name, collection, self.load_if_defined, ) if cache_key in self.normcache: self.normcache[cache_key].append(md) else: self.normcache[cache_key] = [md] count += 1 except MsPASSError as e: raise MsPASSError( e.message + " in document number {n} of collection {col}".format( n=count, col=collection ), e.severity, )
[docs]class DataFrameCacheMatcher(BasicMatcher): """ Matcher implementing a caching method based on a Pandas DataFrame This is an intermediate class for instances where the database collection to be matched is small enough that the in-memory model is appropriate. It should be used when the matching algorithm is readily cast into the subsetting api of a pandas DataFrame. The constructor of this intermediate class first calls the BasicMatcher (base class) constructor to initialize some common attribute including the critical lists of attributes to be loaded. This constructor then creates the internal DataFrame cache by one of two methods. If arg0 is a MongoDB database handle it loads the data in the named collection to a DataFrame created during construction. If the input is a DataFrame already it is simply copied selecting only columns defined by the attributes_to_load and load_if_defined lists. There is also an optional parameter, custom_null_values, that is a python dictionary defining values in a field that should be treated as a definition of a Null for that field. The constuctor converts such values to a standard pandas null field value. This class implements generic find and find_one methods. Subclasses of this class must implement a "subset" method to be concrete. A subset method is the abstract algorithm that defines a match for that instance expressed as a pandas subset operation. (For most algorithms there are multiple ways to skin that cat or is it a panda?) See concrete subclasses for examples. This class cannot be instantiated because it is not concrete (has abstract - virtual - methods that must be defined by subclasses) See implementations for constructor argument definitions. """ def __init__( self, db_or_df, collection=None, attributes_to_load=None, load_if_defined=None, aliases=None, require_unique_match=False, prepend_collection_name=False, custom_null_values=None, ): """ Constructor for this intermediate class. It should not be used except by subclasses as this intermediate class is not concrete. """ self.prepend_collection_name = prepend_collection_name self.require_unique_match = require_unique_match self.custom_null_values = custom_null_values # this is a necessary sanity check if collection is None: raise TypeError( "DataFrameCacheMatcher constructor: collection name must be defined when prepend_collection_name is set True" ) elif isinstance(collection, str): self.collection = collection else: raise TypeError( "DataFrameCacheMatcher constructor: collection argument must be a string type" ) # We have to reference the base class pymongo.database.Database here because # the MsPASS subclass name is also Database. That make this # conditional fail in some uses. Using the base class is totally # appropriate here anyway as no MsPASS extension methods are used if not isinstance(db_or_df, (type_pdd, type_ddd, pymongo.database.Database)): raise TypeError( "DataFrameCacheMatcher constructor: required arg0 must be either a pandas, dask Dataframe, or database handle" ) if attributes_to_load is None: if load_if_defined is None: raise MsPASSError( "DataFrameCacheMatcher constructor: usage error. Cannot use default of attributes_to_load (triggers loading all columns) and define a list of names for argument load_if_defined" ) aload = list() for key in db_or_df.columns: aload.append(key) else: aload = attributes_to_load super().__init__( attributes_to_load=aload, load_if_defined=load_if_defined, aliases=aliases, ) df = ( db_or_df if isinstance(db_or_df, (type_pdd, type_ddd)) else pd.DataFrame(list(db_or_df[self.collection].find({}))) ) self._load_dataframe_cache(df)
[docs] def find(self, mspass_object) -> tuple: """ DataFrame generic implementation of find method. This method uses content in any part of the mspass_object (data object) to subset the internal DataFrame cache to return subset of tuples matching some condition defined computed through the abstract (virtual) methdod subset. It then copies entries in attributes_to_load and when not null load_if_defined into one Metadata container for each row of the returned DataFrame. """ if not _input_is_valid(mspass_object): elog = PyErrorLogger( "DataFrameCacheMatcher.find", "Received datum that was not a valid MsPASS data object", ErrorSeverity.Invalid, ) return [None, elog] subset_df = self.subset(mspass_object) # assume all implementations will return a 0 length dataframe # if the subset failed. if len(subset_df) <= 0: error_message = "subset method found no match for this datum" elog = PyErrorLogger() elog.log_error(error_message, ErrorSeverity.Invalid) return [None, elog] else: # This loop cautiously fills one or more Metadata # containers with each row of the DataFrame generating # one Metadata container. mdlist = list() elog = None for index, row in subset_df.iterrows(): md = Metadata() notnulltest = row.notnull() for k in self.attributes_to_load: if notnulltest[k]: if k in self.aliases: key = self.aliases[k] else: key = k if self.prepend_collection_name: if key == "_id": mdkey = self.collection + key else: mdkey = self.collection + "_" + key else: mdkey = key md[mdkey] = row[key] else: if elog is None: elog = PyErrorLogger() error_message = "Encountered Null value for required attribute {key} - repairs of the input DataFrame are required".format( key=k ) elog.log_error( error_message, ErrorSeverity.Invalid, ) return [None, elog] for k in self.load_if_defined: if notnulltest[k]: if k in self.aliases: key = self.aliases[k] else: key = k if self.prepend_collection_name: if key == "_id": mdkey = self.collection + key else: mdkey = self.collection + "_" + key else: mdkey = key md[mdkey] = row[key] mdlist.append(md) return [mdlist, None]
[docs] def find_one(self, mspass_object) -> tuple: """ DataFrame implementation of the find_one method. This method is mostly a wrapper around the find method. It calls the find method and then does one of two thing s depending upon the value of self.require_unique_match. When that boolean is True if the match is not unique it creates an PyErrorLogger object, posts a message to the log, and then returns a [Null,elog] pair. If self.require_unique_match is False and the match is not ambiguous, it again creates an PyErrorLogger and posts a message, but it also takes the first container in the list returned by find and returns in as component 0 of the pair. """ findreturn = self.find(mspass_object) mdlist = findreturn[0] if mdlist is None: return findreturn elif len(mdlist) == 1: return [mdlist[0], findreturn[1]] elif len(mdlist) > 1: if self.require_unique_match: raise MsPASSError( "DataFrameCacheMatcher.find_one: found {n} matches when require_unique_match was set true".format( n=len(mdlist) ), ErrorSeverity.Fatal, ) if findreturn[1] is None: elog = PyErrorLogger() else: elog = findreturn[1] # This maybe should be only posted with a verbose option???? error_message = "found {n} matches. Returned first one found. You should use find instead of find_one if the match is not unique".format( n=len(mdlist) ) elog.log_error(error_message, ErrorSeverity.Complaint) return [mdlist[0], elog] else: # only land here if mdlist is something for which len returns 0 raise MsPASSError( "DataFrameCacheMatchter.find_one: find returned an empty list. Can only happen if custom matcher has overridden find. Find should return None if the match fails", ErrorSeverity.Fatal, )
[docs] @abstractmethod def subset(self, mspass_object) -> pd.DataFrame: """ Required method defining how the internal DataFrame cache is to be subsetted using the contents of the data object mspass_object. Concrete implementation must implement this class. The point of this abstract method is that the way one defines how to get the information needed to define a match with the cache is application dependent. An implementation can use Metadata attributes, data object attributes (e.g. TimeSeries t0 attribute), or even sample data to compute a value to use in DataFrame subset condition. This simplifies writing a custom matcher to implementing only this method as find and find_one use it. Implementations should return a zero length DataFrame if the subset condition yields a null result. i.e. the test len(return_result) should work and return 0 if the subset produced no rows. """ pass
def _load_dataframe_cache(self, df): # This is a bit error prone. It assumes the BasicMatcher # constructor initializes a None default to an empty list fulllist = self.attributes_to_load + self.load_if_defined self.cache = df.reindex(columns=fulllist)[fulllist] if self.custom_null_values is not None: for col, nul_val in self.custom_null_values.items(): self.cache[col] = self.cache[col].replace(nul_val, np.nan)
[docs]class ObjectIdDBMatcher(DatabaseMatcher): """ Implementation of DatabaseMatcher for ObjectIds. In this class the virtual method query_generator uses the mspass convention of using the collection name and the magic string "_id" as the data object key (e.g. channel_id) but runs the query using the "_id" magic string used in MongoDB for the ObjectId of each document. Users should only utilize the find_one method of this class as find, by definition, will always return only one record or None. The find method, in fact, is overloaded and attempts to use it will result in raising a MsPASSError exception. :param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be queried (default "channel"). :type collection: string :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find_one will fail. Default ["_id","lat","lon","elev","hang","vang"]. :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. :param type: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". :type prepend_collection_name: boolean """ def __init__( self, db, collection="channel", attributes_to_load=["_id", "lat", "lon", "elev", "hang", "vang"], load_if_defined=None, aliases=None, prepend_collection_name=True, ): """ Class Constructor. Just calls the superclass constructor directly with no additions. Sets unique match, however, to be dogmatically enforce uniqueness. """ super().__init__( db, collection, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=True, prepend_collection_name=prepend_collection_name, )
[docs] def query_generator(self, mspass_object) -> dict: data_object_key = self.collection + "_id" if mspass_object.is_defined(data_object_key): query_id = mspass_object[data_object_key] # This is a bombproof way to create an ObjectId # works the same if query_id is a string representation of # the id or an actual ObjectId. In the later case it calls # the copy constructor. testid = ObjectId(query_id) return {"_id": testid} else: return None
[docs]class ObjectIdMatcher(DictionaryCacheMatcher): """ Implement an ObjectId match with caching. Most of the code for this class is derived from the superclass DictionaryCacheMatcher. It adds only a concrete implementation of the cache_id method used to construct a key for the cache defined by a python dict (self.normcache). In this case the cache key is simply the string representation of the ObjectId of each document in the collection defined in construction. The cache is then created by the superclass generic method _load_normalization_cache. :param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be loaded and cached to memory inside this object (default "channel") :type collection: string :param query: optional query to apply to collection before loading document attributes into the cache. A typical example would be a time range limit for the channel or site collection to avoid loading instruments not operational during the time span of a data set. Default is None which causes the entire collection to be parsed. :type query: python dict with content that defines a valid query when be passed to MongoDB the MongoDB find method. If query is a type other than a None type or dict the constructor will throw a TypeError. :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find will fail. Default ["_id","lat","lon","elev","hang","vang"] :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. :param type: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when set true all attributes loaded from the normalizing collection will have the channel name prepended. That is essential if the collection contains generic names like "lat" or "depth" that would produce ambiguous keys if used directly. (e.g. lat is used for source, channel, and site collections in the default schema.) :type prepend_collection_name: boolean """ def __init__( self, db, collection="channel", query=None, attributes_to_load=["_id", "lat", "lon", "elev", "hang", "vang"], load_if_defined=None, aliases=None, prepend_collection_name=True, ): """ Class Constructor. Just calls the superclass constructor directly with no additions. It does, however, set the require_unique_match boolean True which cause the find_one method to be dogmatic in enforcing unique matches. """ # require_unique_match is alwatys set True here as that is # pretty much by definition. super().__init__( db, collection, query=query, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=True, prepend_collection_name=prepend_collection_name, ) if query is None: self.query = dict() elif isinstance(query, dict): self.query = query else: raise TypeError( "ObjectIdMatcher constructor: optional query argument must be either None or a python dict" )
[docs] def cache_id(self, mspass_object) -> str: """ Implementation of virtual method with this name for this matcher. It implements the MsPASS approach of defining the key for a normalizing collection as the collection name and the magic string "_id" (e.g. channel_id or site_id). It assumes the collection name is define as self.collection by the constructor of the class when it is instantiated. It attempts to extract the expanded _id name (e.g. channel_id) from the input mspass_object. If successful it returns the string representation of the resulting (assumed) ObjectId. If the key is not defined it returns None as specified by the superclass api. It is important to note the class attribute, self.prepend_collection_name, is indendent of the definition of the cache_id. i.e. what we attempt to extract as the id ALWAYS used the collection name as a prefix (channel_id and not "_id"). The internal boolean controls if the attributes returned by find_one will have the collection name prepended. :param mspass_object: key-value pair container containing an id that is to be extracted. :type mspass_object: Normally this is expected to be a mspass data object (TimeSeries, Seismogram, or ensembles of same) but it can be as simple as a python dict or Metadata with the required key defined. :return: string representation of an ObjectId to be used to matching the cache index stored internally - find method. """ testid = self.collection + "_id" if testid in mspass_object: return str(mspass_object[testid]) else: return None
[docs] def db_make_cache_id(self, doc) -> str: """ Implementation of virtual methods with this name for this matcher. It does nothing more than extract the magic "_id" value from doc and returns its string representation. With MongoDB that means the string representation of the ObjectId of each collection document is used as the key for the cache. :param doc: python dict defining a document return by MongoDB. Only the "_id" value is used. :type doc: python dict container returned by pymongo - usually a cursor component. """ if "_id" in doc: return str(doc["_id"]) else: return None
[docs]class MiniseedDBMatcher(DatabaseMatcher): """ Database implementation of matcher for miniseed data using SEED keys net:sta:chan(channel only):loc and a time interval test. Miniseed data uses the exessively complex key that combines four unique string names (net, sta, chan, and loc) and a time interval of operation to define a unique set of station metadata for each channel. In mspass we also create the site collection without the chan attribute. This implementation works for both channel and site under control of the collection argument. This case is the complete opposite of something like the ObjectId matcher above as the match query we need to generate is excessively long requiring up to 6 fields. The default collection name is channel which is the only correct use if applied to data created through readers applied to wf_miniseed. The implementation can also work on Seismogram data if and only if the channel argument is then set to "site". The difference is that for Seismogram data "chan" is a undefined concept. In both cases the keys and content assume the mspass schema for the channel or site collections. The constructor will throw a MsPASSError exception if the collection argument is anything but "channel" or "site" to enforce this limitation. If you use a schema other than the mspass schema the methods in this class will fail if you change any of the following keys: net, sta, chan, loc, starttime, endtime, hang, vang Users should only call the find_one method for this application. The find_one algorithm first queries for any matches of net:sta:chan(channel only):loc(optional) and data t0 within the startime and endtime of the channel document attributes (an interval match). That combination should yield either 1 or no matches if the channel collection is clean. However, there are known issues with station metadata that can cause multiple matches in unusual cases (Most notably overlapping time intervals defined for the same channel.) The find_one method will handle that case returning the first one found and posting an error message that should be handled by the caller. Instantiation of this class is a call to the superclass constructor with specialized defaults and wrapper code to automatically handle potential mismatches between site and channel. The arguments for the constructor follow: :param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be queried The default is "channel". Use "site" for Seismogram data. Use anything else at your own risk as the algorithm depends heavily on mspass schema definition and properties guaranteed by using the converter from obspy Inventory class loaded through web services or stationml files. :type collection: string :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find will fail. Default ["_id","lat","lon","elev","hang","vang"] "hang","vang","starttime","endtime"] when collection is set as channel. Smaller list of ["_id","lat","lon","elev"] is default when collection is set as "site". :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default is ["loc"]. A common addition here may be response data (see schema definition for keys) :type load_if_defined: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". :type prepend_collection_name: boolean """ def __init__( self, db, collection="channel", attributes_to_load=["starttime", "endtime", "lat", "lon", "elev", "_id"], load_if_defined=None, aliases=None, prepend_collection_name=True, ): aload_tmp = attributes_to_load if collection == "channel": if "hang" not in aload_tmp: aload_tmp.append("hang") if "vang" not in aload_tmp: aload_tmp.append("vang") elif collection != "site": raise MsPASSError( "MiniseedDBMatcher: " + "Illegal collection argument=" + collection + " Must be either channel or site", ErrorSeverity.Fatal, ) super().__init__( db, collection, attributes_to_load=aload_tmp, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=False, prepend_collection_name=prepend_collection_name, )
[docs] def query_generator(self, mspass_object): """ Concrete implementation of (required) abstract method defined in superclass DatabaseMatcher. It generates the complex query for matching net, sta, chan, and optional loc along with the time interval match of data start time between the channel defined "starttime" and "endtime" attributes. This method provides one safety for a common data problem. A common current issue is that if miniseed data are saved to wf_TimeSeries and then read back in a later workflow the default schema will alter the keys for net, sta, chan, and loc to add the prefix "READONLY_" (e.g. READONLY_net). The query automatically tries to recover any of the station name keys using that recipe. :param mspass_object: assumed to be a TimeSeries object with net, sta, chan, and (optional) loc defined. The time for the time interval test is translation to MongoDB syntax of: channel["starttime"] <= mspass_object.to <= channel["endtime"] This algorithm will abort if the statement mspass_object.t0 does not resolve, which means the caller should assure the input is a TimeSeries object. :return: normal return is a string defining the query. If any required station name keys are not defined the method will silently return a None. Caller should handle a None condition. """ query = dict() net = _get_with_readonly_recovery(mspass_object, "net") if net is None: return None query["net"] = net sta = _get_with_readonly_recovery(mspass_object, "sta") if sta is None: return None query["sta"] = sta if self.collection == "channel": chan = _get_with_readonly_recovery(mspass_object, "chan") if chan is None: return None query["chan"] = chan loc = _get_with_readonly_recovery(mspass_object, "loc") # loc being null is not unusual so if is is null we just add it to query if loc != None: query["loc"] = loc # We don't verify mspass_object is a valid TimeSeries here # assuming it was done prior to calling this method. # fetching t0 could cause an abort if mspass_object were not # previously validated. An alternative would be to test # here and return None if mspass_object was not a valid TimeSeries # done this way because other bad things would happen if find # if that assumption was invalid query["starttime"] = {"$lte": mspass_object.t0} query["endtime"] = {"$gte": mspass_object.endtime()} return query
[docs] def find_one(self, mspass_object): """ We overload find_one to provide the unique match needed. Most of the work is done by the query_generator method in this case. This method is little more than a wrapper to run the find method and translating the output into the slightly different form required by find_one. More important is the fact that the wrapper implements two safties to make the code more robust: 1. It immediately tests that mspass_object is a valid TimeSeries or Seismogram object. It will raise a TypeError exception if that is not true. That is enforced because find_one in this context make sense only for atomic objects. 2. It handles dead data cleanly logging a message complaining that the data was already marked dead. In addition note the find method this calls is assumed to handle the case of failures in the query_generator function if any of the required net, sta, chan keys are missing from mspass_object. """ # Be dogmatic and demand mspass_object is a TimeSeries or Seismogram (atomic) if _input_is_atomic(mspass_object): if mspass_object.live: # trap this unlikely but possible condition as this # condition could produce mysterious behavior if mspass_object.time_is_relative(): elog = PyErrorLogger() message = "Usage error: input has a relative time standard but miniseed matching requires a UTC time stamp" elog.log_error(message, ErrorSeverity.Invalid) return [None, elog] find_output = self.find(mspass_object) if find_output[0] is None: return [None, find_output[0]] number_matches = len(find_output[0]) if number_matches == 1: return [find_output[0][0], find_output[1]] else: if find_output[1] is None: elog = PyErrorLogger() else: elog = find_output[1] message = "{n} channel recorded match net:sta:chan:loc:time_interval query for this datume\n".format( n=number_matches ) message += "Using first document in list returned by find method" elog.log_error(message, ErrorSeverity.Complaint) return [find_output[0][0], elog] else: # logged as complaint because by definition if it was # already killed it is Invalid elog = PyErrorLogger() elog.log_error( "Received a datum marked dead - will not attempt match", ErrorSeverity.Complaint, ) return [None, elog] else: raise TypeError( "MiniseedDBMatcher.find_one: this class method can only be applied to TimeSeries or Seismogram objects" )
[docs]class MiniseedMatcher(DictionaryCacheMatcher): """ Cached version of matcher for miniseed station/channel Metadata. Miniseed data require 6 keys to uniquely define a single channel of data (5 at the Seismogram level where the channels are merged). A further complication for using the DictionaryCacheMatcher interface is that part of the definition is a UTC time interval defining when the metadata is valid. We handle that in this implementation by implementing a two stage search algorithm for the find_one method. First, the cache index is defined by a unique string created from the four string keys of miniseed that the MsPASS default schema refers to with the keywords net, sta, chan, and loc. At this point in time we know of no examples of a seismic instrument where the number of distinct time intervals with different Metadata are huge so the secondary search is a simple linear search through the python list return by the generic find method using only the net, sta, chan, and loc keys as the index. The default collection name is channel which is the only correct use if applied to data created through readers applied to wf_miniseed. The implementation can also work on Seismogram data if and only if the channel argument is then set to "site". The difference is that for Seismogram data "chan" is a undefined concept. In both cases the keys and content assume the mspass schema for the channel or site collections. The constructor will throw a MsPASSError exception if the collection argument is anything but "channel" or "site" to enforce this limitation. If you use a schema other than the mspass schema the methods in this class will fail if you change any of the following keys: net, sta, chan, loc, starttime, endtime, hang, vang Users should only call the find_one method for this application. The find_one method here overrides the generic find_one in the superclass DictionaryCacheMatcher. It implements the linear search for a matching time interval test as noted above. Note also this class does not support Ensembles directly. Matching instrument data is by definition what we call atomic. If you are processing ensembles you will need to write a small wrapper function that would run find_one and handle the out looping over each member of the ensemble. :param db: MongoDB database handle containing collection to be loaded. :type db: mspass Database handle(mspasspy.db.database.Database). :param collection: Name of MongoDB collection that is to be queried The default is "channel". Use "site" for Seismogram data. Use anything else at your own risk as the algorithm depends heavily on the mspass schema definition and properties guaranteed by using the converter from obspy Inventory class loaded through web services or stationml files. :type collection: string :param query: optional query to apply to collection before loading data from the database. This parameter is ignored if the input is a DataFrame. A common use would be to reduce the size of the cache by using a time range limit on station metadata to only load records relevant to the dataset being processed. :type query: python dictionary. :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find will fail. Default ["_id","lat","lon","elev","hang","vang"] "hang","vang","starttime","endtime"] when collection is set as channel. Smaller list of ["_id","lat","lon","elev"] is default when collection is set as "site". In either case the list MUST contain "starttime" and "endtime". The reason is the linear search step will always use those two fields in the linear search for a time interval match. Be careful in how endtime is defined that resolves to an epoch time in the distant future and not some null database attribute; a possible scenario with DataFrame input but not a concern if using mspass loaders from StationML data. Note there is also an implicit assumption that the keys "net" and "sta" are always defined. "chan" must also be defined if the collection name is "channel". "loc" is handled as optional for database input but required if the input is via a Dataframe because we use the same cache id generator for all cases. :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default is ["loc"]. A common addition here may be response data (see schema definition for keys) :type load_if_defined: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". :type prepend_collection_name: boolean """ def __init__( self, db, collection="channel", query=None, attributes_to_load=["starttime", "endtime", "lat", "lon", "elev", "_id"], load_if_defined=None, aliases=None, prepend_collection_name=True, ): aload_tmp = attributes_to_load if collection == "channel": # forcing this may be a bit too dogmatic but hang and vang # are pretty essential metadata for any channel if "hang" not in aload_tmp: aload_tmp.append("hang") if "vang" not in aload_tmp: aload_tmp.append("vang") elif collection != "site": raise MsPASSError( "MiniseedMatcher: " + "Illegal collection argument=" + collection + " Must be either channel or site", ErrorSeverity.Fatal, ) if "starttime" not in aload_tmp or "endtime" not in aload_tmp: raise MsPASSError( "MiniseedMatcher: " + "Error in attribute_to_load list - List must contain starttime and endtime keys", ErrorSeverity.Fatal, ) super().__init__( db, collection, query=query, attributes_to_load=aload_tmp, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=True, prepend_collection_name=prepend_collection_name, )
[docs] def cache_id(self, mspass_object) -> str: """ Concrete implementations of this required method. The cache_id in this algorithm is a composite key made from net, sta, chan, and loc with a fixed separator string of "_". A typical example is IU_AAK_BHZ_00_. An added feature to mesh with MsPASS conventions is a safety for attributes that are automatically renamed when saved that are marked readonly in the schema. Such attributes have a prepended tag, (at this time "READONLYERROR_"). If one of the required keys for the index is missing (e.g. "net") the function tries to then fetch the modified name (e.g. "READONLYERROR_net"). If that also fails it returns a None as specified by the API. :param mspass_object: mspass object to be matched with cache. Must contain net, sta fpr site matching and net, sta, and chan for the channel collection. If loc is not defined for any case an emtpy string in defined an the key has two trailing separator characters (e.g. IU_AAK_BHZ__) :return: normal return is a string that can be used as an index string. If any of the required keys is missing it will return a None """ # we make this a const SEPARATOR = "_" net = _get_with_readonly_recovery(mspass_object, "net") if net is None: return None sta = _get_with_readonly_recovery(mspass_object, "sta") if sta is None: return None if self.collection == "channel": chan = _get_with_readonly_recovery(mspass_object, "chan") if chan is None: return None loc = _get_with_readonly_recovery(mspass_object, "loc") if loc == None: loc = "" idstring = net + SEPARATOR + sta + SEPARATOR + loc + SEPARATOR # a bit nonstandard to put chan at end but this isn't for human # consumption anyway if self.collection == "channel": idstring += chan + SEPARATOR return idstring
[docs] def db_make_cache_id(self, doc) -> str: """ Concrete implementations of this required method. The cache_id in this algorithm is a composite key made from net, sta, chan, and loc with a fixed separator string of "_". A typical example is IU_AAK_BHZ_00_. This method creates this string from a MongoDB document assumed passed through a python dictionary as argument doc. Unlike the cache_id method this function does not have a safety for readonly errors. The reason is that it is designed to be used only while loading the cache from site or channel documents. :param doc: python dict containing a site or channel document. Must contain net, sta fpr site matching and net, sta, and chan for the channel collection. If loc is not defined for any case an emtpy string in defined an the key has two trailing separator characters (e.g. IU_AAK_BHZ__) :return: normal return is a string that can be used as an index string. If any of the required keys is missing it will return a None """ # we make this a const SEPARATOR = "_" # superclass must handle None returns for invalid document if "net" in doc: net = doc["net"] else: return None if "sta" in doc: sta = doc["sta"] else: return None if self.collection == "channel": if "chan" in doc: chan = doc["chan"] else: return None if "loc" in doc: loc = doc["loc"] else: loc = "" idstring = net + SEPARATOR + sta + SEPARATOR + loc + SEPARATOR # a bit nonstandard to put chan at end but this isn't for human # consumption anyway if self.collection == "channel": idstring += chan + SEPARATOR return idstring
[docs] def find_one(self, mspass_object): """ We overload find_one to provide the unique match needed. The algorithm does a linear search for the first time interval for which the start time of mspass_object is within the startime <= t0 <= endtime range of a record stored in the cache. This works only if starttime and endtime are defined in the set of attributes loaded so the constructor of this class enforces that restriction. The times in starttime and endtime are assumed to be defined as epoch times as the algorithm uses a simple numeric test of the data start time with the two times. An issue to watch out for is endtime not being set to a valid distant time but some null field that resolves to something that doesn't work in a numerical test for < endtime. :param mspass_object: data to be used for matching against the cache. It must contain the required keys or the matching will fail. If the datum is marked dead the algorithm will return immediately with a None response and an error message that would usually be dropped by the call in that situation. :type mspass_object: must be one of the atomic data types of mspass (currently TimeSeries and Seismogram) with t0 defined as an epoch time computed from a UTC time stamp. """ # Be dogmatic and demand mspass_object is a TimeSeries if isinstance(mspass_object, (TimeSeries, Seismogram)): if mspass_object.live: # trap this unlikely but possible condition as this # condition could produce mysterious behavior if mspass_object.time_is_relative(): elog = PyErrorLogger() message = "Usage error: input has a relative time standard but miniseed matching requires a UTC time stamp" elog.log_error(message, ErrorSeverity.Invalid) return [None, elog] find_output = self.find(mspass_object) if find_output[0] is None: # Current implementation posts a elog message that is # returned in file_output[1]. Could consider adding to # that log message here to clarify it was the miniseed # instance return [None, find_output[1]] else: for md in find_output[0]: stime_key = ( "starttime" if not self.prepend_collection_name else self.collection + "_starttime" ) etime_key = ( "endtime" if not self.prepend_collection_name else self.collection + "_endtime" ) stime = md[stime_key] etime = md[etime_key] t0 = mspass_object.t0 if t0 >= stime and t0 <= etime: return [md, find_output[1]] # we land here if the linear search failed and no # t0 is within the ranges defined if find_output[1] is None: elog = PyErrorLogger() else: elog = find_output[1] message = ( "No matching records found in " + self.collection + " collection for:\n" ) message += "net=" + mspass_object["net"] message += ", sta=" + mspass_object["sta"] message += ", chan=" + mspass_object["chan"] if mspass_object.is_defined("loc"): message += ", loc=" + mspass_object["loc"] message += "time=" + str(UTCDateTime(mspass_object.t0)) message += "\nFound a match for station codes but no channel time range contains that time " elog.log_error(message, ErrorSeverity.Invalid) return [None, elog] else: elog = PyErrorLogger() elog.log_error( "Received a datum marked dead - will not attempt match", ErrorSeverity.Invalid, ) return [None, elog] else: raise TypeError( "MiniseedMatcher.find_one: this class method can only be applied to TimeSeries or Seismogram objects" )
[docs] def find_doc(self, doc, wfdoc_starttime_key="starttime"): """ Optional function to support application to bulk_normalize to set channel_id or site_id. Acts like find_one but without support for readonly recovery. The bigger difference is that this method accepts a python dict retrieved in a cursor loop for bulk_normalize. Returns the Metadata container that is matched from the cache. This uses the same algorithm as the overloaded find_one above where a linear search is used to handle the time interval matching. Here, however, the time field is extracted from doc with the key defined by starttime. :param doc: document (pretty much assumed to be from wf_miniseed) to be matched with channel or site. :type doc: python dictionary - document from MongoDB :param wfdoc_starttime_key: optional parameter to change the key used to fetch the start time of waveform data from doc. Default is "starttime"/ :type wfdoc_starttime_key: string :return: matching Metadata container if successful. None if matching fails for any reason. """ # always abort if the starttime key is not defined. For # current implementation wf_miniseed documents always have that # key defined so throwing an exception marked Fatal is appropriate. if wfdoc_starttime_key not in doc: raise MsPASSError( "MiniseedMatcher: " + "Required key defining waveform start time=" + wfdoc_starttime_key + " is missing from document received", ErrorSeverity.Fatal, ) # we use this version of the method to generate the cache key as # it works with the dict, BUT it doesn't allow the READONLYERROR # recovery testid = self.db_make_cache_id(doc) if testid is None: return None if testid in self.normcache: matches = self.normcache[testid] else: return None # linear search similar to that in find_one above for md in matches: if self.prepend_collection_name: stkey = self.collection + "_" + "starttime" etkey = self.collection + "_" + "endtime" else: stkey = "starttime" etkey = "endtime" # let this throw an exception if fetch fails. Constructor # should guarantee these two attributes are loaded stime = md[stkey] etime = md[etkey] dt0 = doc[wfdoc_starttime_key] if dt0 >= stime and dt0 <= etime: return md return None
[docs]class EqualityMatcher(DataFrameCacheMatcher): """ Match with an equality test for the values of one or more keys with possible aliasing between data keys and database keys. This class can be used for matching a set of keys that together provide a unique matching capability. Note the keys are applied sequentially to reduce the size of internal DataFrame cache in stages. If the DataFrame is large it may improve performance if the most unique key in a series appears first. A special feature of the implementation is that we allow what is best thought of as reverse aliasing for the keys to be used for matching. That is, the base class of this family has an attribute self.aliases that allow mapping from collection names to data object names. The match_keys parameter here is done in the reverse order. That is, the key of the match_keys dictionary is the data object key while the value associated with that key is the DataFrame column name to match. The constructor of the class does a sanity check to verify the two are consistent. The constructor will throw an exception if the two dictionaries are inconstent. Note that means if you use an actual alias through match_keys (i.e. the key and value are different) you must define the aliases dictionary with the same combination reversed. (e.g. matchkeys={"KSTA":"sta"} requires aliases={"sta":"KSTA"}) :param db_or_df: MongoDB database handle or a pandas DataFrame. Most users will use the database handle version. In that case the collection argument is used to determine what collection is loaded into the cache. If using a DataFrame is used the collection name is only a tag defined by the user. For a DataFrame a column index is required that contains at least the attributes defined in attribute_to_load. :type db_or_df: MongoDB database handle or pandas DataFrame. :param collection: When using database input this is expected to be a string defining a valid MongoDB collection with documents that are to be scanned and loaded into the internal cache. With DataFrame input this string is only a tag. It is relevant then only if the prepend_collection_name boolean is set True. There is no default for this parameter so it must be specified as arg 1. :type collection: string :param match_keys: python dict of keys that are to be used for the equality match. The dict is used as an alias mechanism allowing different keys to be used for the Metadata container in data to be tested relative to the keys used in the database for the same attribute. (a typical common example would be something like "source_lat" in the data matching "lat" in the source collection). The key for each entry in this dict is taken as the key for the data side (mspass_object) and the value assigned to that key in this input is taken as the mongoDB/DataFrame key. :type match_keys: python dictionary :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find will fail. There is currently no default for this parameter and it must be defined as arg 3. :type attributes_to_load: list of string defining keys in collection documents :param query: optional query to apply to collection before loading data from the database. This parameter is ignored if the input is a DataFrame. A common use would be to reduce the size of the cache by using a time range limit on station metadata to only load records relevant to the dataset being processed. This parameter is currently ignored for DataFrame input as we assume pandas subsetting would be used for the same functionality in the workflow prior to calling the class constructor for this object. :type query: python dictionary. :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default resolves to an empty list. Note this parameter is ignored for DataFrame input. :type load_if_defined: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. Default is False. :type prepend_collection_name: boolean :param require_unique_match: boolean handling of ambiguous matches. When True find_one will throw an error if an entry is tries to match is not unique. When False find_one returns the first document found and logs a complaint message. (default is True) :type require_unique_match: boolean """ def __init__( self, db_or_df, collection, match_keys, attributes_to_load, query=None, load_if_defined=None, aliases=None, require_unique_match=True, prepend_collection_name=False, custom_null_values=None, ): super().__init__( db_or_df, collection, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=require_unique_match, prepend_collection_name=prepend_collection_name, custom_null_values=custom_null_values, ) if isinstance(match_keys, dict): self.match_keys = match_keys for key in match_keys: testkey = match_keys[key] # this means no aliasing for key if testkey == key: continue if testkey in self.aliases: backtestkey = self.aliases[testkey] if backtestkey != key: error_message = ( "EqualityMatcher constructor: " + "match_keys and aliases are inconsistent.\n" + "match_keys=" + str(match_keys) + " aliases=" + str(self.aliases) ) raise MsPASSError(error_message, ErrorSeverity.Fatal) else: error_message = ( "EqualityMatcher constructor: " + "match_keys and aliases are inconsistent.\n" + "match_key defines key=" + key + " to have alias name=" + testkey + " but alias name is not defined by aliases parameter" ) raise MsPASSError(error_message, ErrorSeverity.Fatal) else: raise TypeError( "EqualityMatcher Constructor: required argument 2 (matchkeys) must be a python dictionary" )
[docs] def subset(self, mspass_object) -> pd.DataFrame: """ Concrete implementation of this virtual method of DataFrameMatcher for this class. The subset is done sequentially driven by the order key order of the self.match_keys dictionary. i.e. the algorithm uses the row reduction operation of a dataframe one key at a time. An implementation detail is that there may be a more clever way instead create a single conditional clause to pass to the DataFrame operator [] combining the key matches with "and". That would likely improve performance, particulary on large tables. Note the alias is applied using the self.match_keys. i.e. one can have different keys on the left (mspass_data side is the match_keys dictionary key) than the right (dataframe column name). :param mspass_object: Any valid mspass data object with a Metadata container. The container must contain all the required match keys or the function will return an error condition (see below) :type mspass_object: TimeSeries, Seismogram, TimeSeriesEnsemble or SeismogramEnsemble object :return: DataFrame containing all data satisying the match series of match conditions defined on construction. Silently returns a zero length DataFrame if is no match. Be warned two other situations can cause the return to have no data: (1) dead input, and (2) match keys missing from mspass_object. """ if mspass_object.dead(): return pd.DateFrame() # I don't think this can cause a memory problem as in python # this make dfret a temporary alias for self.cache # In the loop it is replaced by subset dataframes dfret = self.cache for key in self.match_keys.keys(): if mspass_object.is_defined(key): testval = mspass_object[key] # this allows an alias between data and dataframe keys dfret = dfret[dfret[self.match_keys[key]] == testval] else: return pd.DataFrame() return dfret
[docs]class EqualityDBMatcher(DatabaseMatcher): """ Database equivalent of EqualityMatcher. param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be queried. This arg is required by the constructor and has not default. :type collection: string :param match_keys: python dict of keys that are to be used for the equality match. The dict is used as an alias mechanism allowing different keys to be used for the Metadata container in data to be tested relative to the keys used in the database for the same attribute. (a typical common example would be something like "source_lat" in the data matching "lat" in the source collection). The key for each entry in this dict is taken as the key for the data side (mspass_object) and the value assigned to that key in this input is taken as the mongoDB/DataFrame key. :type match_keys: python dictionary :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find will fail. There is no default for this class and the list must be defined as arg3. :type attributes_to_load: list of string defining keys in collection documents. :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default is to add load no optional data. :param type: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". Default is False. :type prepend_collection_name: boolean :param require_unique_match: boolean handling of ambiguous matches. When True find_one will throw an error if an entry is tries to match is not unique. When False find_one returns the first document found and logs a complaint message. (default is False) :type require_unique_match: boolean """ def __init__( self, db, collection, match_keys, attributes_to_load, load_if_defined=None, aliases=None, require_unique_match=False, prepend_collection_name=False, ): super().__init__( db, collection, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=require_unique_match, prepend_collection_name=prepend_collection_name, ) if isinstance(match_keys, dict): self.match_keys = match_keys else: raise TypeError( "{} constructor: required arg2 must be a python dictionary - received invalid type. See docstring".format( self.__class__.__name__ ) )
[docs] def query_generator(self, mspass_object) -> dict: """ Implementation of required method for this class. It simply applies an equality test for all the keys defined by the values in the self.match_keys dict. """ query = dict() for dkey in self.match_keys: if dkey in mspass_object: value = mspass_object[dkey] query[self.match_keys[dkey]] = value else: # API says return none if generator fails return None return query
[docs]class OriginTimeDBMatcher(DatabaseMatcher): """ Generic class to match data by comparing a time defined in data to an origin time using a database query algorithm. The default behavior of this matcher class is to match data to source documents based on origin time with an optional time offset. Conceptually the data model for this matching is identical to conventional multichannel shot gathers where the start time is usually the origin time. It is also a common model for downloaded source oriented waveform segments from FDSN web services with obspy. Obspy has an example in their documentation for how to download data defined exactly this way. In that mode we match each source document that matches a projected origin time within a specified tolerance. Specifically, let t0 be the start time extracted from the data. We then compute the projected, test origin time as test_otime = t0 - t0offset. Note the sign convention that a positive offset means the time t0 is after the event origin time. We then select all source records for which the time field satisifies: source.time - tolerance <= test_time <= source.time + tolerance The test_time value for matching from a datum can come through one of two methods driven by the constructor argument "time_key". When time_key is a None (default) the algorithm assumes all input are mspass atomic data objects that have the start time defined by the attribute "t0" (mspass_object.t0). If time_key is a string it is assumed to be a Metadata key used to fetch an epoch time to use for the test. The most likely use of that feature would be for ensemble processing where test_time is set as a field in the ensemble Metadata. Note that form of associating source data to ensembles that are common source gathers can be much faster than the atomic version because only one query is needed per ensemble. :param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be queried (default "source"). :type collection: string :param t0offset: constant offset from data start time that is expected as origin time. A positive t0offset means the origin time is before the data start time. Units are always assumed to be seconds. :type t0offset: float :param tolerance: time tolerance to test for match of origin time. (see formula above for exact use) If the source estimates are exactly the same as the ones used to define data start time this number can be a few samples. Otherwise a few seconds is safter for teleseismic data and less for local/regional events. i.e. the choice depends up on how the source estimates relate to the data. :type tolerance: float :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find_one will fail. Default is ["lat","lon","depth","time"] :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default is ["magnitude"] :param type: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". :type prepend_collection_name: boolean :param require_unique_match: boolean handling of ambiguous matches. When True find_one will throw an error if an entry is tries to match is not unique. When False find_one returns the first document found and logs a complaint message. (default is False) :type require_unique_match: boolean """ def __init__( self, db, collection="source", t0offset=0.0, tolerance=4.0, query=None, attributes_to_load=["_id", "lat", "lon", "depth", "time"], load_if_defined=["magnitude"], aliases=None, require_unique_match=False, prepend_collection_name=True, data_time_key=None, source_time_key=None, ): super().__init__( db, collection, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=require_unique_match, prepend_collection_name=prepend_collection_name, ) self.t0offset = t0offset self.tolerance = tolerance self.data_time_key = data_time_key self.source_time_key = source_time_key if query is None: query = {} if isinstance(query, dict): self.query = query else: raise TypeError( "{} constructor: query argument must define a python dictionary or a None: received invalid type. See docstring".format( self.__class__.__name__ ) )
[docs] def query_generator(self, mspass_object) -> dict: """ Concrete implementation of this required method for a subclass of DatabaseMatcher. This algorithm implements the time test described in detail in docstring for this class. Note the fundamental change in how the test time is computed that depends on the internal (self) attribute time_key. When None we use the data's t0 attribute. Otherwise self.time_key is assumed to be a string key to fetch the test time from the object's Metadata container. :param mspass_object: MsPASS defined data object that contains data to be used for this match (t0 attribute or content of self.time_key). :type mspass_object: Any valid MsPASS data object. :return: query python dictionary on sucess. Return None if a query could not be constructed. That happens two ways here. (1) If the input is not a valid mspass data object or marked dead. (2) if the time_key algorithm is used and time_key isn't defined in the input datum. """ # This could generate mysterious results if a user messes up # badly, but it makes the code more stable - otherwise # a parallel job could, for example, abort if one of the # components in a bag/rdd got set to None if not _input_is_valid(mspass_object): return None if mspass_object.dead(): return None if self.data_time_key is None: # this maybe should have a test to assure UTC time standard # but will defer for now test_time = mspass_object.t0 else: if mspass_object.is_defined(self.data_time_key): test_time = mspass_object[self.data_time_key] else: return None test_time -= self.t0offset # depends upon self.query being initialized by constructor # as python dictionary query = copy.deepcopy(self.query) query["time"] = { "$gte": test_time - self.tolerance, "$lte": test_time + self.tolerance, } return query
[docs]class OriginTimeMatcher(DataFrameCacheMatcher): """ Generic class to match data by comparing a time defined in data to an origin time using a cached DataFrame. The default behavior of this matcher class is to match data to source documents based on origin time with an optional time offset. Conceptually the data model for this matching is identical to conventional multichannel shot gathers where the start time is usually the origin time. It is also a common model for downloaded source oriented waveform segments from FDSN web services with obspy. Obspy has an example in their documentation for how to download data defined exactly this way. In that mode we match each source document that matches a projected origin time within a specified tolerance. Specifically, let t0 be the start time extracted from the data. We then compute the projected, test origin time as test_otime = t0 - t0offset. Note the sign convention that a positive offset means the time t0 is after the event origin time. We then select all source records for which the time field satisifies: source.time - tolerance <= test_time <= source.time + tolerance The test_time value for matching from a datum can come through one of two methods driven by the constructor argument "time_key". When time_key is a None (default) the algorithm assumes all input are mspass atomic data objects that have the start time defined by the attribute "t0" (mspass_object.t0). If time_key is a string it is assumed to be a Metadata key used to fetch an epoch time to use for the test. The most likely use of that feature would be for ensemble processing where test_time is set as a field in the ensemble Metadata. Note that form of associating source data to ensembles that are common source gathers can be much faster than the atomic version because only one query is needed per ensemble. This implentation should be used only if the catalog of events is reasonably small. If the catalog is huge the database version may be more appropriate. :param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be queried (default "source"). :type collection: string :param t0offset: constant offset from data start time that is expected as origin time. A positive t0offset means the origin time is before the data start time. Units are always assumed to be seconds. :type t0offset: float :param tolerance: time tolerance to test for match of origin time. (see formula above for exact use) If the source estimates are exactly the same as the ones used to define data start time this number can be a few samples. Otherwise a few seconds is safter for teleseismic data and less for local/regional events. i.e. the choice depends up on how the source estimates relate to the data. :type tolerance: float :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find_one will fail. Default is ["_id","lat","lon","depth","time"]. Note if constructing from a DataFrame created from something like a Datascope origin table this list will need to be changed to remove _id as it in that context no ObjectID would normally be defined. Be warned, however, that if used with a normalize function the _id may be required to match a "source_id" cross reference in a seismic data object. :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default is ["magnitude"] :param type: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". :type prepend_collection_name: boolean :param require_unique_match: boolean handling of ambiguous matches. When True find_one will throw an error if an entry is tries to match is not unique. When False find_one returns the first document found and logs a complaint message. (default is False) :type require_unique_match: boolean :param data_time_key: data object Metadata key used to fetch time for testing as alternative to data start time. If set None (default) the test will use the start time of an atomic data object for the time test. If nonzero it is assumed to be a string used to fetch a time from the data's Metadata container. That is the best way to run this matcher on Ensembles. :type data_time_key: string :param source_time_key: dataframe column name to use as source origin time field. Default is "time" :type source_time_key: string Can also be a None type which is causes the internal value to be set to "time" """ def __init__( self, db_or_df, collection="source", t0offset=0.0, tolerance=4.0, attributes_to_load=["_id", "lat", "lon", "depth", "time"], load_if_defined=["magnitude"], aliases=None, require_unique_match=False, prepend_collection_name=True, data_time_key=None, source_time_key="time", custom_null_values=None, ): super().__init__( db_or_df, collection, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=require_unique_match, prepend_collection_name=prepend_collection_name, custom_null_values=custom_null_values, ) self.t0offset = t0offset self.tolerance = tolerance self.data_time_key = data_time_key if source_time_key is None: self.source_time_key = "time" else: self.source_time_key = source_time_key
[docs] def subset(self, mspass_object) -> pd.DataFrame: """ """ if not _input_is_valid(mspass_object): return pd.DataFrame() if mspass_object.dead(): return pd.DataFrame() if self.data_time_key is None: # this maybe should have a test to assure UTC time standard # but will defer for now test_time = mspass_object.t0 else: if mspass_object.is_defined(self.data_time_key): test_time = mspass_object[self.data_time_key] else: return pd.DataFrame() test_time -= self.t0offset tmin = test_time - self.tolerance tmax = test_time + self.tolerance # For this matcher we dogmatically use <= equivalent in the between # construct here - inclusive=True. In this context seems appropriate inclusive = '"both"' dfquery = ( self.source_time_key + ".between({tmin},{tmax},inclusive={inclusive})".format( tmin=tmin, tmax=tmax, inclusive=inclusive ) ) dfret = self.cache.query(dfquery) return dfret
[docs]class ArrivalDBMatcher(DatabaseMatcher): """ This is a class for matching a table of arrival times to input waveform data objects. Use this version if the table of arrivals is huge and database query delays will not create a bottleneck in your workflow. Phase arrival time matching is a common need when waveform segments are downloaded. When data are assembled as miniseed files or url downloads of miniseed data, the format has no way to hold arrival time data. This matcher can prove useful for matching waveform segments with an origin as miniseed. The algorithm it uses for matching is a logic and of two tests: 1. We first match all arrival times falling between the sample range of an input MsPASS data object, d. That is, first component of the query is to find all arrival times, t_a, that obey the relation: d.t0 <= t_a <= d.endtime(). 2. Match only data for which the (fixed) name "sta" in arrival and the data match. A secondary key match using the "net" attribute is used only if "net" is defined with the data. That is done to streamline processing of css3.0 data where "net" is not defined. Note the concept of an arrival time is also mixed as in some contexts it means a time computed from an earth model and other time a measured time that is "picked" by a human or computer algorithm. This class does not distinguish model-based from measured times. It simply uses the time and station tag information with the algorithm noted above to attempt a match. :param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be queried (default "arrival", which is not currently part of the stock mspass schema. Note it isn't required to be in the schema and illustrates flexibility'). :type collection: string :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find_one will fail. Default ["phase","time"]. :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default is None :param type: list of strings defining collection keys :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". :type prepend_collection_name: boolean :param require_unique_match: boolean handling of ambiguous matches. When True find_one will throw an error if an entry is tries to match is not unique. When False find_one returns the first document found and logs a complaint message. (default is False) :type require_unique_match: boolean :param query: optional query predicate. That is, if set the interval query is appended to this query to build a more specific query. An example might be station code keys to match a specific pick for a specific station like {"sta":"AAK"}. Another would be to limit arrivals to a specific phase name like {"phase" : "ScS"}. Default is None which reverts to no query predicate. :type query: python dictionary or None. None is equivalewnt to passing an empty dictionary. A TypeError will be thrown if this argument is not None or a dict. """ def __init__( self, db, collection="arrival", attributes_to_load=["phase", "time"], load_if_defined=None, aliases=None, require_unique_match=False, prepend_collection_name=True, query=None, ): super().__init__( db, collection, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=require_unique_match, prepend_collection_name=prepend_collection_name, ) if query is None: self.query = dict() elif isinstance(query, dict): self.query = query else: raise TypeError( "ArrivalDBMatcher constructor: query arg must define a python dictionary" )
[docs] def query_generator(self, mspass_object) -> dict: """ Concrete implementation of method required by superclass DatabaseMatcher. This generator implements the switching algorithm noted in the class docstring. That is, for atomic data the time span for the interval query is determined from the range of the waveform data received through mspass_object. For ensembles the algorithm fetches fields defined by self.startime_key and self.endtime_key to define the time interval. The interval test is overlaid on the self.query input. i.e. the query dict components derived are added to the self.query. """ if _input_is_atomic(mspass_object): if mspass_object.live: query = copy.deepcopy(self.query) stime = mspass_object.t0 etime = mspass_object.endtime() query["time"] = {"$gte": stime, "$lte": etime} # these names are frozen sta = _get_with_readonly_recovery(mspass_object, "sta") net = _get_with_readonly_recovery(mspass_object, "net") if net is not None: query["net"] = net if sta is not None: query["sta"] = sta # intentionally ignore loc as option return query else: return None
[docs]class ArrivalMatcher(DataFrameCacheMatcher): """ This is a class for matching a table of arrival times to input waveform data objects. Use this version if the table of arrivals is not huge enough to cause a memory problem. Phase arrival time matching is a common need when waveform segments are downloaded. When data are assembled as miniseed files or url downloads of miniseed data, the format has no way to hold arrival time data. This matcher can prove useful for matching waveform segments with an origin as miniseed. The algorithm it uses for matching is a logic and of two tests: 1. We first match all arrival times falling between the sample range of an input MsPASS data object, d. That is, first component of the query is to find all arrival times, t_a, that obey the relation: d.t0 <= t_a <= d.endtime(). 2. Match only data for which the (fixed) name "sta" in arrival and the data match. A secondary key match using the "net" attribute is used only if "net" is defined with the data. That is done to streamline processing of css3.0 data where "net" is not defined. Note the concept of an arrival time is also mixed as in some contexts it means a time computed from an earth model and other time a measured time that is "picked" by a human or computer algorithm. This class does not distinguish model-based from measured times. It simply uses the time and station tag information with the algorithm noted above to attempt a match. This implementation caches the table of attributes desired to an internal pandas DataFrame. It is thus most appropriate for arrival tables that are not huge. Note it may be possible to do appropriate preprocessing to manage the arrival table size. e.g. the table can be grouped by station or in time blocks and then processed in a loop updating waveform database records in multiple passes. The alternative for large arrival tables is to use the DB version of this matcher. :param db: MongoDB database handle (positional - no default) :type db: normally a MsPASS Database class but with this algorithm it can be the superclass from which Database is derived. :param collection: Name of MongoDB collection that is to be queried (default "arrival", which is not currently part of the stock mspass schema. Note it isn't required to be in the schema and illustrates flexibility'). :type collection: string :param attributes_to_load: list of keys of required attributes that will be returned in the output of the find method. The keys listed must ALL have defined values for all documents in the collection or some calls to find_one will fail. Default ["phase","time"]. :type attributes_to_load: list of string defining keys in collection documents :param load_if_defined: list of keys of optional attributes to be extracted by find method. Any data attached to these keys will only be posted in the find return if they are defined in the database document retrieved in the query. Default is None :param type: list of strings defining collection keyes :param aliases: python dictionary defining alias names to apply when fetching from a data object's Metadata container. The key sense of the mapping is important to keep straight. The key of this dictionary should match one of the attributes in attributes_to_load or load_if_defined. The value the key defines should be the alias used to fetch the comparable attribute from the data. :type aliaes: python dictionary :param prepend_collection_name: when True attributes returned in Metadata containers by the find and find_one method will all have the collection name prepended with a (fixed) separator. For example, if the collection name is "channel" the "lat" attribute in the channel document would be returned as "channel_lat". :type prepend_collection_name: boolean :param require_unique_match: boolean handling of ambiguous matches. When True find_one will throw an error if an entry is tries to match is not unique. When False find_one returns the first document found and logs a complaint message. (default is False) :type require_unique_match: boolean :param ensemble_starttime_key: defines the key used to fetch a start time for the interval test when processing with ensemble data. Default is "starttime". :type ensemble_starttime_key: string :param ensemble_endtime_key: defines the key used to fetch a end time for the interval test when processing with ensemble data. Default is "endtime". :type ensemble_endtime_key: string :param query: optional query predicate. That is, if set the interval query is appended to this query to build a more specific query. An example might be station code keys to match a specific pick for a specific station like {"sta":"AAK"}. Default is None. :type query: python dictionary or None. None is equivalewnt to passing an empty dictionary. A TypeError will be thrown if this argument is not None or a dict. """ def __init__( self, db_or_df, collection="arrival", attributes_to_load=["phase", "time"], load_if_defined=None, aliases=None, require_unique_match=False, prepend_collection_name=True, ensemble_starttime_key="starttime", ensemble_endtime_key="endtime", arrival_time_key=None, custom_null_values=None, ): super().__init__( db_or_df, collection, attributes_to_load=attributes_to_load, load_if_defined=load_if_defined, aliases=aliases, require_unique_match=require_unique_match, prepend_collection_name=prepend_collection_name, custom_null_values=custom_null_values, ) # maybe a bit confusing to shorten the names here but the # argument names are a bit much self.starttime_key = ensemble_starttime_key self.endtime_key = ensemble_endtime_key if arrival_time_key is None: self.arrival_time_key = collection + "_time" elif isinstance(arrival_time_key, str): self.arrival_time_key = arrival_time_key else: raise TypeError( "ArrivalDBMatcher constructor: arrival_time_key argument must define a string" )
[docs] def subset(self, mspass_object) -> pd.DataFrame: """ Concrete implementation of method required by superclass DataFramematcher """ if _input_is_valid(mspass_object): if mspass_object.live: if _input_is_atomic(mspass_object): stime = mspass_object.t0 etime = mspass_object.endtime() else: if mspass_object.is_defined( self.starttime_key ) and mspass_object.is_defined(self.endtimekey): stime = mspass_object[self.starttime_key] etime = mspass_object[self.endtime_key] else: return pd.DataFrame() sta = _get_with_readonly_recovery(mspass_object, "sta") if sta is not None: dfret = self.cache[ ("sta" == sta) & (self.arrival_time_key >= stime) & (self.arrival_time_key <= etime) ] if len(dfret) > 1: net = _get_with_readonly_recovery(mspass_object, "net") if net is not None: dfret = dfret["net" == net] return dfret else: return pd.DataFrame() else: return pd.DataFrame()
[docs]@mspass_func_wrapper def normalize(mspass_object, matcher, kill_on_failure=True): """ Generic function to do in line normalization with dask/spark map operator. In MsPASS we use the normalized data model for receiver and source metadata. The normalization can be done during any reads if the data have cross-referencing ids defined as described in the User's Manual. This function provides a generic interface to link to a normalizing collection within a workflow using a map operator applied to a dask bag or spark rdd containing a dataset of MsPASS data objects. The algorithm is made generic through the matcher argument that must point a concrete implementation of the abstract base class defined in this module as BasicMatcher. For example, suppose we create a concrete implementation of the MiniseedMatcher using all defaults from a database handle db as follows: matcher = MiniseedMatcher() Suppose we then load data from wf_miniseed with read_distributed_data into the dask bag we will call dataset. We can normalize that data within a workflow as follows: dataset = dataset.map(normalize,matcher) :param mspass_object: data to be normalized :type mspass_object: For all mspass matchers this must be one of the mspass data types of TimeSeries, Seismogram, TimeSeriesEnsemble, or SeismogramEnsemble. Many matchers have further restrictions. e.g. the normal use of the MiniseedMatcher using the defaults like the example insists the data received are either TimeSeries or Seismogram objects. Read the docstring carefully for your matcher choice for any limitations. :param matcher: a generic matching function that is a subclass of BasicMatcher. This function only calls the find_one method. :type matcher: must be a concrete subclass of BasicMatcher :param kill_on_failure: when True if the call to the find_one method of matcher fails the datum returned will be marked dead. :type kill_on_failure: boolean :return: copy of mspass_object. dead data are returned immediately. if kill_on_failure is true the result may be killed on return. """ if mspass_object.dead(): return mspass_object find_output = matcher.find_one(mspass_object) # api of BasicMatcher specified a pair return we handle here if find_output[0] is None: mspass_object.kill() else: # this could be done with operator+= in C++ with appropriate # casting but I think this is the only solution here # we are just copying the return Metadata contents to the data for k in find_output[0]: mspass_object[k] = find_output[0][k] # append any error log returns to the data elog # operator += is bound to python by pybind11 so this works if find_output[1] is not None: mspass_object.elog += find_output[1] return mspass_object
[docs]def bulk_normalize( db, wfquery=None, wf_col="wf_miniseed", blocksize=1000, matcher_list=None, ): """ This function iterates through the collection specified by db and wf_col, and runs a chain of normalization funtions in serial on each document defined by the cursor returned by wfquery. It speeds updates by using the bulk methods of MongoDB. The chain also speeds updates as the all matchers in matcher_list append to the update string for the same wf_col document. A typical example would be to run this function on wf_miniseed data running a matcher to set channel_id, site_id, and source_id. :param db: should be a MsPASS database handle containing the wf_col and the collections defined by the matcher_list list. :param wf_col: The collection that need to be normalized, default is wf_miniseed :param blockssize: To speed up updates this function uses the bulk writer/updater methods of MongoDB that can be orders of magnitude faster than one-at-a-time updates. A user should not normally need to alter this parameter. :param wfquery: is an optional query to apply to wf_col. The output of this query defines the list of documents that the algorithm will attempt to normalize as described above. The default (None) will process the entire collection (query set to an emtpy dict). :param matcher_list: a list of instances of one or more subclasses of BasicMather. In addition to the required classes all instances passed to through this interface must contain two required attributes: (1) collection which defines the collection name, and (2) prepend_collection_name is a boolean that determines if the attributes loaded should have the collection name prepended (e.g. channel_id). In addition, all instances must define the find_doc method which is not required by the BasicMatcher interface. (find_doc is comparable to find_one but uses a python dictionary as the container instead of referencing a mspass data object. find_one is the core method for inline normalization) :return: a list with a length of len(matcher_list)+1. 0 is the number of documents processed in the collection (output of query), The rest are the numbers of success normalizations for the corresponding NMF instances, they are mapped one on one (matcher_list[x] -> ret[x+1]). """ if wfquery is None: wfquery = {} if matcher_list is None: # The default value for matcher_list is for wf_miniseed with channel # Assume the defaults are sufficient but we limit the required # attribute list to save memory channel_matcher = MiniseedMatcher( db, attributes_to_load=["starttime", "endtime", "_id"] ) matcher_list = [channel_matcher] for matcher in matcher_list: if not isinstance(matcher, BasicMatcher): raise MsPASSError( "bulk_normalize: the component in the matcher list={} is not a subclass of BasicMatcher".format( str(matcher) ), ErrorSeverity.Fatal, ) # check that matcher has the find_doc method implemented - it is # not defined in the BasicMatcher interface and is required if not ( hasattr(matcher, "find_doc") and callable(getattr(matcher, "find_doc")) ): message = "matcher_list contains class={classname} that does not have an implementation of the find_doc method required by this function - try using it in a map operator".format( classname=type(matcher).__name__ ) raise MsPASSError("bulk_normalize: " + message, ErrorSeverity.Fatal) # these two attributes are also required and best checked here # for a minor cost if not hasattr(matcher, "prepend_collection_name"): message = "matcher list class={classname} does not define required attribute prepend_collection_name - trying using it in a map operator".format( classname=type(matcher).__name__ ) raise MsPASSError("bulk_normalize: " + message, ErrorSeverity.Fatal) if not hasattr(matcher, "collection"): message = "matcher list class={classname} does not define required attribute collection - trying using it in a map operator".format( classname=type(matcher).__name__ ) raise MsPASSError("bulk_normalize: " + message, ErrorSeverity.Fatal) ndocs = db[wf_col].count_documents(wfquery) if ndocs == 0: raise MsPASSError( "bulk_normalize: " + "query={wfquery} of collection={wf_col} yielded 0 documents\nNothing to process".format( wfquery=wfquery, wf_col=wf_col ), ErrorSeverity.Fatal, ) # this incantation initializes cnt_list as a list with number of # components set as the size of matcher_list and initialized to 0 cnt_list = [0] * len(matcher_list) counter = 0 cursor = db[wf_col].find(wfquery) bulk = [] for doc in cursor: wf_id = doc["_id"] need_update = False update_doc = {} for ind, matcher in enumerate(matcher_list): norm_doc = matcher.find_doc(doc) if norm_doc is None: # not this silently ignores failures # may want this to count failures for each matcher continue for key in matcher.attributes_to_load: new_key = key if matcher.prepend_collection_name: if key == "_id": new_key = matcher.collection + key else: new_key = matcher.collection + "_" + key update_doc[new_key] = norm_doc[new_key] cnt_list[ind] += 1 need_update = True if need_update: bulk.append(pymongo.UpdateOne({"_id": wf_id}, {"$set": update_doc})) counter += 1 # Tests for counter and len(bulk) are needed because the logic here # allows this block to be entered the first pass and if the pass # after the previous call to bulk_write did not yield a match # either will cause bulk_write to throw an error when it gets an # an empty list. Should consider a logic change here # to make this less obscure if counter % blocksize == 0 and counter != 0 and len(bulk) > 0: db[wf_col].bulk_write(bulk) bulk = [] if counter % blocksize != 0: db[wf_col].bulk_write(bulk) return [ndocs] + cnt_list
[docs]def normalize_mseed( db, wfquery=None, blocksize=1000, normalize_channel=True, normalize_site=True, ): """ In MsPASS the standard support for station information is stored in two collections called "channel" and "site". When normalized with channel collection data a miniseed record can be associated with station metadata downloaded by FDSN web services and stored previously with MsPASS database methods. The default behavior tries to associate each wf_miniseed document with an entry in "site". In MsPASS site is a smaller collection intended for use only with data already assembled into three component bundles we call Seismogram objects. For both channel and site the association algorithm used assumes the SEED convention wherein the strings stored with the keys "net","sta","chan", and (optionally) "loc" define a unique channel of data registered globally through the FDSN. The algorithm then need only query for a match of these keys and a time interval match with the start time of the waveform defined by each wf_miniseed document. The only distinction in the algorithm between site and channel is that "chan" is not used in site since by definition site data refer to common attributes of one seismic observatory (commonly also called a "station"). :param db: should be a MsPASS database handle containing at least wf_miniseed and the collections defined by the norm_collection list. :param blockssize: To speed up updates this function uses the bulk writer/updater methods of MongoDB that can be orders of magnitude faster than one-at-a-time updates for setting channel_id and site_id. A user should not normally need to alter this parameter. :param wfquery: is a query to apply to wf_miniseed. The output of this query defines the list of documents that the algorithm will attempt to normalize as described above. The default will process the entire wf_miniseed collection (query set to an emtpy dict). :param normalize_channel: boolean for handling channel collection. When True (default) matches will be attempted with the channel collection and when matches are found the associated channel document id will be set in the associated wf_miniseed document as channel_id. :param normalize_site: boolean for handling site collection. When True (default) matches will be attempted with the site collection and when matches are found the associated site document id will be set wf_miniseed document as site_id. Note at least one of the two booleans normalize_channel and normalize_site must be set True or the function will immediately abort. :return: list with three integers. 0 is the number of documents processed in wf_miniseed (output of query), 1 is the number with channel ids set, and 2 contains the number of site documents set. 1 or 2 should contain 0 if normalization for that collection was set false. """ if wfquery is None: wfquery = {} if not (normalize_channel or normalize_site): raise MsPASSError( "normalize_mseed: usage error. normalize_channel and normalize_site cannot both be set False", ErrorSeverity.Fatal, ) matcher_function_list = [] # in the calls to the constructors below starttime and endtime # must be included for the miniseed matcher to work if normalize_channel: matcher = MiniseedMatcher( db, collection="channel", attributes_to_load=["_id", "starttime", "endtime"], prepend_collection_name=True, ) matcher_function_list.append(matcher) if normalize_site: sitematcher = MiniseedMatcher( db, collection="site", attributes_to_load=["_id", "starttime", "endtime"], prepend_collection_name=True, ) matcher_function_list.append(sitematcher) bulk_ret = bulk_normalize( db, wfquery=wfquery, wf_col="wf_miniseed", blocksize=blocksize, matcher_list=matcher_function_list, ) ret = [bulk_ret[0]] if normalize_channel: ret.append(bulk_ret[1]) if normalize_site: ret.append(bulk_ret[2]) else: ret.append(0) else: ret.append(0) if normalize_site: ret.append(bulk_ret[1]) else: ret.append(0) return ret
def _get_test_time(d, time): """ A helper function to get the test time used for searching. If the time is given, we simply use that as the test time. Otherwise (the time is None), we first try to get the start time from d. If start time is not defined in d, None is return to indicate the time field should be ignored. :param d: Data object with a Metadata container to extract the field :param time: the start time used for matching :return: the test_time extracted """ if time == None: if isinstance(d, (TimeSeries, Seismogram)): test_time = d.t0 else: if d.is_defined("starttime"): test_time = d["starttime"] else: # Use None for test_time as a signal to ignore time field test_time = None else: test_time = time return test_time def _get_with_readonly_recovery(d, key): """ Private method for repetitious handling of trying to use the readonly tag to recover if one of net, sta, chan, or loc have been botched with readonly tag. d is the datum from with key is to be extracted. Returns a None if recovery failed. """ ROTAG = "READONLY_" if d.is_defined(key): return d[key] else: testkey = ROTAG + key if d.is_defined(testkey): return d[testkey] else: return None def _input_is_valid(d): """ This internal function standardizes the test to certify the input datum, d, is or is not a valid MsPASS data object. Putting it in one place makes extending the code base for other data types much easier. It uses an isinstance tests of d to standardize the test that the input is valid data. It returns True if d is one a valid data object known to mspass. Returns false it not. Caller must decide what to do if the function returns false. """ return isinstance( d, (TimeSeries, Seismogram, TimeSeriesEnsemble, SeismogramEnsemble) ) def _input_is_atomic(d): """ A variant of input_is_valid is to test if a data object is atomic by the mspass definition. In this case, that means a datum is not an ensemble. This is necessary, for example, to assume something like asking for d.t0 doesn't generate an exception. """ return isinstance(d, (TimeSeries, Seismogram)) def _load_as_df(db, collection, query, attributes_to_load, load_if_defined): """ Internal helper function used to translate all or part of a collection to a DataFrame. This algorithm should only be used for small collections as it makes an intermediate copy of the collection as a dictionary before calling the DataFrame.from_dict method to create the working dataframe. :param db: Database handle assumed to contain collection :param collection: collection from which the data are to be extracted :param query: python dict defining a query to apply to the collection. If you want the entire collection specify None or an empty dictionary. :type query: python dict defining a pymongo query or None. :param attributes_to_load: list of keys to extract of required attributes to load from collection. This function will abort if any document does not contain one of these attributes. :param load_if_defined: attributes loaded more cautiously. If the attributes for any of the keys in this list are not found in a document the output dataframe has a Null defined for that cell. """ if query is None: query = dict() ntuples = db[collection].count_documents(query) if ntuples == 0: return pd.DataFrame() # create dictionary with empty array values to initialize dict_tmp = dict() for k in attributes_to_load: dict_tmp[k] = [] for k in load_if_defined: dict_tmp[k] = [] cursor = db[collection].find(query) for doc in cursor: # attributes_to_load list are required. For now let this # thow an exception if that is not true - may need a handler for k in attributes_to_load: dict_tmp[k].append(doc[k]) for k in load_if_defined: if k in doc: dict_tmp[k].append(doc[k]) else: dict_tmp[k].append(None) return pd.DataFrame.from_dict(dict_tmp) def _extractData2Metadata( doc, attributes_to_load, aliases, prepend_collection_name, collection, load_if_defined, ): md = Metadata() for k in attributes_to_load: if k in doc: if k in aliases: key = aliases[k] else: key = k if prepend_collection_name: if key == "_id": mdkey = collection + key else: mdkey = collection + "_" + key else: mdkey = key md[mdkey] = doc[key] else: message = "Required attribute {key} was not found".format( key=k, ) raise MsPASSError( "DictionaryCacheMatcher._load_normalization_cache: " + message, ErrorSeverity.Fatal, ) for k in load_if_defined: if k in doc: if k in aliases: key = aliases[k] else: key = k if prepend_collection_name: if key == "_id": mdkey = collection + key else: mdkey = collection + "_" + key else: mdkey = key md[mdkey] = doc[key] return md