diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3604c..64731f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Copyright (c) 2021-2026 Claudio Satriano ## [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`` diff --git a/requake/config/configspec.conf b/requake/config/configspec.conf index f44fcd0..802ba2a 100644 --- a/requake/config/configspec.conf +++ b/requake/config/configspec.conf @@ -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: diff --git a/requake/waveforms/waveforms.py b/requake/waveforms/waveforms.py index ff6493b..ad8f06c 100644 --- a/requake/waveforms/waveforms.py +++ b/requake/waveforms/waveforms.py @@ -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 @@ -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, @@ -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): """ diff --git a/tests/unit/test_location_fallback.py b/tests/unit/test_location_fallback.py new file mode 100644 index 0000000..2b96f0a --- /dev/null +++ b/tests/unit/test_location_fallback.py @@ -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 , + 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()