Skip to content

Commit cc26eaa

Browse files
committed
Fix fetchdata layout regression and add event_files output mode
1 parent bdd20eb commit cc26eaa

9 files changed

Lines changed: 333 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ Copyright (c) 2022-2026 Claudio Satriano <satriano@ipgp.fr>
1515
background catalog updates, inspect daemon status, and
1616
install/uninstall `launchd` or `systemd` service units for periodic
1717
`updatedb`, event-details, and waveform-fetch cycles.
18+
- New `fetchdata_layout` configuration option to control how fetched data is
19+
organized:
20+
- `event_dirs` (default):
21+
`events/<evid>/<evid>.xml`, `events/<evid>/waveforms/`,
22+
`events/<evid>/stations/`
23+
- `event_files`:
24+
`events/<evid>/event.xml`, `events/<evid>/event.mseed`,
25+
`events/<evid>/stations.xml`
1826

1927
### Fixed
2028

@@ -26,6 +34,9 @@ Copyright (c) 2022-2026 Claudio Satriano <satriano@ipgp.fr>
2634
plotting/data dependencies choice: if plotting extras are not already
2735
installed, update no longer adds them implicitly (applies to release and
2836
`--git` update paths).
37+
- Fixed `seiscat fetchdata` directory layout regression where `waveforms/` and
38+
`stations/` could be created as top-level directories instead of
39+
per-event subdirectories.
2940

3041
## [0.9.4] - 2026-04-23
3142

seiscat/config/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ def _validate_config(config_obj):
104104

105105
# Keys whose values are file/directory paths that should be resolved
106106
# relative to the config file's directory when they are not absolute.
107-
_PATH_KEYS = ('db_file', 'event_dir', 'waveform_dir', 'station_dir')
107+
# waveform_dir and station_dir are event-subdirectory names and must stay
108+
# relative to each per-event folder.
109+
_PATH_KEYS = ('db_file', 'event_dir')
108110

109111

110112
def _resolve_path_keys(config_obj, config_file):

seiscat/config/configspec.conf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ fdsn_providers_passwords = force_list(default=None)
5757
# Directory to store event data. For each event, an event directory named
5858
# after the event ID will be created inside this directory.
5959
event_dir = string(default=events)
60+
# Fetchdata output layout:
61+
# - event_dirs: legacy layout
62+
# events/<evid>/<evid>.xml
63+
# events/<evid>/<waveform_dir>/*.mseed
64+
# events/<evid>/<station_dir>/*.xml
65+
# - event_files: bundled layout
66+
# events/<evid>/event.xml
67+
# events/<evid>/event.mseed
68+
# events/<evid>/stations.xml
69+
fetchdata_layout = string(default=event_dirs)
6070
# Directory to store waveform data.
6171
# It will be created inside the event directory.
6272
waveform_dir = string(default=waveforms)

seiscat/fetchdata/event_details.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ..database.dbfunctions import read_events_from_db
1818
from ..sources.fdsnws import open_fdsn_connection
1919
from ..utils import ExceptionExit
20+
from .event_waveforms_utils import get_event_layout_paths
2021

2122

2223
def _get_events(client, evid):
@@ -88,9 +89,10 @@ def fetch_event_details(config):
8889
# .get() returns None if 'raw_evid' column doesn't exist
8990
raw_evid = event.get('raw_evid')
9091
print(f'{evid}:', end=' ')
91-
evid_dir = pathlib.Path(event_dir / f'{evid}')
92+
paths = get_event_layout_paths(config, evid)
93+
evid_dir = pathlib.Path(paths['evid_dir'])
9294
evid_dir.mkdir(parents=True, exist_ok=True)
93-
outfile = evid_dir / f'{evid}.xml'
95+
outfile = paths['event_xml_file']
9496
if not overwrite_existing and outfile.exists():
9597
print(f'{outfile} exists, skipping')
9698
continue

seiscat/fetchdata/event_waveforms_utils.py

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,136 @@ def prefer_high_sampling_rate(waveform_dir, logger=None):
7575
file.unlink()
7676

7777

