groundtrack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ """
2
+ groundtrack
3
+
4
+ Tools for discovering seismic signals from atmospheric re-entry events
5
+ using orbital ground tracks and seismic station data.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ # Allow user to import below functions directly from the package
11
+ from .download import download_boxes
12
+ from .stations import load_and_filter_stations
13
+ from .tiling import track_to_box_windows, box_windows_to_download_requests
14
+ from .processing import process_boxes, process_box, process_stream
15
+ from .pipeline import run_pipeline
16
+
17
+ from .plotting import (
18
+ plot_track_and_boxes,
19
+ plot_stations,
20
+ plot_station_comparison,
21
+ plot_waveform_comparison,
22
+ plot_all_waveforms,
23
+ )
@@ -0,0 +1,386 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Iterable, Sequence
6
+
7
+ from obspy import UTCDateTime
8
+ from obspy.clients.fdsn import Client
9
+ from obspy.clients.fdsn.header import FDSNNoDataException
10
+ from obspy.clients.fdsn.mass_downloader import (
11
+ MassDownloader,
12
+ RectangularDomain,
13
+ Restrictions,
14
+ )
15
+
16
+ from .stations import (
17
+ parse_stationxml_files,
18
+ deduplicate_stations,
19
+ filter_stations_by_track_distance,
20
+ )
21
+
22
+ from .processing import (
23
+ process_box,
24
+ DEFAULT_PRE_FILT_LOW,
25
+ DEFAULT_WATER_LEVEL,
26
+ DEFAULT_OUTPUT,
27
+ DEFAULT_TAPER_PCT,
28
+ DEFAULT_FREQMIN,
29
+ DEFAULT_FREQMAX,
30
+ DEFAULT_CORNERS,
31
+ DEFAULT_ZEROPHASE,
32
+ )
33
+
34
+
35
+ def _normalize_providers(providers):
36
+ """
37
+ Make sure providers are always in a clean list format.
38
+
39
+ Why:
40
+ ObsPy expects provider names as strings. This avoids weird behavior if
41
+ something else gets passed in accidentally.
42
+ """
43
+ if providers is None:
44
+ return []
45
+ return [str(p) for p in providers]
46
+
47
+
48
+ def _count_files(path: Path, pattern: str) -> int:
49
+ """
50
+ Count files recursively underneath a folder.
51
+
52
+ Why:
53
+ We use this for quick bookkeeping, like:
54
+ - checking whether a box already has downloaded content
55
+ - recording how many files ended up on disk
56
+ """
57
+ return sum(1 for p in path.rglob(pattern) if p.is_file())
58
+
59
+
60
+ def _provider_station_query(
61
+ client: Client,
62
+ req: dict,
63
+ channel_priorities: Sequence[str],
64
+ level: str = "station",
65
+ ):
66
+ """
67
+ Query one provider for station inventory inside one box.
68
+
69
+ Why:
70
+ This is the coarse discovery step. We are asking:
71
+ "What stations exist in this box and this time window?"
72
+ """
73
+ return client.get_stations(
74
+ starttime=UTCDateTime(req["t_start_utc"]),
75
+ endtime=UTCDateTime(req["t_end_utc"]),
76
+ minlatitude=req["lat_min"],
77
+ maxlatitude=req["lat_max"],
78
+ minlongitude=req["lon_min"],
79
+ maxlongitude=req["lon_max"],
80
+ channel=",".join(channel_priorities),
81
+ level=level,
82
+ )
83
+
84
+
85
+ def _make_mseed_storage(approved_stations: set[tuple[str, str]], wav_dir: Path):
86
+ """
87
+ Build a callable for MassDownloader's mseed_storage parameter.
88
+
89
+ MassDownloader calls this once per channel/time-interval it considers
90
+ downloading. Returning a file path means "download here"; returning
91
+ None means "skip this station entirely."
92
+
93
+ We use this to enforce the corridor distance filter: only stations
94
+ that passed Phase 1 filtering are in approved_stations.
95
+ """
96
+ def storage(network, station, location, channel, starttime, endtime):
97
+ if (network, station) not in approved_stations:
98
+ return None
99
+ loc = location if location else ""
100
+ return str(wav_dir / f"{network}.{station}.{loc}.{channel}.mseed")
101
+
102
+ return storage
103
+
104
+
105
+ def download_boxes(
106
+ download_requests: Iterable[dict],
107
+ track_points,
108
+ output_base: str | Path,
109
+ event_name: str,
110
+ corridor_km: float = 100.0,
111
+ providers: Sequence[str] | None = ("EARTHSCOPE",),
112
+ channel_priorities: Sequence[str] = ("HHZ", "BHZ"),
113
+ location_priorities: Sequence[str] = ("", "00", "10", "20"),
114
+ overwrite_existing: bool = False,
115
+ verbose: bool = True,
116
+
117
+ # ------------------------------------------------------------------
118
+ # Optional post-download processing
119
+ # ------------------------------------------------------------------
120
+ # When apply_processing is False (default), raw data is downloaded and
121
+ # nothing else happens -- this preserves the original behavior. When it
122
+ # is True, each box is processed immediately after its waveforms land
123
+ # on disk, using the parameters below. All defaults match process_boxes()
124
+ # so the one-shot and two-step workflows produce identical output.
125
+ apply_processing: bool = False,
126
+ # Pre-processing (before response removal)
127
+ demean: bool = True,
128
+ detrend_linear: bool = True,
129
+ taper_pct: float = DEFAULT_TAPER_PCT,
130
+ # Response removal
131
+ output: str = DEFAULT_OUTPUT,
132
+ pre_filt_low: tuple[float, float] | None = DEFAULT_PRE_FILT_LOW,
133
+ water_level: float = DEFAULT_WATER_LEVEL,
134
+ # Bandpass (after response removal)
135
+ apply_bandpass: bool = True,
136
+ freqmin: float = DEFAULT_FREQMIN,
137
+ freqmax: float = DEFAULT_FREQMAX,
138
+ corners: int = DEFAULT_CORNERS,
139
+ zerophase: bool = DEFAULT_ZEROPHASE
140
+ ) -> dict:
141
+ """
142
+ Main box-based download pipeline.
143
+
144
+ High-level logic:
145
+ -----------------
146
+ For each box:
147
+ 1. Query candidate station inventory inside the box
148
+ 2. Parse and deduplicate stations
149
+ 3. Filter stations by distance to the propagated track
150
+ 4. Download waveforms only for stations that survive the filter
151
+ 5. Save a manifest describing what happened
152
+
153
+ This is the box -> candidate inventory -> distance filter -> waveform
154
+ download architecture we discussed.
155
+ """
156
+ requests = list(download_requests)
157
+
158
+ run_folder = Path(output_base) / event_name
159
+ boxes_root = run_folder / "boxes"
160
+ boxes_root.mkdir(parents=True, exist_ok=True)
161
+
162
+ provider_names = _normalize_providers(providers)
163
+
164
+ # Initialize provider clients once up front.
165
+ clients = {}
166
+ for provider_name in provider_names:
167
+ try:
168
+ clients[provider_name] = Client(provider_name)
169
+ except Exception as e:
170
+ if verbose:
171
+ print(f"Could not initialize provider {provider_name}: {repr(e)}")
172
+
173
+ mdl = MassDownloader(providers=provider_names) # One instance used for every box
174
+
175
+ results = []
176
+ n_ok = 0
177
+ n_skip = 0
178
+ n_fail = 0
179
+
180
+ for k, req in enumerate(requests, start=1):
181
+ box_id = req["box_id"]
182
+
183
+ box_dir = boxes_root / box_id
184
+ inv_dir = box_dir / "stations"
185
+ wav_dir = box_dir / "waveforms"
186
+ log_dir = box_dir / "logs"
187
+
188
+ inv_dir.mkdir(parents=True, exist_ok=True)
189
+ wav_dir.mkdir(parents=True, exist_ok=True)
190
+ log_dir.mkdir(parents=True, exist_ok=True)
191
+
192
+ existing_mseed = _count_files(wav_dir, "*.mseed")
193
+ existing_xml = _count_files(inv_dir, "*.xml")
194
+
195
+ if not overwrite_existing and existing_mseed > 0 and existing_xml > 0:
196
+ n_skip += 1
197
+ results.append(
198
+ {
199
+ "box_id": box_id,
200
+ "status": "skipped_existing",
201
+ "existing_mseed_files": existing_mseed,
202
+ "existing_stationxml_files": existing_xml,
203
+ }
204
+ )
205
+
206
+ if verbose:
207
+ print(f"[{k}/{len(requests)}] skipping {box_id} (already has data)")
208
+
209
+ continue
210
+
211
+ if verbose:
212
+ print(f"[{k}/{len(requests)}] processing {box_id} ...")
213
+
214
+ # ------------------------------------------------------------
215
+ # Stage 1: gather candidate inventory files from providers
216
+ # ------------------------------------------------------------
217
+ provider_stationxml_files = []
218
+
219
+ for provider_name, client in clients.items():
220
+ try:
221
+ inv = _provider_station_query(
222
+ client=client,
223
+ req=req,
224
+ channel_priorities=channel_priorities,
225
+ level="station",
226
+ )
227
+
228
+ xml_path = inv_dir / f"{provider_name}_stations.xml"
229
+ inv.write(str(xml_path), format="STATIONXML")
230
+ provider_stationxml_files.append(xml_path)
231
+
232
+ if verbose:
233
+ print(f" inventory from {provider_name}: saved {xml_path.name}")
234
+
235
+ except FDSNNoDataException:
236
+ if verbose:
237
+ print(f" inventory from {provider_name}: no data")
238
+ except Exception as e:
239
+ if verbose:
240
+ print(f" inventory from {provider_name}: FAILED -> {repr(e)}")
241
+
242
+ # Parse raw candidate stations from the StationXML files we just saved.
243
+ station_rows = parse_stationxml_files(provider_stationxml_files)
244
+ unique_stations = deduplicate_stations(station_rows)
245
+
246
+ # ------------------------------------------------------------
247
+ # Stage 2: keep only stations within the physical corridor
248
+ # ------------------------------------------------------------
249
+ kept_stations = filter_stations_by_track_distance(
250
+ unique_stations,
251
+ track_points=track_points,
252
+ corridor_km=corridor_km,
253
+ )
254
+
255
+ if verbose:
256
+ print(
257
+ f" candidate stations: {len(unique_stations)} | "
258
+ f"kept within {corridor_km} km: {len(kept_stations)}"
259
+ )
260
+
261
+ # Save a simple filtered station summary for debugging / inspection.
262
+ filtered_summary_path = log_dir / "filtered_stations.json"
263
+ with open(filtered_summary_path, "w", encoding="utf-8") as f:
264
+ json.dump(kept_stations, f, indent=2, default=str)
265
+
266
+
267
+ # ------------------------------------------------------------
268
+ # Stage 3: bulk waveform download via MassDownloader
269
+ # ------------------------------------------------------------
270
+ approved = {(s["network"], s["station"]) for s in kept_stations}
271
+
272
+ if approved:
273
+ domain = RectangularDomain(
274
+ minlatitude=req["lat_min"],
275
+ maxlatitude=req["lat_max"],
276
+ minlongitude=req["lon_min"],
277
+ maxlongitude=req["lon_max"],
278
+ )
279
+
280
+ restrictions = Restrictions(
281
+ starttime=UTCDateTime(req["t_start_utc"]),
282
+ endtime=UTCDateTime(req["t_end_utc"]),
283
+ network=",".join(sorted({s["network"] for s in kept_stations})),
284
+ station=",".join(sorted({s["station"] for s in kept_stations})),
285
+ channel_priorities=list(channel_priorities),
286
+ location_priorities=list(location_priorities),
287
+ reject_channels_with_gaps=True,
288
+ minimum_length=0.9,
289
+ )
290
+
291
+ try:
292
+ mdl.download(
293
+ domain,
294
+ restrictions,
295
+ mseed_storage=_make_mseed_storage(approved, wav_dir),
296
+ stationxml_storage=str(inv_dir / "{network}.{station}.xml"),
297
+ )
298
+ except Exception as e:
299
+ if verbose:
300
+ print(f" MassDownloader error: {repr(e)}")
301
+
302
+ new_mseed = _count_files(wav_dir, "*.mseed")
303
+ new_xml = _count_files(inv_dir, "*.xml")
304
+
305
+ box_result = {
306
+ "box_id": box_id,
307
+ "status": "ok",
308
+ "candidate_station_count": len(unique_stations),
309
+ "filtered_station_count": len(kept_stations),
310
+ "mseed_files": new_mseed,
311
+ "stationxml_files": new_xml,
312
+ }
313
+
314
+ # ------------------------------------------------------------
315
+ # Optional: process this box's waveforms right after download
316
+ # ------------------------------------------------------------
317
+ if apply_processing and new_mseed > 0:
318
+ if verbose:
319
+ print(f" processing {box_id} ...")
320
+ proc_result = process_box(
321
+ box_dir,
322
+ demean=demean,
323
+ detrend_linear=detrend_linear,
324
+ taper_pct=taper_pct,
325
+ output=output,
326
+ pre_filt_low=pre_filt_low,
327
+ water_level=water_level,
328
+ apply_bandpass=apply_bandpass,
329
+ freqmin=freqmin,
330
+ freqmax=freqmax,
331
+ corners=corners,
332
+ zerophase=zerophase,
333
+ overwrite_existing=overwrite_existing,
334
+ verbose=verbose,
335
+ )
336
+ box_result["processing"] = {
337
+ "traces_in": proc_result["traces_in"],
338
+ "traces_out": proc_result["traces_out"],
339
+ "skipped_existing": proc_result["skipped_existing"],
340
+ "n_errors": len(proc_result["errors"]),
341
+ }
342
+
343
+ n_ok += 1
344
+ results.append(box_result)
345
+
346
+ manifest = {
347
+ "event_name": event_name,
348
+ "run_folder": str(run_folder),
349
+ "boxes_root": str(boxes_root),
350
+ "total_requests": len(requests),
351
+ "providers": provider_names,
352
+ "channel_priorities": list(channel_priorities),
353
+ "location_priorities": list(location_priorities),
354
+ "corridor_km": corridor_km,
355
+ "ok": n_ok,
356
+ "skipped_existing": n_skip,
357
+ "failed": n_fail,
358
+ "processing": {
359
+ "applied": apply_processing,
360
+ "params": {
361
+ "demean": demean,
362
+ "detrend_linear": detrend_linear,
363
+ "taper_pct": taper_pct,
364
+ "output": output,
365
+ "pre_filt_low": list(pre_filt_low) if pre_filt_low is not None else None,
366
+ "water_level": water_level,
367
+ "apply_bandpass": apply_bandpass,
368
+ "freqmin": freqmin,
369
+ "freqmax": freqmax,
370
+ "corners": corners,
371
+ "zerophase": zerophase,
372
+ } if apply_processing else None,
373
+ },
374
+ "results": results,
375
+ }
376
+
377
+ manifest_path = run_folder / "download_manifest.json"
378
+ with open(manifest_path, "w", encoding="utf-8") as f:
379
+ json.dump(manifest, f, indent=2, default=str)
380
+
381
+ if verbose:
382
+ print("\n=== Download Summary ===")
383
+ print("OK:", n_ok, "| Skipped:", n_skip, "| Failed:", n_fail)
384
+ print("Manifest saved to:", manifest_path)
385
+
386
+ return manifest
groundtrack/geodesy.py ADDED
@@ -0,0 +1,140 @@
1
+ import numpy as np
2
+
3
+ from .types import TrackSegment
4
+
5
+ from obspy.geodetics import (
6
+ locations2degrees,
7
+ degrees2kilometers,
8
+ )
9
+
10
+
11
+ def wrap_lon_deg(lon: float) -> float:
12
+ return (lon + 180.0) % 360.0 - 180.0
13
+
14
+
15
+ _AMBIGUOUS_BAND_KM = 10.0
16
+
17
+ def min_distance_km_to_track(
18
+ sta_lat: float,
19
+ sta_lon: float,
20
+ track_points,
21
+ corridor_km: float | None = None,
22
+ ) -> float:
23
+ """
24
+ Minimum great-circle distance in km from a station to the ground track.
25
+
26
+ For stations clearly inside or outside the corridor, returns the
27
+ point-sampled distance (fast, vectorized). For stations in the ambiguous
28
+ band near the corridor boundary, computes the true cross-track distance
29
+ to the one or two arc segments neighboring the nearest track point.
30
+
31
+ Args:
32
+ sta_lat: Station latitude in degrees.
33
+ sta_lon: Station longitude in degrees.
34
+ track_points: List of TrackPoint objects.
35
+ corridor_km: The distance threshold being tested. Required to
36
+ determine whether a station is in the ambiguous band.
37
+ If None, always returns the point-sampled distance.
38
+ """
39
+ sta_lon = wrap_lon_deg(float(sta_lon))
40
+
41
+ lats = np.array([p.lat for p in track_points], dtype=float)
42
+ lons = np.array([wrap_lon_deg(p.lon) for p in track_points], dtype=float)
43
+
44
+ # Fast vectorized point-to-point pass
45
+ deg_to_points = locations2degrees(sta_lat, sta_lon, lats, lons)
46
+ min_km = float(degrees2kilometers(np.min(deg_to_points)))
47
+
48
+ # If no corridor given, or station is clearly inside/outside the
49
+ # ambiguous band, return the point-sampled answer immediately
50
+ if corridor_km is None:
51
+ return min_km
52
+ if min_km <= corridor_km - _AMBIGUOUS_BAND_KM:
53
+ return min_km
54
+ if min_km > corridor_km + _AMBIGUOUS_BAND_KM:
55
+ return min_km
56
+
57
+ # --- Ambiguous band: compute cross-track distance for the one or two
58
+ # segments immediately neighboring the nearest track point ---
59
+ nearest_idx = int(np.argmin(deg_to_points))
60
+ candidate_indices = set()
61
+ if nearest_idx > 0:
62
+ candidate_indices.add(nearest_idx - 1)
63
+ if nearest_idx < len(track_points) - 1:
64
+ candidate_indices.add(nearest_idx)
65
+
66
+ sta_lat_r = np.deg2rad(sta_lat)
67
+ sta_lon_r = np.deg2rad(sta_lon)
68
+
69
+ for i in candidate_indices:
70
+ seg = _compute_segment(lats, lons, i)
71
+
72
+ d_AS = np.deg2rad(deg_to_points[i])
73
+ if d_AS == 0.0:
74
+ continue
75
+
76
+ bearing_AS = _bearing_rad(seg.lat_a_rad, seg.lon_a_rad, sta_lat_r, sta_lon_r)
77
+
78
+ sin_dxt = np.sin(d_AS) * np.sin(bearing_AS - seg.bearing_ab_rad)
79
+ dxt_rad = np.arcsin(np.clip(sin_dxt, -1.0, 1.0))
80
+
81
+ cos_dxt = np.cos(dxt_rad)
82
+ dat_rad = np.arccos(np.clip(np.cos(d_AS) / max(cos_dxt, 1e-10), -1.0, 1.0))
83
+
84
+ # Only use cross-track distance if the perpendicular foot
85
+ # falls within the segment, not beyond either endpoint
86
+ if 0.0 <= dat_rad <= seg.length_rad:
87
+ min_km = min(min_km, float(degrees2kilometers(np.rad2deg(abs(dxt_rad)))))
88
+
89
+ return min_km
90
+
91
+
92
+ def lon_bounds_dateline_safe(lons_wrapped):
93
+ """
94
+ Return (lon_min, lon_max), possibly with lon_min > lon_max to indicate
95
+ dateline crossing. Chooses the shortest longitude interval containing
96
+ all points.
97
+ """
98
+ lons = np.array([wrap_lon_deg(x) for x in lons_wrapped], dtype=float)
99
+ s = np.sort(lons)
100
+
101
+ gaps = np.diff(np.r_[s, s[0] + 360.0])
102
+ k = int(np.argmax(gaps))
103
+
104
+ lon_min = s[(k + 1) % len(s)]
105
+ lon_max = s[k]
106
+
107
+ return wrap_lon_deg(lon_min), wrap_lon_deg(lon_max)
108
+
109
+
110
+ def _compute_segment(lats_deg, lons_deg, i: int) -> TrackSegment:
111
+ """
112
+ Compute a single TrackSegment on the fly from degree arrays.
113
+ Used when no precomputed segment list is available.
114
+ """
115
+ lat_a = np.deg2rad(lats_deg[i])
116
+ lon_a = np.deg2rad(lons_deg[i])
117
+ lat_b = np.deg2rad(lats_deg[i + 1])
118
+ lon_b = np.deg2rad(lons_deg[i + 1])
119
+
120
+ return TrackSegment(
121
+ lat_a_rad=lat_a,
122
+ lon_a_rad=lon_a,
123
+ lat_b_rad=lat_b,
124
+ lon_b_rad=lon_b,
125
+ bearing_ab_rad=_bearing_rad(lat_a, lon_a, lat_b, lon_b),
126
+ length_rad=np.deg2rad(float(locations2degrees(
127
+ lats_deg[i], lons_deg[i],
128
+ lats_deg[i + 1], lons_deg[i + 1],
129
+ ))),
130
+ )
131
+
132
+
133
+ def _bearing_rad(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
134
+ """
135
+ Initial bearing in radians from point 1 to point 2 (all inputs in radians).
136
+ """
137
+ dlon = lon2 - lon1
138
+ x = np.sin(dlon) * np.cos(lat2)
139
+ y = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(dlon)
140
+ return float(np.arctan2(x, y))
groundtrack/io.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def ensure_dir(path: str | Path) -> Path:
8
+ """
9
+ Make sure a directory exists and return it as a Path object.
10
+
11
+ Why this exists:
12
+ ----------------
13
+ A lot of this pipeline depends on folders being present before we write
14
+ cache files, manifests, or downloaded data. This keeps that logic in one
15
+ place instead of repeating mkdir everywhere.
16
+ """
17
+ path = Path(path)
18
+ path.mkdir(parents=True, exist_ok=True)
19
+ return path
20
+
21
+
22
+ def get_tle_cache_dir(base_cache_dir: str | Path) -> Path:
23
+ """
24
+ Return the TLE cache directory and create it if needed.
25
+
26
+ Example:
27
+ cache/
28
+ tle/
29
+
30
+ Why:
31
+ ----
32
+ TLE caching is a must so we do not keep hitting the Space-Track API and
33
+ risk running into request limits.
34
+ """
35
+ base_cache_dir = ensure_dir(base_cache_dir)
36
+ return ensure_dir(base_cache_dir / "tle")
37
+
38
+
39
+ def get_tle_cache_path(base_cache_dir: str | Path, cache_key: str) -> Path:
40
+ """
41
+ Return the full file path for one cached TLE file.
42
+ """
43
+ tle_cache_dir = get_tle_cache_dir(base_cache_dir)
44
+ return tle_cache_dir / cache_key
45
+
46
+
47
+ def get_run_folder(output_base: str | Path, event_name: str) -> Path:
48
+ """
49
+ Return the main output folder for one event/run.
50
+
51
+ Example:
52
+ output_base/
53
+ shenzhou15_20240402T084000Z_to_20240402T090000Z/
54
+
55
+ Why:
56
+ ----
57
+ This keeps event outputs grouped cleanly and makes the demo notebook
58
+ easier to read.
59
+ """
60
+ output_base = ensure_dir(output_base)
61
+ return ensure_dir(output_base / event_name)
62
+
63
+
64
+ def write_json(path: str | Path, obj) -> Path:
65
+ """
66
+ Write a JSON artifact to disk.
67
+
68
+ Why:
69
+ ----
70
+ We use JSON for simple reproducibility artifacts like:
71
+ - selected TLE metadata
72
+ - propagation metadata
73
+ - request plans
74
+ - manifests
75
+ """
76
+ path = Path(path)
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+
79
+ with open(path, "w", encoding="utf-8") as f:
80
+ json.dump(obj, f, indent=2, default=str)
81
+
82
+ return path