Source code for pyrocko.client.globalcmt

# http://pyrocko.org - GPLv3
#
# The Pyrocko Developers, 21st Century
# ---|P------/S----------~Lg----------

'''
Client to get earthquake catalog information from
`GlobalCMT <http://www.globalcmt.org/>`_.
'''

import os
import math
import time
import calendar
import re
import logging
import email.utils

from pyrocko import model, util, config
from pyrocko.moment_tensor import MomentTensor, moment_to_magnitude
from pyrocko.util import Request, urlopen
from .base_catalog import EarthquakeCatalog, NotFound


import numpy as num


logger = logging.getLogger('pyrocko.client.globalcmt')

km = 1000.


class Anon(object):
    pass


[docs] class GlobalCMT(EarthquakeCatalog): ''' Client to get earthquake catalog information from `GlobalCMT <http://www.globalcmt.org/>`_. ''' def __init__(self): self.events = {} def flush(self): self.events = {} def iter_event_names( self, time_range=None, magmin=0., magmax=10., latmin=-90., latmax=90., lonmin=-180., lonmax=180., depthmin=0., depthmax=1000*km): yearbeg, monbeg, daybeg = time.gmtime(time_range[0])[:3] yearend, monend, dayend = time.gmtime(time_range[1])[:3] url = 'http://www.globalcmt.org/cgi-bin/globalcmt-cgi-bin/CMT5/form?' \ + '&'.join([ 'itype=ymd', 'yr=%i' % yearbeg, 'mo=%i' % monbeg, 'day=%i' % daybeg, 'otype=ymd', 'oyr=%i' % yearend, 'omo=%i' % monend, 'oday=%i' % dayend, 'jyr=1976', 'jday=1', 'ojyr=1976', 'ojday=1', 'nday=1', 'lmw=%g' % magmin, 'umw=%g' % magmax, 'lms=0', 'ums=10', 'lmb=0', 'umb=10', 'llat=%g' % latmin, 'ulat=%g' % latmax, 'llon=%g' % lonmin, 'ulon=%g' % lonmax, 'lhd=%g' % (depthmin/km), 'uhd=%g' % (depthmax/km), 'lts=-9999', 'uts=9999', 'lpe1=0', 'upe1=90', 'lpe2=0', 'upe2=90', 'list=5']) while True: logger.debug('Opening URL: %s' % url) req = Request(url) page = urlopen(req).read() logger.debug('Received page (%i bytes)' % len(page)) events, more = self._parse_events_page(page) for ev in events: self.events[ev.name] = ev for ev in events: if time_range[0] <= ev.time and ev.time <= time_range[1]: yield ev.name if more: url = more.decode('ascii') else: break def get_event(self, name): if name not in self.events: t = self._name_to_date(name) for name2 in self.iter_event_names( time_range=(t-24*60*60, t+2*24*60*60)): if name2 == name: break return self.events[name] def _parse_events_page(self, page): lines = page.splitlines() state = 0 events = [] def complete(data): try: t = util.to_time_float( calendar.timegm(( data.year, data.month, data.day, data.hour, data.minute, data.seconds))) m = num.array( [data.mrr, data.mrt, data.mrp, data.mrt, data.mtt, data.mtp, data.mrp, data.mtp, data.mpp], dtype=float).reshape(3, 3) m *= 10.0**(data.exponent-7) mt = MomentTensor(m_up_south_east=m) ev = model.Event( lat=data.lat, lon=data.lon, time=t, name=data.eventname, depth=data.depth_km*1000., magnitude=float(mt.moment_magnitude()), duration=data.half_duration * 2., region=data.region.rstrip(), catalog=data.catalog) ev.moment_tensor = mt events.append(ev) except AttributeError: pass catalog = 'gCMT' data = None more = None for line in lines: if state == 0: m = re.search(br'<a href="([^"]+)">More solutions', line) if m: more = m.group(1) m = re.search(br'From Quick CMT catalog', line) if m: catalog = 'gCMT-Q' m = re.search(br'Event name:\s+(\S+)', line) if m: if data: complete(data) data = Anon() data.eventname = str(m.group(1).decode('ascii')) data.catalog = catalog if data: m = re.search(br'Region name:\s+([^<]+)', line) if m: data.region = str(m.group(1).decode('ascii')) m = re.search( br'Date \(y/m/d\): (\d\d\d\d)/(\d+)/(\d+)', line) if m: data.year, data.month, data.day = ( int(m.group(1)), int(m.group(2)), int(m.group(3))) m = re.search(br'Timing and location information', line) if m: state = 1 if state == 1: toks = line.split() if toks and toks[0] == b'CMT': data.hour, data.minute = [int(x) for x in toks[1:3]] data.seconds, data.lat, data.lon, data.depth_km = [ float(x) for x in toks[3:]] m = re.search(br'Assumed half duration:\s+(\S+)', line) if m: data.half_duration = float(m.group(1)) m = re.search(br'Mechanism information', line) if m: state = 2 if state == 2: m = re.search(br'Exponent for moment tensor:\s+(\d+)', line) if m: data.exponent = int(m.group(1)) toks = line.split() if toks and toks[0] == b'CMT': data.mrr, data.mtt, data.mpp, \ data.mrt, data.mrp, data.mtp = [ float(x) for x in toks[1:]] m = re.search(br'^Eigenvector:', line) if m: state = 0 if data is not None: complete(data) return events, more def _name_to_date(self, name): if len(name) == 7: y, m, d = time.strptime(name[:6], '%m%d%y')[:3] if y > 2005: y -= 100 else: y, m, d = time.strptime(name[:8], '%Y%m%d')[:3] t = util.to_time_float( calendar.timegm((y, m, d, 0, 0, 0))) return t
g_url_ndk = 'https://www.ldeo.columbia.edu/~gcmt/projects/CMT/catalog' # The bulk file covers 1976-01 to 2020-12, monthly files start in 2021. g_tmin_monthly = util.str_to_time('2021-01-01 00:00:00') g_month_names = [ 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] class NDKError(Exception): pass def _touch(path): with open(path, 'a'): os.utime(path, None)
[docs] class NDKRecord(object): ''' Parsed GlobalCMT NDK record. The :py:class:`~pyrocko.model.event.Event` object is only created on demand, as setting up the moment tensor is comparatively expensive. ''' __slots__ = ( 'name', 'time', 'lat', 'lon', 'depth', 'magnitude', 'half_duration', 'region', 'm_up_south_east', 'solution_type', 'time_modified', '_event') def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) self._event = None @property def solution_time(self): return self.time_modified or 0.0 def get_event(self): if self._event is None: mt = MomentTensor(m_up_south_east=self.m_up_south_east) extras = {} if self.solution_type: extras['catalog_version'] = self.solution_type if self.time_modified is not None: extras['catalog_time_modified'] = self.time_modified self._event = model.Event( lat=self.lat, lon=self.lon, time=self.time, name=self.name, depth=self.depth, magnitude=float(mt.moment_magnitude()), magnitude_type='Mw', duration=None if self.half_duration is None else self.half_duration * 2., region=self.region, catalog='gCMT', moment_tensor=mt, extras=extras) return self._event
[docs] def parse_ndk_records(data): ''' Parse GlobalCMT NDK formatted text into :py:class:`NDKRecord` objects. See :py:func:`parse_ndk`. ''' lines = data.splitlines() while lines and not lines[-1].strip(): lines.pop() if len(lines) % 5 != 0: raise NDKError( 'Number of lines (%i) is not a multiple of 5.' % len(lines)) records = [] for i in range(0, len(lines), 5): l1, l2, l3, l4, _ = [line.ljust(80) for line in lines[i:i+5]] try: # Line 1: hypocenter reference year, month, day = [int(x) for x in l1[5:15].split('/')] hour, minute = int(l1[16:18]), int(l1[19:21]) seconds = float(l1[22:26]) region = l1[56:80].strip() # Line 2: CMT event name, moment rate function name = l2[0:16].strip()[1:] m = re.search(r'(TRI|BOX)HD:\s*([0-9.]+)', l2[61:]) half_duration = float(m.group(2)) if m else None # Line 3: centroid parameters relative to hypocenter reference, # solution type and timestamp if not l3.startswith('CENTROID:'): raise NDKError('Expected CENTROID line.') time_shift = float(l3[9:18]) lat = float(l3[23:29]) lon = float(l3[35:42]) depth = float(l3[48:53]) * km stamp = l3[64:80].strip() solution_type = stamp[:1] or None digits = stamp[2:].replace(' ', '0') if len(digits) == 14 and digits.strip('0'): time_modified = util.to_time_float(calendar.timegm( time.strptime(digits, '%Y%m%d%H%M%S'))) else: time_modified = None # Line 4: moment tensor toks = l4.split() exponent = int(toks[0]) mrr, mtt, mpp, mrt, mrp, mtp = [float(x) for x in toks[1:13:2]] except (ValueError, IndexError, NDKError) as e: raise NDKError( 'Cannot parse NDK record starting at line %i: %s' % ( i+1, e)) from None t = util.to_time_float(calendar.timegm( (year, month, day, hour, minute, 0))) + seconds + time_shift m = num.array( [[mrr, mrt, mrp], [mrt, mtt, mtp], [mrp, mtp, mpp]], dtype=float) * 10.0**(exponent-7) records.append(NDKRecord( name=name, time=t, lat=lat, lon=lon, depth=depth, # Same definition as MomentTensor.moment_magnitude, used for # filtering before the event is created. magnitude=float(moment_to_magnitude( num.linalg.norm(m) / math.sqrt(2.))), half_duration=half_duration, region=region, m_up_south_east=m, solution_type=solution_type, time_modified=time_modified)) return records
[docs] def parse_ndk(data): ''' Parse GlobalCMT NDK formatted text into Pyrocko events. Fixed column positions are used, as given in the NDK format description (https://www.ldeo.columbia.edu/~gcmt/projects/CMT/catalog/allorder.ndk_explained). Events are named by the CMT event name without its leading source type character, e.g. ``'201001122153A'``, consistent with :py:class:`GlobalCMT`. The solution type (``'Q'``: quick, ``'S'``: standard, ``'O'``: old) is stored in ``extras['catalog_version']``, the time the solution has been computed, if available, in ``extras['catalog_time_modified']``. :param data: NDK file content. :type data: :py:class:`str` :returns: List of :py:class:`~pyrocko.model.event.Event` objects. ''' return [rec.get_event() for rec in parse_ndk_records(data)]
[docs] class GlobalCMTNDK(EarthquakeCatalog): ''' Client to get the GlobalCMT catalog from its NDK files. Instead of querying the GlobalCMT web search, the catalog files are downloaded and cached locally: the bulk file for 1976-2020 (about 23 MB), monthly files with standard solutions since 2021, and the file of quick solutions for recent events. For months without a published monthly file, quick solutions are used. Unlike :py:class:`GlobalCMT`, quick and standard solutions share the catalog name ``'gCMT'``, so that a standard solution can be recognized as an update of a quick one. The solution type and time are available in the event's ``extras``, see :py:func:`parse_ndk`. :param cache_dir: Directory where downloaded files are kept. Default is a subdirectory of Pyrocko's cache directory. :param max_age: Time [s] after which files which may change (quick solutions, not yet published monthly files) are checked for updates. ''' def __init__(self, cache_dir=None, max_age=3600.): if cache_dir is None: cache_dir = os.path.join( config.config().cache_dir, 'globalcmt-ndk') self._cache_dir = cache_dir self._max_age = max_age self._parsed = {} self.events = {} def flush(self): self.events = {} self._parsed = {} def iter_event_names( self, time_range=None, magmin=0., magmax=10., latmin=-90., latmax=90., lonmin=-180., lonmax=180., depthmin=0., depthmax=1000*km): tmin, tmax = time_range records = [ rec for rec in self._get_records(tmin, tmax) if tmin <= rec.time <= tmax and magmin <= rec.magnitude <= magmax and latmin <= rec.lat <= latmax and lonmin <= rec.lon <= lonmax and depthmin <= rec.depth <= depthmax] records.sort(key=lambda rec: rec.time) events = [rec.get_event() for rec in records] for ev in events: self.events[ev.name] = ev for ev in events: yield ev.name def get_event(self, name): if name not in self.events: t = GlobalCMT._name_to_date(None, name) for name2 in self.iter_event_names( time_range=(t-24*60*60, t+2*24*60*60)): if name2 == name: break if name not in self.events: raise NotFound() return self.events[name] def _get_records(self, tmin, tmax): records = [] if tmin < g_tmin_monthly: records.extend(self._load('jan76_dec20.ndk', static=True)) quick_months = set() for year, month in self._iter_months( max(tmin, g_tmin_monthly), min(tmax, time.time())): path = 'NEW_MONTHLY/%04i/%s%02i.ndk' % ( year, g_month_names[month-1], year % 100) monthly = self._load(path, static=True, optional=True) if monthly is None: quick_months.add((year, month)) else: records.extend(monthly) if quick_months: for rec in self._load('NEW_QUICK/qcmt.ndk', static=False): if time.gmtime(rec.time)[:2] in quick_months: records.append(rec) # In case an event appears more than once, use latest solution. latest = {} for rec in records: other = latest.get(rec.name) if other is None or rec.solution_time > other.solution_time: latest[rec.name] = rec return list(latest.values()) def _iter_months(self, tmin, tmax): if tmax < tmin: return year, month = time.gmtime(tmin)[:2] year_end, month_end = time.gmtime(tmax)[:2] while (year, month) <= (year_end, month_end): yield year, month month += 1 if month == 13: year, month = year + 1, 1 def _load(self, path, static, optional=False): fpath = self._fetch(path, static, optional) if fpath is None: return None mtime = os.stat(fpath).st_mtime if path not in self._parsed or self._parsed[path][0] != mtime: logger.debug('Parsing NDK file: %s' % fpath) with open(fpath, 'r', encoding='latin-1') as f: self._parsed[path] = (mtime, parse_ndk_records(f.read())) return self._parsed[path][1] def _fetch(self, path, static, optional): ''' Get local path of cached file, downloading it if needed. Static files are downloaded once. Other files and files known to be missing are checked again when the last check is older than ``max_age``. Returns ``None`` for missing optional files. ''' import requests fpath = os.path.join(self._cache_dir, path) fpath_checked = fpath + '.checked' fpath_missing = fpath + '.missing' def age(p): try: return time.time() - os.stat(p).st_mtime except FileNotFoundError: return float('inf') if os.path.exists(fpath) and ( static or age(fpath_checked) < self._max_age): return fpath if os.path.exists(fpath_missing) \ and age(fpath_missing) < self._max_age: return None url = '%s/%s' % (g_url_ndk, path) headers = {} if os.path.exists(fpath): headers['If-Modified-Since'] = email.utils.formatdate( os.stat(fpath).st_mtime, usegmt=True) logger.info('Checking GlobalCMT NDK file: %s' % url) r = requests.get(url, headers=headers, timeout=120.) util.ensuredirs(fpath) if r.status_code == 404 and optional: _touch(fpath_missing) return None if r.status_code != 304: r.raise_for_status() logger.info('Downloaded %i bytes.' % len(r.content)) fpath_tmp = fpath + '.tmp-%i' % os.getpid() with open(fpath_tmp, 'wb') as f: f.write(r.content) if 'Last-Modified' in r.headers: tmod = email.utils.parsedate_to_datetime( r.headers['Last-Modified']).timestamp() os.utime(fpath_tmp, (tmod, tmod)) os.replace(fpath_tmp, fpath) if os.path.exists(fpath_missing): os.unlink(fpath_missing) _touch(fpath_checked) return fpath