Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Copyright (c) 2021-2026 Claudio Satriano <satriano@ipgp.fr>

## [unreleased]

- New `dataselect_location_fallback` option: a waveform request that returns
no data for the exact location code is retried with a wildcard location
code, which helps with networks whose location code changed over time (for
example an empty location code before 2002). Disabled by default.
- New interactive curses pager for all ``print_`` commands
(``print_catalog``, ``print_pairs``, ``print_families``).
Automatically activated when output is a terminal; use ``--no-pager``
Expand Down
4 changes: 4 additions & 0 deletions requake/config/configspec.conf
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ sds_data_path = string(default=None)
## containing waveform files in any format supported by ObsPy
## (e.g., SAC, miniSEED). The eventid should be the same as in the catalog.
event_data_path = string(default=None)
## Retry a waveform request with a wildcard location code when the exact
## location code returns no data. Helps with networks whose location code
## changed over time (for example an empty location code before 2002).
dataselect_location_fallback = boolean(default=False)

#### Catalog-based scan
### The following parameters are for a catalog-based scan:
Expand Down
86 changes: 65 additions & 21 deletions requake/waveforms/waveforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,20 @@ def _write_waveform_to_disk_cache(evid, traceid, starttime, endtime, tr):
return written


def get_waveform_from_client(traceid, starttime, endtime):
def _fetch_waveform(client, net, sta, loc, chan, traceid, starttime, endtime):
"""
Get a waveform from a FDSN or SDS client.

The FDSN dataselect client is created lazily on first use to avoid
unnecessary network I/O when waveforms come from the disk cache.

:param traceid: trace id
Fetch and clean a single waveform window, raising on missing data.

:param client: an ObsPy FDSN or SDS client
:param net: network code
:type net: str
:param sta: station code
:type sta: str
:param loc: location code
:type loc: str
:param chan: channel code
:type chan: str
:param traceid: trace id, used only for error messages
:type traceid: str
:param starttime: start time
:type starttime: obspy.UTCDateTime
Expand All @@ -130,21 +136,9 @@ def get_waveform_from_client(traceid, starttime, endtime):

:return: waveform trace
:rtype: obspy.Trace

:raises NoWaveformError: if no waveform data is available
"""
client = config.dataselect_client
if client is None and config.event_data_path is None:
# Lazy-init FDSN dataselect client
from obspy.clients.fdsn import Client as FDSNClient
client = FDSNClient(config.fdsn_dataselect_url)
config.dataselect_client = client
logger.info(
'Connected to FDSN dataselect server: '
f'{config.fdsn_dataselect_url}'
)
if client is None:
raise NoWaveformError(
'No dataselect_client defined in the config file')
net, sta, loc, chan = traceid.split('.')
try:
st = client.get_waveforms(
network=net, station=sta, location=loc, channel=chan,
Expand Down Expand Up @@ -185,6 +179,56 @@ def get_waveform_from_client(traceid, starttime, endtime):
return st[0]


def get_waveform_from_client(traceid, starttime, endtime):
"""
Get a waveform from a FDSN or SDS client.

The FDSN dataselect client is created lazily on first use to avoid
unnecessary network I/O when waveforms come from the disk cache.

When ``dataselect_location_fallback`` is set and the exact location code
returns no data, the request is retried with a wildcard location code.
This helps with networks whose location code changed over time (for
example an empty location code before 2002).

:param traceid: trace id
:type traceid: str
:param starttime: start time
:type starttime: obspy.UTCDateTime
:param endtime: end time
:type endtime: obspy.UTCDateTime

:return: waveform trace
:rtype: obspy.Trace
"""
client = config.dataselect_client
if client is None and config.event_data_path is None:
# Lazy-init FDSN dataselect client
from obspy.clients.fdsn import Client as FDSNClient
client = FDSNClient(config.fdsn_dataselect_url)
config.dataselect_client = client
logger.info(
'Connected to FDSN dataselect server: '
f'{config.fdsn_dataselect_url}'
)
if client is None:
raise NoWaveformError(
'No dataselect_client defined in the config file')
net, sta, loc, chan = traceid.split('.')
try:
return _fetch_waveform(
client, net, sta, loc, chan, traceid, starttime, endtime)
except NoWaveformError:
fallback = getattr(config, 'dataselect_location_fallback', False)
if not fallback or loc in ('', '*'):
raise
logger.warning(
'No data for %s with location "%s"; retrying with a wildcard '
'location code', traceid, loc)
return _fetch_waveform(
client, net, sta, '*', chan, traceid, starttime, endtime)


def _get_arrivals_and_distance(
trace_lat, trace_lon, ev_lat, ev_lon, ev_depth, orig_time):
"""
Expand Down
88 changes: 88 additions & 0 deletions tests/unit/test_location_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# -*- coding: utf8 -*-
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Unit tests for the wildcard location-code fallback in get_waveform_from_client.

:copyright:
2021-2026 Claudio Satriano <satriano@ipgp.fr>,
Marius Yvard
:license:
GNU General Public License v3.0 or later
(https://www.gnu.org/licenses/gpl-3.0-standalone.html)
"""

import unittest
from unittest.mock import patch
import numpy as np
from obspy import Trace, Stream, UTCDateTime
from obspy.clients.fdsn.header import FDSNNoDataException
from requake.config import config
from requake.waveforms import NoWaveformError
from requake.waveforms.waveforms import get_waveform_from_client

T0 = UTCDateTime('2000-06-01T00:00:00')
T1 = T0 + 60.0


class _FakeClient:
"""A client that returns data only for a wildcard or empty location."""

def __init__(self):
"""Record the location codes that were requested."""
self.requested_locations = []

def get_waveforms(self, network, station, location, channel,
starttime, endtime):
"""Raise for location '00', return a trace otherwise."""
self.requested_locations.append(location)
if location == '00':
raise FDSNNoDataException('No data')
npts = int((endtime - starttime) * 100.0) + 1
tr = Trace(data=np.ones(npts, dtype=np.float64))
tr.stats.sampling_rate = 100.0
tr.stats.network, tr.stats.station = network, station
tr.stats.location = '' if location == '*' else location
tr.stats.channel = channel
tr.stats.starttime = starttime
return Stream([tr])


class TestLocationFallback(unittest.TestCase):
"""get_waveform_from_client retries with a wildcard location on demand."""

def _patch(self, client, fallback):
"""Point the config at the fake client and set the fallback flag."""
return patch.multiple(
config, create=True,
dataselect_client=client,
event_data_path=None,
dataselect_location_fallback=fallback,
)

def test_retries_with_wildcard_when_enabled(self):
"""An exact-location miss is retried with a wildcard location."""
client = _FakeClient()
with self._patch(client, True):
tr = get_waveform_from_client('XX.STA.00.BHZ', T0, T1)
self.assertIsNotNone(tr)
self.assertEqual(client.requested_locations, ['00', '*'])

def test_no_retry_when_disabled(self):
"""Without the flag, a miss raises and is not retried."""
client = _FakeClient()
with self._patch(client, False):
with self.assertRaises(NoWaveformError):
get_waveform_from_client('XX.STA.00.BHZ', T0, T1)
self.assertEqual(client.requested_locations, ['00'])

def test_no_retry_when_location_already_empty(self):
"""An empty location code is permissive and is not retried."""
client = _FakeClient()
with self._patch(client, True):
tr = get_waveform_from_client('XX.STA..BHZ', T0, T1)
self.assertIsNotNone(tr)
self.assertEqual(client.requested_locations, [''])


if __name__ == '__main__':
unittest.main()