bepad 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.
- bepad/__init__.py +17 -0
- bepad/__main__.py +206 -0
- bepad/associator.py +339 -0
- bepad/build/__init__.py +11 -0
- bepad/build/config.py +116 -0
- bepad/build/geo.py +64 -0
- bepad/build/labels.py +341 -0
- bepad/build/pool.py +392 -0
- bepad/build/release.py +256 -0
- bepad/build/utdquake.py +76 -0
- bepad/build/windows.py +408 -0
- bepad/data.py +185 -0
- bepad/leaderboard.py +368 -0
- bepad/metrics.py +324 -0
- bepad/plots.py +950 -0
- bepad/stats.py +140 -0
- bepad/torch.py +159 -0
- bepad-0.1.0.dist-info/METADATA +85 -0
- bepad-0.1.0.dist-info/RECORD +23 -0
- bepad-0.1.0.dist-info/WHEEL +5 -0
- bepad-0.1.0.dist-info/entry_points.txt +2 -0
- bepad-0.1.0.dist-info/licenses/LICENSE +674 -0
- bepad-0.1.0.dist-info/top_level.txt +1 -0
bepad/build/utdquake.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""UTDQuake tables as the pool reads them (UTDQuake 1.0 and later).
|
|
2
|
+
|
|
3
|
+
UTDQuake names things this way:
|
|
4
|
+
|
|
5
|
+
* ``utdq_net``: the UTDQuake network, i.e. the catalog or project that reported the
|
|
6
|
+
events and picks (``tx``, ``AEIC``, ``R05`` ...). Key of every UTDQuake table.
|
|
7
|
+
* ``network``: the FDSN code of the station that recorded a pick (stations and picks
|
|
8
|
+
tables only).
|
|
9
|
+
|
|
10
|
+
In BEPAD, ``network`` is the UTDQuake network (it names windows, pool folders and release
|
|
11
|
+
files), the FDSN code of a station is ``station_network``, and a station is ``NET.STA``
|
|
12
|
+
(``2T.EF51``): unique within a UTDQuake network, where a plain code may not be (``UU.SPU`` in
|
|
13
|
+
Utah and ``AV.SPU`` in Alaska are both in ISC). UTDQuake is read from ``$UTDQUAKE_ROOT`` at
|
|
14
|
+
``$UTDQUAKE_REVISION`` (a Hugging Face commit; ``main`` if unset).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def revision():
|
|
24
|
+
"""The UTDQuake Hugging Face revision the build reads (``UTDQUAKE_REVISION``), None = main."""
|
|
25
|
+
return os.environ.get("UTDQUAKE_REVISION", "").strip() or None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def network_table():
|
|
29
|
+
"""UTDQuake's (non-DAS) network table, one row per ``utdq_net``: the local copy under
|
|
30
|
+
``$UTDQUAKE_ROOT`` if there is one (parallel pool jobs then never query the Hub), else the Hub."""
|
|
31
|
+
import utdquake as utdq
|
|
32
|
+
|
|
33
|
+
ds = utdq.Dataset(das=False)
|
|
34
|
+
try:
|
|
35
|
+
return ds.local_networks
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
return ds.networks
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_networks():
|
|
41
|
+
return sorted(network_table()["utdq_net"].astype(str))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def station_key(network, station):
|
|
45
|
+
"""``NET.STA`` from FDSN network and station codes (arrays or Series)."""
|
|
46
|
+
return pd.Series(network, dtype="string").str.cat(pd.Series(station, dtype="string").to_numpy(), sep=".") \
|
|
47
|
+
.to_numpy(dtype=object)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_network(name):
|
|
51
|
+
"""(events, picks, stations) of one UTDQuake network.
|
|
52
|
+
|
|
53
|
+
The ``utdq_net`` column is dropped; picks and stations get ``station_network`` (FDSN code)
|
|
54
|
+
and ``station`` = ``NET.STA``, so picks join stations on ``station``.
|
|
55
|
+
"""
|
|
56
|
+
import utdquake as utdq
|
|
57
|
+
|
|
58
|
+
net = utdq.Dataset(das=False).get_network(name=name)
|
|
59
|
+
events = net.events.drop(columns=["utdq_net"], errors="ignore")
|
|
60
|
+
out = []
|
|
61
|
+
for df in (net.picks, net.stations):
|
|
62
|
+
df = df.drop(columns=["utdq_net"], errors="ignore").rename(columns={"network": "station_network"})
|
|
63
|
+
df["code"] = df["station"].astype(str)
|
|
64
|
+
df["station"] = station_key(df["station_network"], df["code"])
|
|
65
|
+
out.append(df)
|
|
66
|
+
return events, out[0], out[1]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def network_info(name):
|
|
70
|
+
"""Provenance of one network for the pool manifest (catalog, UTDQuake dataset version)."""
|
|
71
|
+
try:
|
|
72
|
+
row = network_table().set_index("utdq_net").loc[name]
|
|
73
|
+
return {k: (None if pd.isna(row.get(k)) else str(row.get(k)))
|
|
74
|
+
for k in ("catalog", "catalog_type", "dataset_version", "provider")}
|
|
75
|
+
except Exception as exc: # informative only
|
|
76
|
+
return {"error": repr(exc)}
|
bepad/build/windows.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""One window: real events on a shared timeline, plus generated picks and noise.
|
|
2
|
+
|
|
3
|
+
A window is a pure function of (pool, label model, window config, seed).
|
|
4
|
+
|
|
5
|
+
1. events n events of one split, all within +/- event_time_neighborhood_days
|
|
6
|
+
of an anchor event (so the station geometry existed at that time);
|
|
7
|
+
optionally one edge event starting before t = 0
|
|
8
|
+
2. stations every pool station that picked any event in that period
|
|
9
|
+
3. timeline length = length_factor x longest event duration; origins uniform in
|
|
10
|
+
the first (1 - tail_frac) of the window; the end is extended so every
|
|
11
|
+
event passes the pool rule inside the window (>= 4 P picks, >= 3 stations);
|
|
12
|
+
an edge event starts early enough before t = 0 that its late picks pass it too
|
|
13
|
+
4. real arrival = new origin + REAL travel time; picks outside the window are cut
|
|
14
|
+
5. generated round(ratio x real picks), ratio ~ U(generated_ratio): at stations that
|
|
15
|
+
did not pick that event, inside its band, between its first and last
|
|
16
|
+
real pick and where the gap rule allows (bepad.build.labels)
|
|
17
|
+
6. noise round(noise_ratio x real picks): 90% uniform in distance to a window
|
|
18
|
+
event, 10% on any station, uniform in time -- and never inside any
|
|
19
|
+
event's band (rejection sampling)
|
|
20
|
+
7. labels event_idx (-1 noise), pick_type, real (1/0), prob, sigma_s. No pick is ever
|
|
21
|
+
removed or merged: picks of two events close in time at one station are both
|
|
22
|
+
kept with their own event (a hard case for the associator, as in a swarm)
|
|
23
|
+
8. check every event has >= event_min_p real P picks, >= event_min_s real S picks and
|
|
24
|
+
>= event_min_stations stations INSIDE the window; otherwise the window is
|
|
25
|
+
regenerated from a seed derived from (seed, attempt)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
from .geo import azimuthal_gap_deg, haversine_km
|
|
36
|
+
from .labels import EventArrivals, LabelModel
|
|
37
|
+
|
|
38
|
+
NOISE = -1
|
|
39
|
+
PICK_COLUMNS = ["window_id", "pick_idx", "station", "phase", "t_rel_s", "time", "event_idx", "pick_type",
|
|
40
|
+
"real", "prob", "sigma_s", "in_scorable_event", "src_event_id", "src_pick_id"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Window:
|
|
45
|
+
window: dict # one metadata row
|
|
46
|
+
picks: pd.DataFrame # one row per pick
|
|
47
|
+
events: pd.DataFrame # one row per event in the window
|
|
48
|
+
stations: pd.DataFrame # stations available in the window
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NetworkIndex:
|
|
52
|
+
"""In-memory, time-sorted view of one network of the pool + the label model."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, events, picks, stations, network, label_model: LabelModel):
|
|
55
|
+
self.network = network
|
|
56
|
+
self.model = label_model
|
|
57
|
+
self.events = events.sort_values("time").reset_index(drop=True)
|
|
58
|
+
self.ev_t = self.events["time"].to_numpy("datetime64[ns]").astype(np.int64) / 1e9
|
|
59
|
+
self.stations = stations.set_index("station")
|
|
60
|
+
mag = pd.to_numeric(self.events["magnitude"], errors="coerce")
|
|
61
|
+
self.magnitude = mag.fillna(label_model.median_magnitude(network)).to_numpy(float)
|
|
62
|
+
|
|
63
|
+
picks = picks.merge(self.events[["event_id", "time"]].rename(columns={"time": "event_time"}),
|
|
64
|
+
on="event_id", how="inner")
|
|
65
|
+
picks = picks.sort_values("event_time").reset_index(drop=True)
|
|
66
|
+
self.pk_ev_t = picks["event_time"].to_numpy("datetime64[ns]").astype(np.int64) / 1e9
|
|
67
|
+
codes, self.station_names = pd.factorize(picks["station"], sort=True)
|
|
68
|
+
self.pk_station = codes.astype(np.int32) # integer codes: fast unique()
|
|
69
|
+
self.picks = picks[["event_id", "station", "phase", "travel_time", "hyp_distance_km", "pick_id"]]
|
|
70
|
+
self.pick_rows = picks.groupby("event_id").indices
|
|
71
|
+
|
|
72
|
+
def neighborhood(self, t, days):
|
|
73
|
+
if days is None:
|
|
74
|
+
return 0, len(self.ev_t)
|
|
75
|
+
return np.searchsorted(self.ev_t, [t - days * 86400, t + days * 86400])
|
|
76
|
+
|
|
77
|
+
def active_stations(self, t, days):
|
|
78
|
+
if days is None:
|
|
79
|
+
return np.asarray(self.station_names, dtype=object)
|
|
80
|
+
lo, hi = np.searchsorted(self.pk_ev_t, [t - days * 86400, t + days * 86400])
|
|
81
|
+
present = np.bincount(self.pk_station[lo:hi], minlength=len(self.station_names)) > 0
|
|
82
|
+
return np.asarray(self.station_names, dtype=object)[present]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ----------------------------------------------------------------------
|
|
86
|
+
def _candidate_mask(events, split, cfg):
|
|
87
|
+
m = (events["split"] == split).to_numpy() & (events["duration_s"] > 0).to_numpy()
|
|
88
|
+
for col, key in (("gap_deg", "gap_range"), ("magnitude", "magnitude_range")):
|
|
89
|
+
rng_ = cfg.get(key)
|
|
90
|
+
if rng_ is not None:
|
|
91
|
+
v = pd.to_numeric(events[col], errors="coerce").to_numpy()
|
|
92
|
+
m &= (v >= rng_[0]) & (v <= rng_[1])
|
|
93
|
+
return m
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sample_events(idx, split, cfg, rng):
|
|
97
|
+
"""Anchor + (n - 1) neighbours (+ optional edge event) from the same split."""
|
|
98
|
+
cand_pos = np.flatnonzero(_candidate_mask(idx.events, split, cfg))
|
|
99
|
+
if len(cand_pos) == 0:
|
|
100
|
+
return None
|
|
101
|
+
lo_n, hi_n = cfg["n_events"]
|
|
102
|
+
n = int(rng.integers(lo_n, hi_n + 1))
|
|
103
|
+
want_edge = rng.random() < cfg["edge_event_prob"]
|
|
104
|
+
need = n + int(want_edge)
|
|
105
|
+
anchor = int(rng.choice(cand_pos))
|
|
106
|
+
days = cfg["event_time_neighborhood_days"]
|
|
107
|
+
while True:
|
|
108
|
+
lo, hi = idx.neighborhood(idx.ev_t[anchor], days)
|
|
109
|
+
pool = cand_pos[(cand_pos >= lo) & (cand_pos < hi) & (cand_pos != anchor)]
|
|
110
|
+
if len(pool) >= need - 1 or days is None:
|
|
111
|
+
break
|
|
112
|
+
days = days * 2 if days < 20 * 365 else None # widen, then give up on locality
|
|
113
|
+
others = rng.choice(pool, size=min(need - 1, len(pool)), replace=False)
|
|
114
|
+
chosen = np.concatenate([[anchor], others]).astype(int)
|
|
115
|
+
edge = chosen[n] if (want_edge and len(chosen) > n) else None
|
|
116
|
+
return chosen[:n], edge, anchor, days
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _overlap_stats(first, last):
|
|
120
|
+
n = len(first)
|
|
121
|
+
pairs = sum(1 for i in range(n) for j in range(i + 1, n) if first[i] <= last[j] and first[j] <= last[i])
|
|
122
|
+
ev = sorted([(f, 1) for f in first] + [(l, -1) for l in last], key=lambda x: (x[0], -x[1]))
|
|
123
|
+
cur = best = 0
|
|
124
|
+
for _, d in ev:
|
|
125
|
+
cur += d
|
|
126
|
+
best = max(best, cur)
|
|
127
|
+
return pairs, best
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _rule_time(p, cfg):
|
|
131
|
+
"""Travel time after which an event's real picks pass the rule, or None if they never do."""
|
|
132
|
+
tp = np.sort(p.loc[p.phase == "P", "travel_time"].to_numpy())
|
|
133
|
+
ts = np.sort(p.loc[p.phase == "S", "travel_time"].to_numpy())
|
|
134
|
+
first = np.sort(p.groupby("station")["travel_time"].min().to_numpy())
|
|
135
|
+
kp, ks, kst = cfg["event_min_p"], cfg["event_min_s"], cfg["event_min_stations"]
|
|
136
|
+
if len(tp) < kp or len(ts) < ks or len(first) < kst:
|
|
137
|
+
return None
|
|
138
|
+
return max(tp[kp - 1] if kp else 0.0, ts[ks - 1] if ks else 0.0, first[kst - 1] if kst else 0.0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _edge_shift(p, cfg):
|
|
142
|
+
"""Largest origin offset before t = 0 that still leaves the rule satisfied by the late picks."""
|
|
143
|
+
tp = np.sort(p.loc[p.phase == "P", "travel_time"].to_numpy())[::-1]
|
|
144
|
+
ts = np.sort(p.loc[p.phase == "S", "travel_time"].to_numpy())[::-1]
|
|
145
|
+
last = np.sort(p.groupby("station")["travel_time"].max().to_numpy())[::-1]
|
|
146
|
+
kp, ks, kst = cfg["event_min_p"], cfg["event_min_s"], cfg["event_min_stations"]
|
|
147
|
+
if len(tp) < kp or len(ts) < ks or len(last) < kst:
|
|
148
|
+
return None
|
|
149
|
+
return min(tp[kp - 1] if kp else np.inf, ts[ks - 1] if ks else np.inf, last[kst - 1] if kst else np.inf)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _passes_rule(picks, cfg):
|
|
153
|
+
r = picks[picks.real == 1]
|
|
154
|
+
g = r.groupby("event_idx")
|
|
155
|
+
n_p = (r.phase == "P").groupby(r.event_idx).sum()
|
|
156
|
+
n_s = (r.phase == "S").groupby(r.event_idx).sum()
|
|
157
|
+
return bool(((n_p >= cfg["event_min_p"]) & (n_s >= cfg["event_min_s"])
|
|
158
|
+
& (g.station.nunique() >= cfg["event_min_stations"])).all())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _truncated_normal(rng, k, size):
|
|
162
|
+
z = rng.standard_normal(size)
|
|
163
|
+
bad = np.abs(z) > k
|
|
164
|
+
while bad.any():
|
|
165
|
+
z[bad] = rng.standard_normal(bad.sum())
|
|
166
|
+
bad = np.abs(z) > k
|
|
167
|
+
return z
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ----------------------------------------------------------------------
|
|
171
|
+
def generate_window(idx: NetworkIndex, split, cfg, seed, window_id, scenario="custom", max_attempts=20):
|
|
172
|
+
"""One window. Deterministic in ``seed``; retried (derived seeds) until every event passes the rule."""
|
|
173
|
+
from .geo import stable_seed
|
|
174
|
+
|
|
175
|
+
for attempt in range(max_attempts):
|
|
176
|
+
s = seed if attempt == 0 else stable_seed(seed, "retry", attempt)
|
|
177
|
+
w = _generate_once(idx, split, cfg, s, window_id, scenario)
|
|
178
|
+
if w is None:
|
|
179
|
+
return None
|
|
180
|
+
if _passes_rule(w.picks, cfg):
|
|
181
|
+
w.window["seed"], w.window["attempts"] = int(seed), attempt + 1
|
|
182
|
+
return w
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _generate_once(idx: NetworkIndex, split, cfg, seed, window_id, scenario="custom"):
|
|
187
|
+
rng = np.random.default_rng(seed)
|
|
188
|
+
model = idx.model
|
|
189
|
+
sampled = _sample_events(idx, split, cfg, rng)
|
|
190
|
+
if sampled is None:
|
|
191
|
+
return None
|
|
192
|
+
main, edge, anchor, days_used = sampled
|
|
193
|
+
|
|
194
|
+
# --- stations ------------------------------------------------------------
|
|
195
|
+
stations = idx.active_stations(idx.ev_t[anchor], days_used)
|
|
196
|
+
if cfg["station_keep_frac"] < 1.0:
|
|
197
|
+
k = max(3, int(round(cfg["station_keep_frac"] * len(stations))))
|
|
198
|
+
stations = np.sort(rng.choice(stations, size=min(k, len(stations)), replace=False))
|
|
199
|
+
st = idx.stations.loc[stations]
|
|
200
|
+
st_lat, st_lon = st["latitude"].to_numpy(), st["longitude"].to_numpy()
|
|
201
|
+
station_set = set(stations)
|
|
202
|
+
|
|
203
|
+
# --- events: real picks on window stations, arrivals, distances ------------
|
|
204
|
+
rows = list(main) + ([edge] if edge is not None else [])
|
|
205
|
+
E = len(rows)
|
|
206
|
+
ev_df = idx.events.loc[rows]
|
|
207
|
+
R = np.stack([np.hypot(haversine_km(e.latitude, e.longitude, st_lat, st_lon), e.depth / 1e3)
|
|
208
|
+
for e in ev_df.itertuples()]) # (E, S) hypocentral distance
|
|
209
|
+
real, arrivals, pool_have, dur, nth = [], [], [], [], []
|
|
210
|
+
for r in rows:
|
|
211
|
+
eid = idx.events.at[r, "event_id"]
|
|
212
|
+
p = idx.picks.iloc[idx.pick_rows[eid]]
|
|
213
|
+
arrivals.append(EventArrivals(model, idx.network, p["hyp_distance_km"], p["travel_time"], p["phase"],
|
|
214
|
+
idx.events.at[r, "last_station_km"]))
|
|
215
|
+
pool_have.append(set(zip(p["phase"], p["station"]))) # every real pick of the event
|
|
216
|
+
p = p[p["station"].isin(station_set)]
|
|
217
|
+
real.append(p)
|
|
218
|
+
dur.append(float(p["travel_time"].max()) if len(p) else 0.0)
|
|
219
|
+
nth.append(_rule_time(p, cfg))
|
|
220
|
+
if any(v is None for v in nth[:len(main)]):
|
|
221
|
+
return None # an event cannot pass the rule on these stations (station subsampling)
|
|
222
|
+
edge_shift = _edge_shift(real[-1], cfg) if edge is not None else None
|
|
223
|
+
if edge is not None and edge_shift is None: # drop an edge event that cannot pass
|
|
224
|
+
rows, real, arrivals, pool_have, dur, nth = rows[:-1], real[:-1], arrivals[:-1], pool_have[:-1], dur[:-1], nth[:-1]
|
|
225
|
+
edge = None
|
|
226
|
+
E = len(rows)
|
|
227
|
+
R = R[:E]
|
|
228
|
+
ev_df = ev_df.iloc[:E]
|
|
229
|
+
dur, nth = np.array(dur), np.array(nth[:len(main)], float)
|
|
230
|
+
n_main = len(main)
|
|
231
|
+
|
|
232
|
+
# --- timeline ---------------------------------------------------------------
|
|
233
|
+
length = cfg["length_factor"] * float(dur[:n_main].max()) if cfg["length_s"] == "auto" else float(cfg["length_s"])
|
|
234
|
+
t0 = np.empty(n_main)
|
|
235
|
+
t0[rng.permutation(n_main)] = np.sort(rng.uniform(0, (1 - cfg["tail_frac"]) * length, n_main))
|
|
236
|
+
end = max(length, float(np.max(t0 + nth[:n_main])))
|
|
237
|
+
if edge is not None:
|
|
238
|
+
t0 = np.append(t0, -rng.uniform(0, edge_shift))
|
|
239
|
+
t_ref = idx.events.at[anchor, "time"] - pd.to_timedelta(t0[rows.index(anchor)], unit="s")
|
|
240
|
+
ev_order = np.argsort(t0, kind="stable")
|
|
241
|
+
e_idx = np.empty(E, int)
|
|
242
|
+
e_idx[ev_order] = np.arange(E) # event_idx = rank of origin time
|
|
243
|
+
mag = idx.magnitude[rows]
|
|
244
|
+
|
|
245
|
+
# predicted arrivals at every window station (E, S) and timing sigma
|
|
246
|
+
MU = {ph: np.stack([arrivals[j].mu(ph, R[j]) for j in range(E)]) for ph in ("P", "S")}
|
|
247
|
+
SIG = model.sigma(R)
|
|
248
|
+
k_band = model.band_k
|
|
249
|
+
|
|
250
|
+
# --- real picks ---------------------------------------------------------------
|
|
251
|
+
pos = {s: i for i, s in enumerate(stations)}
|
|
252
|
+
parts, n_trunc = [], 0
|
|
253
|
+
for j, p in enumerate(real):
|
|
254
|
+
t = t0[j] + p["travel_time"].to_numpy()
|
|
255
|
+
inside = (t >= 0) & (t <= end)
|
|
256
|
+
n_trunc += int((~inside).sum())
|
|
257
|
+
sidx = p["station"].map(pos).to_numpy()[inside]
|
|
258
|
+
r_ = R[j, sidx]
|
|
259
|
+
parts.append(pd.DataFrame({
|
|
260
|
+
"station": p["station"].to_numpy()[inside], "phase": p["phase"].to_numpy()[inside],
|
|
261
|
+
"t_rel_s": t[inside], "event_idx": e_idx[j], "pick_type": "real", "real": 1,
|
|
262
|
+
"prob": model.prob_real(mag[j], r_), "sigma_s": model.sigma(r_),
|
|
263
|
+
"src_event_id": idx.events.at[rows[j], "event_id"], "src_pick_id": p["pick_id"].to_numpy()[inside],
|
|
264
|
+
}))
|
|
265
|
+
picks = pd.concat(parts, ignore_index=True)
|
|
266
|
+
n_real = len(picks)
|
|
267
|
+
|
|
268
|
+
# --- generated picks --------------------------------------------------------------
|
|
269
|
+
lo_g, hi_g = cfg["generated_ratio"] if isinstance(cfg["generated_ratio"], (list, tuple)) else \
|
|
270
|
+
(cfg["generated_ratio"], cfg["generated_ratio"])
|
|
271
|
+
gen_ratio = float(rng.uniform(lo_g, hi_g)) if hi_g > lo_g else float(lo_g)
|
|
272
|
+
n_gen_target = int(round(gen_ratio * n_real))
|
|
273
|
+
gen = []
|
|
274
|
+
if n_gen_target:
|
|
275
|
+
slots = [] # (event j, phase, station index)
|
|
276
|
+
for j in range(E):
|
|
277
|
+
for ph in ("P", "S"):
|
|
278
|
+
taken = np.isin(stations, [s_ for p_, s_ in pool_have[j] if p_ == ph])
|
|
279
|
+
ok = arrivals[j].can_generate(ph, R[j]) & ~taken
|
|
280
|
+
slots.extend((j, ph, si) for si in np.flatnonzero(ok))
|
|
281
|
+
if slots:
|
|
282
|
+
w = np.array([len(real[j]) + 1.0 for j, _, _ in slots])
|
|
283
|
+
take = rng.choice(len(slots), size=min(n_gen_target, len(slots)), replace=False, p=w / w.sum())
|
|
284
|
+
z = _truncated_normal(rng, k_band, len(take))
|
|
285
|
+
for (j, ph, si), zz in zip((slots[i] for i in take), z):
|
|
286
|
+
t = t0[j] + MU[ph][j, si] + zz * SIG[j, si]
|
|
287
|
+
if 0 <= t <= end:
|
|
288
|
+
gen.append({"station": stations[si], "phase": ph, "t_rel_s": t, "event_idx": e_idx[j],
|
|
289
|
+
"pick_type": "generated", "real": 0,
|
|
290
|
+
"prob": float(model.prob_generated(mag[j], R[j, si], zz)),
|
|
291
|
+
"sigma_s": float(SIG[j, si]), "src_event_id": idx.events.at[rows[j], "event_id"],
|
|
292
|
+
"src_pick_id": None})
|
|
293
|
+
if gen:
|
|
294
|
+
picks = pd.concat([picks, pd.DataFrame(gen)], ignore_index=True)
|
|
295
|
+
|
|
296
|
+
# --- noise (never inside a band) ----------------------------------------------------
|
|
297
|
+
n_noise_target = int(round(cfg["noise_ratio"] * n_real))
|
|
298
|
+
noise_st, noise_t, tries = [], [], 0
|
|
299
|
+
r_max = 1.1 * float(np.nanmax(R[:, [pos[s] for s in picks.loc[picks.real == 1, "station"].unique()]])) \
|
|
300
|
+
if n_real else float(R.max())
|
|
301
|
+
while len(noise_t) < n_noise_target and tries < 30:
|
|
302
|
+
m = 2 * (n_noise_target - len(noise_t)) + 8
|
|
303
|
+
anyst = rng.random(m) < cfg["noise_any_station_frac"]
|
|
304
|
+
j = rng.integers(0, n_main, m)
|
|
305
|
+
target = rng.uniform(0, r_max, m)
|
|
306
|
+
si = np.where(anyst, rng.integers(0, len(stations), m),
|
|
307
|
+
np.abs(R[j] - target[:, None]).argmin(axis=1))
|
|
308
|
+
t = rng.uniform(0, end, m)
|
|
309
|
+
dt_ = t[:, None] - t0[None, :] # (m, E)
|
|
310
|
+
inband = np.zeros(m, bool)
|
|
311
|
+
for ph in ("P", "S"):
|
|
312
|
+
inband |= (np.abs(dt_ - MU[ph][:, si].T) <= k_band * SIG[:, si].T).any(axis=1)
|
|
313
|
+
keep = np.flatnonzero(~inband)[: n_noise_target - len(noise_t)]
|
|
314
|
+
noise_st.extend(si[keep])
|
|
315
|
+
noise_t.extend(t[keep])
|
|
316
|
+
tries += 1
|
|
317
|
+
if noise_t:
|
|
318
|
+
n_ = len(noise_t)
|
|
319
|
+
phase_p = (picks["phase"] == "P").mean() if len(picks) else 0.5
|
|
320
|
+
picks = pd.concat([picks, pd.DataFrame({
|
|
321
|
+
"station": stations[np.array(noise_st)], "phase": np.where(rng.random(n_) < phase_p, "P", "S"),
|
|
322
|
+
"t_rel_s": np.array(noise_t), "event_idx": NOISE, "pick_type": "noise", "real": 0, "prob": 0.0,
|
|
323
|
+
"sigma_s": np.nan, "src_event_id": None, "src_pick_id": None})], ignore_index=True)
|
|
324
|
+
|
|
325
|
+
# --- ordering, absolute time -------------------------------------------------------------
|
|
326
|
+
picks = picks.sort_values("t_rel_s", kind="stable").reset_index(drop=True)
|
|
327
|
+
picks.insert(0, "pick_idx", np.arange(len(picks)))
|
|
328
|
+
picks.insert(0, "window_id", window_id)
|
|
329
|
+
picks["time"] = t_ref + pd.to_timedelta(picks["t_rel_s"], unit="s")
|
|
330
|
+
|
|
331
|
+
# --- events table -----------------------------------------------------------------------
|
|
332
|
+
ev_rows = []
|
|
333
|
+
for k, j in enumerate(ev_order):
|
|
334
|
+
e = idx.events.loc[rows[j]]
|
|
335
|
+
pe = picks[picks["event_idx"] == k]
|
|
336
|
+
pr = pe[pe["real"] == 1]
|
|
337
|
+
sta = pr["station"].unique()
|
|
338
|
+
s_ = idx.stations.loc[sta]
|
|
339
|
+
ev_rows.append({
|
|
340
|
+
"window_id": window_id, "event_idx": k, "t0_rel_s": t0[j],
|
|
341
|
+
"origin_time": t_ref + pd.to_timedelta(t0[j], unit="s"),
|
|
342
|
+
"latitude": e["latitude"], "longitude": e["longitude"], "depth": e["depth"],
|
|
343
|
+
"magnitude": e["magnitude"], "magnitude_type": e["magnitude_type"],
|
|
344
|
+
"n_picks": len(pe), "n_real_picks": len(pr), "n_generated_picks": int((pe["pick_type"] == "generated").sum()),
|
|
345
|
+
"n_p": int((pr["phase"] == "P").sum()), "n_s": int((pr["phase"] == "S").sum()), "n_stations": len(sta),
|
|
346
|
+
"gap_deg": azimuthal_gap_deg(e["latitude"], e["longitude"], s_["latitude"].to_numpy(), s_["longitude"].to_numpy()),
|
|
347
|
+
"last_station_km": e["last_station_km"],
|
|
348
|
+
"first_pick_s": pe["t_rel_s"].min() if len(pe) else np.nan,
|
|
349
|
+
"last_pick_s": pe["t_rel_s"].max() if len(pe) else np.nan,
|
|
350
|
+
"is_edge": rows[j] == edge,
|
|
351
|
+
"scorable": (int((pr["phase"] == "P").sum()) >= cfg["event_min_p"]
|
|
352
|
+
and int((pr["phase"] == "S").sum()) >= cfg["event_min_s"]
|
|
353
|
+
and len(sta) >= cfg["event_min_stations"]),
|
|
354
|
+
"src_event_id": e["event_id"], "src_origin_time": e["time"],
|
|
355
|
+
})
|
|
356
|
+
events = pd.DataFrame(ev_rows)
|
|
357
|
+
picks["in_scorable_event"] = picks["event_idx"].isin(set(events.loc[events["scorable"], "event_idx"]))
|
|
358
|
+
|
|
359
|
+
# --- window metadata -------------------------------------------------------------------------
|
|
360
|
+
sc = events[events["scorable"]]
|
|
361
|
+
n_type = picks["pick_type"].value_counts()
|
|
362
|
+
seen = events.dropna(subset=["first_pick_s"])
|
|
363
|
+
pairs, conc = _overlap_stats(seen["first_pick_s"].to_numpy(), seen["last_pick_s"].to_numpy())
|
|
364
|
+
t0s = np.sort(events["t0_rel_s"].to_numpy())
|
|
365
|
+
real_pk = picks[picks["real"] == 1]
|
|
366
|
+
src_t = pd.to_datetime(events["src_origin_time"])
|
|
367
|
+
window = {
|
|
368
|
+
"window_id": window_id, "network": idx.network, "split": split, "scenario": scenario, "seed": int(seed),
|
|
369
|
+
"t_start": t_ref, "length_s": end,
|
|
370
|
+
"n_stations": len(station_set), "n_active_stations": int(real_pk["station"].nunique()),
|
|
371
|
+
"n_events": len(events), "n_scorable_events": len(sc), "n_edge_events": int(events["is_edge"].sum()),
|
|
372
|
+
"n_picks": len(picks), "n_real_picks": int(n_type.get("real", 0)),
|
|
373
|
+
"n_generated_picks": int(n_type.get("generated", 0)), "n_noise_picks": int(n_type.get("noise", 0)),
|
|
374
|
+
"noise_ratio_target": cfg["noise_ratio"], "generated_ratio_target": gen_ratio,
|
|
375
|
+
"noise_ratio": n_type.get("noise", 0) / max(n_type.get("real", 0), 1),
|
|
376
|
+
"noise_shortfall": max(n_noise_target - len(noise_t), 0),
|
|
377
|
+
"n_truncated_picks": n_trunc,
|
|
378
|
+
"min_origin_sep_s": float(np.diff(t0s).min()) if len(t0s) > 1 else np.nan,
|
|
379
|
+
"n_overlapping_pairs": pairs, "max_concurrent_events": conc,
|
|
380
|
+
"mag_min": sc["magnitude"].min(), "mag_max": sc["magnitude"].max(),
|
|
381
|
+
"depth_min_km": sc["depth"].min() / 1e3, "depth_max_km": sc["depth"].max() / 1e3,
|
|
382
|
+
"gap_min_deg": sc["gap_deg"].min(), "gap_max_deg": sc["gap_deg"].max(),
|
|
383
|
+
"max_hyp_dist_km": float(np.nanmax(R[:, [pos[s] for s in real_pk["station"].unique()]])) if len(real_pk) else np.nan,
|
|
384
|
+
"src_time_span_days": (src_t.max() - src_t.min()).total_seconds() / 86400,
|
|
385
|
+
"anchor_event_id": idx.events.at[anchor, "event_id"],
|
|
386
|
+
}
|
|
387
|
+
stations_df = pd.DataFrame({"window_id": window_id, "station": sorted(station_set)})
|
|
388
|
+
return Window(window=window, picks=picks[PICK_COLUMNS], events=events, stations=stations_df)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class WindowGenerator:
|
|
392
|
+
"""On-the-fly windows: ``WindowGenerator(data_root, "tx", "train", {...}).sample(seed)``."""
|
|
393
|
+
|
|
394
|
+
def __init__(self, data_root, network, split, window_cfg=None, scenario="custom"):
|
|
395
|
+
from pathlib import Path
|
|
396
|
+
|
|
397
|
+
from .config import WINDOW_DEFAULTS
|
|
398
|
+
from .pool import load_network, pool_dir
|
|
399
|
+
|
|
400
|
+
self.cfg = {**WINDOW_DEFAULTS, **(window_cfg or {})}
|
|
401
|
+
self.split, self.scenario = split, scenario
|
|
402
|
+
model = LabelModel.load(pool_dir(data_root) / "label_model.json")
|
|
403
|
+
self.index = NetworkIndex(*load_network(data_root, network), network=network, label_model=model)
|
|
404
|
+
self._root = Path(data_root)
|
|
405
|
+
|
|
406
|
+
def sample(self, seed, window_id=None):
|
|
407
|
+
window_id = window_id or f"{self.index.network}.{self.split}.{self.scenario}.s{seed}"
|
|
408
|
+
return generate_window(self.index, self.split, self.cfg, seed, window_id, self.scenario)
|
bepad/data.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Get BEPAD data: download from Hugging Face (or open a local copy) and read windows.
|
|
2
|
+
|
|
3
|
+
>>> import bepad
|
|
4
|
+
>>> test = bepad.load("test", networks=["tx"], labels=False) # downloads only what is needed
|
|
5
|
+
>>> for w in test:
|
|
6
|
+
... w.picks # pick_idx, station, phase, t_rel_s, time
|
|
7
|
+
... w.stations # station, latitude, longitude, elevation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
#: Columns of picks.parquet that carry ground truth. Never give them to an associator.
|
|
20
|
+
LABEL_COLUMNS = ("event_idx", "pick_type", "real", "prob", "sigma_s", "in_scorable_event",
|
|
21
|
+
"src_event_id", "src_pick_id")
|
|
22
|
+
INPUT_COLUMNS = ("window_id", "pick_idx", "station", "phase", "t_rel_s", "time")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class WindowData:
|
|
27
|
+
window_id: str
|
|
28
|
+
meta: pd.Series # row of windows.parquet
|
|
29
|
+
picks: pd.DataFrame # picks of this window (labels only if loaded with labels=True)
|
|
30
|
+
stations: pd.DataFrame # stations available in this window, with coordinates
|
|
31
|
+
events: pd.DataFrame | None = None # true events (labels=True only)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Split:
|
|
35
|
+
"""All windows of one split (optionally a subset of networks)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, windows, picks, window_stations, stations, events=None, labels=True):
|
|
38
|
+
self.windows = windows.reset_index(drop=True)
|
|
39
|
+
self.picks = picks
|
|
40
|
+
self.events = events
|
|
41
|
+
self.labels = labels
|
|
42
|
+
net = self.windows.set_index("window_id")["network"]
|
|
43
|
+
ws = window_stations.assign(network=window_stations["window_id"].map(net))
|
|
44
|
+
self.window_stations = ws.merge(stations, on=["network", "station"], how="left")
|
|
45
|
+
self._pick_rows = picks.groupby("window_id", sort=False).indices
|
|
46
|
+
self._sta_rows = self.window_stations.groupby("window_id", sort=False).indices
|
|
47
|
+
self._ev_rows = events.groupby("window_id", sort=False).indices if events is not None else {}
|
|
48
|
+
|
|
49
|
+
def __len__(self):
|
|
50
|
+
return len(self.windows)
|
|
51
|
+
|
|
52
|
+
def __iter__(self):
|
|
53
|
+
for i in range(len(self.windows)):
|
|
54
|
+
yield self[i]
|
|
55
|
+
|
|
56
|
+
def __getitem__(self, i):
|
|
57
|
+
if isinstance(i, str):
|
|
58
|
+
i = int(self.windows.index[self.windows["window_id"] == i][0])
|
|
59
|
+
meta = self.windows.iloc[i]
|
|
60
|
+
wid = meta["window_id"]
|
|
61
|
+
picks = self.picks.iloc[self._pick_rows.get(wid, [])].reset_index(drop=True)
|
|
62
|
+
st = self.window_stations.iloc[self._sta_rows.get(wid, [])]
|
|
63
|
+
st = st[["station", "latitude", "longitude", "elevation"]].reset_index(drop=True)
|
|
64
|
+
ev = self.events.iloc[self._ev_rows[wid]].reset_index(drop=True) if wid in self._ev_rows else None
|
|
65
|
+
return WindowData(wid, meta, picks, st, ev)
|
|
66
|
+
|
|
67
|
+
def __repr__(self):
|
|
68
|
+
nets = self.windows["network"].nunique() if len(self.windows) else 0
|
|
69
|
+
return (f"<bepad.Split {len(self)} windows, {len(self.picks)} picks, {nets} networks, "
|
|
70
|
+
f"labels={self.labels}>")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Release:
|
|
74
|
+
"""A BEPAD release folder (local copy of the Hugging Face dataset or a local build)."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, path):
|
|
77
|
+
self.path = Path(path)
|
|
78
|
+
if not (self.path / "stations.parquet").exists():
|
|
79
|
+
raise FileNotFoundError(f"{self.path} is not a BEPAD release (no stations.parquet)")
|
|
80
|
+
mf = self.path / "manifest.json"
|
|
81
|
+
self.manifest = json.loads(mf.read_text()) if mf.exists() else {}
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def splits(self):
|
|
85
|
+
return [d.name for d in sorted(self.path.iterdir()) if (d / "windows").is_dir()]
|
|
86
|
+
|
|
87
|
+
def networks(self, split="test"):
|
|
88
|
+
return sorted(f.stem.split("=", 1)[1] for f in (self.path / split / "windows").glob("network=*.parquet"))
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def label_model(self):
|
|
92
|
+
"""The label model of this release (predicted arrivals, sigma(r), prob)."""
|
|
93
|
+
from .build.labels import LabelModel
|
|
94
|
+
|
|
95
|
+
return LabelModel.load(self.path / "label_model.json")
|
|
96
|
+
|
|
97
|
+
def scenarios(self):
|
|
98
|
+
return json.loads((self.path / "scenarios.json").read_text())
|
|
99
|
+
|
|
100
|
+
def _read(self, split, table, networks, columns=None):
|
|
101
|
+
files = sorted((self.path / split / table).glob("network=*.parquet"))
|
|
102
|
+
if networks is not None:
|
|
103
|
+
keep = {f"network={n}.parquet" for n in networks}
|
|
104
|
+
files = [f for f in files if f.name in keep]
|
|
105
|
+
if not files:
|
|
106
|
+
raise FileNotFoundError(f"no {table} files for split={split!r}, networks={networks}")
|
|
107
|
+
return pd.concat([pd.read_parquet(f, columns=columns) for f in files], ignore_index=True)
|
|
108
|
+
|
|
109
|
+
def load(self, split, networks=None, labels=True):
|
|
110
|
+
"""Load one split. ``labels=False`` returns only what an associator may use."""
|
|
111
|
+
cols = None if labels else list(INPUT_COLUMNS)
|
|
112
|
+
s = Split(
|
|
113
|
+
windows=self._read(split, "windows", networks),
|
|
114
|
+
picks=self._read(split, "picks", networks, columns=cols),
|
|
115
|
+
window_stations=self._read(split, "window_stations", networks),
|
|
116
|
+
stations=pd.read_parquet(self.path / "stations.parquet"),
|
|
117
|
+
events=self._read(split, "events", networks) if labels else None,
|
|
118
|
+
labels=labels,
|
|
119
|
+
)
|
|
120
|
+
s.release, s.name = self, split
|
|
121
|
+
return s
|
|
122
|
+
|
|
123
|
+
def __repr__(self):
|
|
124
|
+
return f"<bepad.Release {self.path.name}: splits={self.splits}>"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
REPO_ID = "ecastillot/BEPAD"
|
|
128
|
+
RELEASE = "v0.1" # the current release (a folder of the Hugging Face dataset)
|
|
129
|
+
# The core test set: every leaderboard entry must run these networks; the default leaderboard
|
|
130
|
+
# view ranks methods by their mean over them. ``"core"`` selects them (``--networks core``).
|
|
131
|
+
CORE_NAME = "BEPAD-10"
|
|
132
|
+
CORE_NETWORKS = ["R05", "R07", "R01", "nc", "tx", "JMA", "CM", "R09", "ak", "NOA"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _networks(networks):
|
|
136
|
+
"""``"core"`` in a list of networks stands for the core networks."""
|
|
137
|
+
if networks is None:
|
|
138
|
+
return None
|
|
139
|
+
networks = [networks] if isinstance(networks, str) else list(networks)
|
|
140
|
+
return list(dict.fromkeys(m for n in networks for m in (CORE_NETWORKS if n == "core" else [n])))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def download(release=RELEASE, splits=("test",), networks=None, repo_id=REPO_ID, local_dir=None):
|
|
144
|
+
"""Download (and cache) part of a release from Hugging Face; returns the local release folder.
|
|
145
|
+
|
|
146
|
+
Only the requested splits/networks are fetched, plus the small shared files
|
|
147
|
+
(stations, scenarios, label model, manifest). Re-running is free: files are cached.
|
|
148
|
+
"""
|
|
149
|
+
from huggingface_hub import snapshot_download
|
|
150
|
+
|
|
151
|
+
networks = _networks(networks)
|
|
152
|
+
patterns = [f"{release}/*.json", f"{release}/stations.parquet", f"{release}/metadata/*"]
|
|
153
|
+
for sp in splits:
|
|
154
|
+
if networks is None:
|
|
155
|
+
patterns.append(f"{release}/{sp}/*")
|
|
156
|
+
else:
|
|
157
|
+
patterns += [f"{release}/{sp}/*/network={n}.parquet" for n in networks]
|
|
158
|
+
path = snapshot_download(repo_id, repo_type="dataset", allow_patterns=patterns, local_dir=local_dir)
|
|
159
|
+
return Path(path) / release
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def open(path): # noqa: A001 -- mirrors the usual `dataset.open` idiom
|
|
163
|
+
"""Open a local release folder."""
|
|
164
|
+
return Release(path)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load(split="test", networks=None, labels=True, release=RELEASE, root=None, repo_id=REPO_ID):
|
|
168
|
+
"""Load one split as a :class:`Split` (iterate it to get windows).
|
|
169
|
+
|
|
170
|
+
``root``: a local release folder (default: the ``BEPAD_ROOT`` environment
|
|
171
|
+
variable); if neither is set, the data are downloaded from Hugging Face
|
|
172
|
+
(``repo_id``, ``release``) and cached.
|
|
173
|
+
``labels=False`` returns only what an associator may use. ``networks=["core"]``: the
|
|
174
|
+
:data:`CORE_NETWORKS`.
|
|
175
|
+
"""
|
|
176
|
+
networks = _networks(networks)
|
|
177
|
+
root = root or os.environ.get("BEPAD_ROOT") # a local copy, e.g. export BEPAD_ROOT=data/releases/v0.1
|
|
178
|
+
path = Path(root) if root else download(release, splits=(split,), networks=networks, repo_id=repo_id)
|
|
179
|
+
return Release(path).load(split, networks=networks, labels=labels)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def predictions_frame(window_ids, pick_idx, pred_event):
|
|
183
|
+
"""Build a predictions table in the expected format."""
|
|
184
|
+
return pd.DataFrame({"window_id": window_ids, "pick_idx": pick_idx, "pred_event": pred_event}) \
|
|
185
|
+
.astype({"pick_idx": "int64", "pred_event": "int64"})
|