78+
def get_fetchdata_layout(config):
79+
"""
80+
Get the fetchdata output layout.
81+
82+
Supported values:
83+
- ``event_dirs``: legacy layout with per-event waveform/station folders
84+
- ``event_files``: bundled per-event files (event.mseed/stations.xml)
85+
86+
:param config: config object
87+
:type config: dict
88+
:return: normalized layout name
89+
:rtype: str
90+
"""
91+
layout = str(config.get('fetchdata_layout', 'event_dirs')).strip().lower()
92+
if layout in ('legacy', 'event_dirs', 'event_dir'):
93+
return 'event_dirs'
94+
if layout in ('event_files', 'bundled'):
95+
return 'event_files'
96+
return 'event_dirs'
97+
98+
99+
def get_event_xml_file(evid_dir, evid):
100+
"""
101+
Return the event QuakeML file path for an event directory.
102+
103+
Tries ``{evid}.xml`` first, then ``event.xml``.
104+
105+
:param evid_dir: event directory
106+
:type evid_dir: pathlib.Path
107+
:param evid: event ID
108+
:type evid: str
109+
:return: event xml file path or None
110+
:rtype: pathlib.Path or None
111+
"""
112+
evid_dir = pathlib.Path(evid_dir)
113+
candidates = (
114+
evid_dir / f'{evid}.xml',
115+
evid_dir / 'event.xml',
116+
)
117+
for xml_file in candidates:
118+
if xml_file.exists():
119+
return xml_file
120+
return None
121+
122+
123+
def get_event_layout_paths(config, evid):
124+
"""
125+
Build event-specific paths for the configured fetchdata layout.
126+
127+
:param config: config object
128+
:type config: dict
129+
:param evid: event ID
130+
:type evid: str
131+
:return: dictionary with layout and file/directory paths
132+
:rtype: dict
133+
"""
134+
event_dir = pathlib.Path(config['event_dir'])
135+
evid_dir = event_dir / f'{evid}'
136+
layout = get_fetchdata_layout(config)
137+
if layout == 'event_files':
138+
return {
139+
'layout': layout,
140+
'evid_dir': evid_dir,
141+
'waveform_dir': evid_dir / '.waveforms',
142+
'station_dir': evid_dir / '.stations',
143+
'event_xml_file': evid_dir / 'event.xml',
144+
'waveform_file': evid_dir / 'event.mseed',
145+
'station_file': evid_dir / 'stations.xml',
146+
}
147+
return {
148+
'layout': layout,
149+
'evid_dir': evid_dir,
150+
'waveform_dir': evid_dir / config['waveform_dir'],
151+
'station_dir': evid_dir / config['station_dir'],
152+
'event_xml_file': evid_dir / f'{evid}.xml',
153+
'waveform_file': None,
154+
'station_file': None,
155+
}
156+
157+
158+
def bundle_waveforms_to_mseed(waveform_dir, outfile):
159+
"""
160+
Bundle all waveform miniSEED files from a directory into one file.
161+
162+
:param waveform_dir: source waveform directory
163+
:type waveform_dir: pathlib.Path
164+
:param outfile: output miniSEED file
165+
:type outfile: pathlib.Path
166+
:return: True if output file was written, False otherwise
167+
:rtype: bool
168+
"""
169+
from obspy import Stream, read
170+
waveform_dir = pathlib.Path(waveform_dir)
171+
outfile = pathlib.Path(outfile)
172+
stream = Stream()
173+
for mseed_file in sorted(waveform_dir.glob('*.mseed')):
174+
stream += read(str(mseed_file))
175+
if len(stream) == 0:
176+
return False
177+
stream.write(str(outfile), format='MSEED')
178+
return True
179+
180+
181+
def bundle_stations_to_xml(station_dir, outfile):
182+
"""
183+
Bundle all station XML files from a directory into one StationXML file.
184+
185+
:param station_dir: source station XML directory
186+
:type station_dir: pathlib.Path
187+
:param outfile: output StationXML file
188+
:type outfile: pathlib.Path
189+
:return: True if output file was written, False otherwise
190+
:rtype: bool
191+
"""
192+
from obspy import read_inventory
193+
station_dir = pathlib.Path(station_dir)
194+
outfile = pathlib.Path(outfile)
195+
inv = None
196+
for station_file in sorted(station_dir.glob('*.xml')):
197+
_inv = read_inventory(str(station_file))
198+
if inv is None:
199+
inv = _inv
200+
else:
201+
inv += _inv
202+
if inv is None:
203+
return False
204+
inv.write(str(outfile), format='STATIONXML')
205+
return True
206+
207+
78208
def check_station(station, station_codes):
79209
"""
80210
Check if a station matches the specified station codes.
@@ -116,8 +246,8 @@ def get_picked_station_codes(evid_dir, evid):
116246
"""
117247
import warnings
118248
from obspy import read_events
119-
xml_file = pathlib.Path(evid_dir) / f'{evid}.xml'
120-
if not xml_file.exists():
249+
xml_file = get_event_xml_file(evid_dir, evid)
250+
if xml_file is None:
121251
return None
122252
with warnings.catch_warnings():
123253
warnings.simplefilter('ignore')

