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.
- groundtrack/__init__.py +23 -0
- groundtrack/download.py +386 -0
- groundtrack/geodesy.py +140 -0
- groundtrack/io.py +82 -0
- groundtrack/pipeline.py +187 -0
- groundtrack/plotting.py +480 -0
- groundtrack/processing.py +509 -0
- groundtrack/stations.py +216 -0
- groundtrack/tiling.py +159 -0
- groundtrack/track.py +345 -0
- groundtrack/types.py +61 -0
- groundtrack-0.1.0.dist-info/METADATA +348 -0
- groundtrack-0.1.0.dist-info/RECORD +16 -0
- groundtrack-0.1.0.dist-info/WHEEL +5 -0
- groundtrack-0.1.0.dist-info/licenses/LICENSE +21 -0
- groundtrack-0.1.0.dist-info/top_level.txt +1 -0
groundtrack/__init__.py
ADDED
|
@@ -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
|
+
)
|
groundtrack/download.py
ADDED
|
@@ -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
|