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.
@@ -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"})