seiscat/fetchdata/mass_downloader.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@
1212
import sys
1313
import pathlib
1414
import logging
15+
import shutil
1516
from obspy.clients.fdsn import Client
1617
from obspy.clients.fdsn.mass_downloader import (
1718
CircularDomain, Restrictions, MassDownloader
1819
)
19-
from .event_waveforms_utils import prefer_high_sampling_rate, get_picked_station_codes
20+
from .event_waveforms_utils import (
21+
prefer_high_sampling_rate,
22+
get_event_layout_paths,
23+
get_event_xml_file,
24+
bundle_waveforms_to_mseed,
25+
bundle_stations_to_xml,
26+
)
2027
from ..utils import ExceptionExit
2128
mdl_logger = logging.getLogger('obspy.clients.fdsn.mass_downloader')
2229

@@ -234,10 +241,13 @@ def _build_station_restriction(
234241
if picked_stations_only:
235242
picked = get_picked_station_codes(evid_dir, evid)
236243
if picked is None:
244+
event_xml = get_event_xml_file(evid_dir, evid)
245+
if event_xml is None:
246+
event_xml = pathlib.Path(evid_dir) / f'{evid}.xml'
237247
mdl_logger.warning(
238248
'picked_stations_only is True but no event QuakeML file '
239249
'found at %s. Ignoring pick-based station selection.',
240-
evid_dir / f'{evid}.xml'
250+
event_xml
241251
)
242252
# Fall through to plain station_codes restriction (or None)
243253
if not station_codes:
@@ -298,11 +308,11 @@ def mass_download_waveforms(config, event):
298308
channel_codes = config['channel_codes']
299309
station_codes = config['station_codes']
300310
picked_stations_only = config['picked_stations_only']
301-
event_dir = pathlib.Path(config['event_dir'])
302-
evid_dir = event_dir / f'{evid}'
303-
waveform_dir = pathlib.Path(evid_dir / config['waveform_dir'])
311+
paths = get_event_layout_paths(config, evid)
312+
evid_dir = paths['evid_dir']
313+
waveform_dir = pathlib.Path(paths['waveform_dir'])
304314
waveform_dir.mkdir(parents=True, exist_ok=True)
305-
station_dir = pathlib.Path(evid_dir / config['station_dir'])
315+
station_dir = pathlib.Path(paths['station_dir'])
306316
station_dir.mkdir(parents=True, exist_ok=True)
307317

308318
_set_mdl_logger(evid)
@@ -361,6 +371,23 @@ def mass_download_waveforms(config, event):
361371
)
362372
if config['prefer_high_sampling_rate']:
363373
prefer_high_sampling_rate(waveform_dir, mdl_logger)
374+
if paths['layout'] == 'event_files':
375+
waveforms_written = bundle_waveforms_to_mseed(
376+
waveform_dir, paths['waveform_file'])
377+
stations_written = bundle_stations_to_xml(
378+
station_dir, paths['station_file'])
379+
shutil.rmtree(waveform_dir, ignore_errors=True)
380+
shutil.rmtree(station_dir, ignore_errors=True)
381+
if waveforms_written:
382+
mdl_logger.info(
383+
'Bundled waveforms saved to %s',
384+
paths['waveform_file']
385+
)
386+
if stations_written:
387+
mdl_logger.info(
388+
'Bundled stations saved to %s',
389+
paths['station_file']
390+
)
364391
_info_msg = f'Waveforms and station metadata saved to {evid_dir}'
365392
mdl_logger.info(_info_msg)
366393
_unset_mdl_logger()

seiscat/fetchdata/sds.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@
1111
"""
1212
import pathlib
1313
import re
14+
import shutil
1415
from obspy.clients.filesystem.sds import Client
1516
from .event_waveforms_utils import (
16-
prefer_high_sampling_rate, check_station, get_picked_station_codes
17+
prefer_high_sampling_rate,
18+
check_station,
19+
get_picked_station_codes,
20+
get_event_layout_paths,
21+
get_event_xml_file,
22+
bundle_waveforms_to_mseed,
1723
)
1824

1925

@@ -76,9 +82,9 @@ def fetch_sds_waveforms(config, event, client):
7682
:type client: obspy.clients.filesystem.sds.Client
7783
"""
7884
evid = event['evid']
79-
event_dir = pathlib.Path(config['event_dir'])
80-
evid_dir = event_dir / f'{evid}'
81-
waveform_dir = pathlib.Path(evid_dir / config['waveform_dir'])
85+
paths = get_event_layout_paths(config, evid)
86+
evid_dir = paths['evid_dir']
87+
waveform_dir = pathlib.Path(paths['waveform_dir'])
8288
waveform_dir.mkdir(parents=True, exist_ok=True)
8389
seconds_before = config['seconds_before_origin']
8490
seconds_after = config['seconds_after_origin']
@@ -92,9 +98,12 @@ def fetch_sds_waveforms(config, event, client):
9298
if picked_stations_only:
9399
picked_stations = get_picked_station_codes(evid_dir, evid)
94100
if picked_stations is None:
101+
event_xml = get_event_xml_file(evid_dir, evid)
102+
if event_xml is None:
103+
event_xml = evid_dir / f'{evid}.xml'
95104
print(
96105
f'{evid}: picked_stations_only is True but no event QuakeML '
97-
f'file found at {evid_dir / f"{evid}.xml"}. '
106+
f'file found at {event_xml}. '
98107
'Ignoring pick-based station selection.'
99108
)
100109
elif not picked_stations:
@@ -136,4 +145,8 @@ def fetch_sds_waveforms(config, event, client):
136145
print(f' {outfile} written')
137146
if config['prefer_high_sampling_rate']:
138147
prefer_high_sampling_rate(waveform_dir)
148+
if paths['layout'] == 'event_files':
149+
if bundle_waveforms_to_mseed(waveform_dir, paths['waveform_file']):
150+
print(f' {paths["waveform_file"]} written')
151+
shutil.rmtree(waveform_dir, ignore_errors=True)
139152
print()

tests/test_config_paths.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# -*- coding: utf8 -*-
2+
# SPDX-License-Identifier: GPL-3.0-or-later
3+
"""
4+
Test config path resolution rules.
5+
"""
6+
import os
7+
import pathlib
8+
import tempfile
9+
import unittest
10+
from seiscat.config.config import parse_configspec, read_config
11+
12+
13+
class TestConfigPathResolution(unittest.TestCase):
14+
"""Test resolution of relative paths in config files."""
15+
16+
def test_event_dir_resolved_wave_station_kept_relative(self):
17+
"""event_dir is absolutized, waveform/station names remain relative."""
18+
with tempfile.TemporaryDirectory() as tmpdir:
19+
cfg_path = pathlib.Path(tmpdir) / 'seiscat.conf'
20+
cfg_path.write_text(
21+
'\n'.join([
22+
'db_file = seiscat_db.sqlite',
23+
'event_dir = events',
24+
'waveform_dir = waveforms',
25+
'station_dir = stations',
26+
])
27+
)
28+
config = read_config(str(cfg_path), parse_configspec())
29+
self.assertEqual(
30+
config['event_dir'],
31+
os.path.join(tmpdir, 'events')
32+
)
33+
self.assertEqual(config['waveform_dir'], 'waveforms')
34+
self.assertEqual(config['station_dir'], 'stations')
35+
36+
37+
if __name__ == '__main__':
38+
unittest.main()

0 commit comments

Comments
 (0)